= ({
className,
name,
label,
labelProps,
textareaProps,
isRequired,
}) => {
const { field, fieldState } = useController<{ __name__: string }, '__name__'>(
{ name: name as '__name__' },
);
return (
{typeof label !== 'undefined' && (
{label}
{isRequired ? * : ''}
)}
{fieldState.error && (
{fieldState.error.message}
)}
);
};
================================================
FILE: medusa/src/admin/components/QueryClientProvider.tsx
================================================
import * as React from 'react';
import {
QueryClient,
QueryClientProvider as TanstackQueryClientProvider,
} from '@tanstack/react-query';
const queryClient = new QueryClient();
export const QueryClientProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
return (
{children}
);
};
export const withQueryClient = (
Component: React.ComponentType
,
) => {
return (props: P & JSX.IntrinsicAttributes) => (
);
};
================================================
FILE: medusa/src/admin/hooks/fashion.ts
================================================
import {
useMutation,
UseMutationOptions,
useQueryClient,
} from '@tanstack/react-query';
export const useCreateMaterialMutation = (
options:
| Omit<
UseMutationOptions<
any,
Error,
{
name: string;
},
unknown
>,
'mutationKey' | 'mutationFn'
>
| undefined = undefined,
) => {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['fashion', 'create'],
mutationFn: async (values: { name: string }) => {
return fetch('/admin/fashion', {
method: 'POST',
body: JSON.stringify(values),
credentials: 'include',
}).then((res) => res.json());
},
...options,
onSuccess: async (...args) => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'fashion',
});
if (options?.onSuccess) {
return options.onSuccess(...args);
}
},
});
};
export const useCreateColorMutation = (
material_id: string,
options:
| Omit<
UseMutationOptions<
any,
Error,
{ name: string; hex_code: string },
unknown
>,
'mutationKey' | 'mutationFn'
>
| undefined = undefined,
) => {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['fashion', material_id, 'colors', 'create'],
mutationFn: async (values: { name: string; hex_code: string }) => {
return fetch(`/admin/fashion/${material_id}/colors`, {
method: 'POST',
body: JSON.stringify(values),
credentials: 'include',
}).then((res) => res.json());
},
...options,
onSuccess: async (...args) => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'fashion',
});
if (options?.onSuccess) {
return options.onSuccess(...args);
}
},
});
};
================================================
FILE: medusa/src/admin/hooks/images.ts
================================================
import { HttpTypes } from '@medusajs/framework/types';
import { UseMutationOptions, useMutation } from '@tanstack/react-query';
const getFileBase64EncodedContent = (file: File) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(
(reader.result as string).replace('data:', '').replace(/^.+,/, ''),
);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
const createPayload = async (payload: HttpTypes.AdminUploadFile) => {
if (payload instanceof FileList) {
const formData = new FormData();
for (const file of payload) {
formData.append('files', file);
}
return formData;
}
if (payload.files.every((f) => f instanceof File)) {
const formData = new FormData();
for (const file of payload.files) {
formData.append('files', file);
}
return formData;
}
const obj: {
files: {
name: string;
content: string;
}[];
} = {
files: [],
};
for (const file of payload.files) {
if (file instanceof File) {
obj.files.push({
name: file.name,
content: await getFileBase64EncodedContent(file),
});
} else {
obj.files.push(file);
}
}
return JSON.stringify(obj);
};
export const useAdminUploadImage = (
options?: UseMutationOptions<
HttpTypes.AdminFileListResponse,
Error,
HttpTypes.AdminUploadFile
>,
) => {
return useMutation<
HttpTypes.AdminFileListResponse,
Error,
HttpTypes.AdminUploadFile
>({
mutationKey: ['admin-upload-image'],
mutationFn: async (payload) => {
const res = await fetch(`/admin/uploads`, {
method: 'POST',
body: await createPayload(payload),
credentials: 'include',
});
if (!res.ok) {
throw new Error(res.statusText);
}
return res.json();
},
...options,
});
};
================================================
FILE: medusa/src/admin/routes/fashion/[id]/page.tsx
================================================
import * as React from 'react';
import { z } from 'zod';
import { useParams } from 'react-router-dom';
import {
Container,
Heading,
Text,
IconButton,
Table,
Button,
Drawer,
DropdownMenu,
Prompt,
Switch,
Label,
Kbd,
} from '@medusajs/ui';
import {
PencilSquare,
EllipsisHorizontal,
Trash,
ArrowPath,
} from '@medusajs/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import type { MaterialModelType } from '../../../../modules/fashion/models/material';
import { ColorModelType } from '../../../../modules/fashion/models/color';
import { useCreateColorMutation } from '../../../hooks/fashion';
import { Form } from '../../../components/Form/Form';
import { InputField } from '../../../components/Form/InputField';
import { EditMaterialDrawer } from '../../../components/EditMaterialDrawer';
import { withQueryClient } from '../../../components/QueryClientProvider';
const colorFormSchema = z.object({
name: z.string().min(1),
hex_code: z.string().min(7).max(7),
});
const EditColorDrawer: React.FC<{
materialId: string;
id: string;
initialValues: z.infer;
children: React.ReactNode;
}> = ({ materialId, id, initialValues, children }) => {
const queryClient = useQueryClient();
const [isDrawerOpen, setIsDrawerOpen] = React.useState(false);
const updateColorMutation = useMutation({
mutationKey: ['fashion', materialId, 'colors', id, 'update'],
mutationFn: async (values: z.infer) => {
return fetch(`/admin/fashion/${materialId}/colors/${id}`, {
method: 'POST',
body: JSON.stringify(values),
credentials: 'include',
}).then((res) => res.json());
},
onSuccess: async () => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'fashion',
});
},
});
return (
{children}
Edit Color
Cancel
Update
);
};
const DeleteColorPrompt: React.FC<{
materialId: string;
id: string;
name: string;
children: React.ReactNode;
}> = ({ materialId, name, id, children }) => {
const queryClient = useQueryClient();
const [isPromptOpen, setIsPromptOpen] = React.useState(false);
const deleteColorMutation = useMutation({
mutationKey: ['fashion', materialId, 'colors', id, 'delete'],
mutationFn: async () => {
return fetch(`/admin/fashion/${materialId}/colors/${id}`, {
method: 'DELETE',
credentials: 'include',
}).then((res) => res.json());
},
onSuccess: async () => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'fashion',
});
setIsPromptOpen(false);
},
});
return (
{children}
Delete {name} color?
Are you sure you want to delete the color {name}?
Cancel
{
deleteColorMutation.mutate();
}}
>
Delete
);
};
const RestoreColorPrompt: React.FC<{
materialId: string;
id: string;
name: string;
children: React.ReactNode;
}> = ({ materialId, name, id, children }) => {
const queryClient = useQueryClient();
const [isPromptOpen, setIsPromptOpen] = React.useState(false);
const restoreColorMutation = useMutation({
mutationKey: ['fashion', materialId, 'colors', id, 'restore'],
mutationFn: async () => {
return fetch(`/admin/fashion/${materialId}/colors/${id}/restore`, {
method: 'POST',
credentials: 'include',
}).then((res) => res.json());
},
onSuccess: async () => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'fashion',
});
setIsPromptOpen(false);
},
});
return (
{children}
Restore {name} color?
Are you sure you want to restore the color {name}?
Cancel
{
restoreColorMutation.mutate();
}}
>
Restore
);
};
const MaterialColors: React.FC<{ materialId: string }> = ({ materialId }) => {
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get('page')) || 1;
const setPage = React.useCallback(
(page: number) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('page', page.toString());
return next;
});
},
[setSearchParams]
);
const deleted = searchParams.has('deleted');
const toggleDeleted = React.useCallback(() => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (prev.has('page')) {
next.delete('page');
}
if (!prev.has('deleted')) {
next.set('deleted', '');
} else {
next.delete('deleted');
}
return next;
});
}, [setSearchParams]);
const [isCreateModalOpen, setIsCreateModalOpen] = React.useState(false);
const { data, isLoading, isError, isSuccess } = useQuery({
queryKey: ['fashion', materialId, 'colors', deleted, page],
queryFn: async () => {
return fetch(
`/admin/fashion/${materialId}/colors?page=${page}${
deleted ? '&deleted=true' : ''
}`,
{
credentials: 'include',
}
).then(
(res) =>
res.json() as Promise<{
colors: ColorModelType[];
count: number;
page: number;
last_page: number;
}>
);
},
});
const createColorMutation = useCreateColorMutation(materialId);
return (
Colors
{
toggleDeleted();
}}
/>
Show Deleted
Create
Create Color
Cancel
Create
Name
Hex Code
{isLoading && (
{/* @ts-ignore */}
Loading...
)}
{isError && (
{/* @ts-ignore */}
Error loading colors
)}
{isSuccess && data.colors.length === 0 && (
{/* @ts-ignore */}
No colors found
)}
{isSuccess &&
data.colors.length > 0 &&
data.colors.map((color) => (
{color.name}
{color.hex_code}
Edit
{color.deleted_at ? (
Restore
) : (
Delete
)}
))}
1}
canNextPage={page < (data?.last_page ?? 1)}
previousPage={() => setPage(Math.max(1, page - 1))}
nextPage={() => setPage(Math.min(page + 1, data?.last_page ?? 1))}
/>
);
};
const MaterialPage = () => {
const { id } = useParams();
const { data, isLoading, isError, isSuccess } = useQuery({
queryKey: ['fashion', id],
queryFn: async () => {
const res = await fetch(`/admin/fashion/${id}`, {
credentials: 'include',
});
return res.json() as Promise;
},
});
if (!id) {
return null;
}
return (
{isLoading && Loading... }
{isError && Error loading material }
{isSuccess && (
<>
>
)}
);
};
export default withQueryClient(MaterialPage);
================================================
FILE: medusa/src/admin/routes/fashion/page.tsx
================================================
import * as React from 'react';
import { defineRouteConfig } from '@medusajs/admin-sdk';
import {
Swatch,
PencilSquare,
EllipsisHorizontal,
Trash,
ArrowPath,
} from '@medusajs/icons';
import {
Container,
Heading,
Table,
Button,
IconButton,
Text,
Drawer,
DropdownMenu,
Prompt,
Switch,
Label,
} from '@medusajs/ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams, Link } from 'react-router-dom';
import { MaterialModelType } from '../../../modules/fashion/models/material';
import { useCreateMaterialMutation } from '../../hooks/fashion';
import { Form } from '../../components/Form/Form';
import { InputField } from '../../components/Form/InputField';
import {
EditMaterialDrawer,
materialFormSchema,
} from '../../components/EditMaterialDrawer';
import { withQueryClient } from '../../components/QueryClientProvider';
const DeleteMaterialPrompt: React.FC<{
id: string;
name: string;
children: React.ReactNode;
}> = ({ id, name, children }) => {
const queryClient = useQueryClient();
const [isPromptOpen, setIsPromptOpen] = React.useState(false);
const deleteMaterialMutation = useMutation({
mutationKey: ['fashion', id, 'delete'],
mutationFn: async () => {
return fetch(`/admin/fashion/${id}`, {
method: 'DELETE',
credentials: 'include',
}).then((res) => res.json());
},
onSuccess: async () => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'fashion',
});
setIsPromptOpen(false);
},
});
return (
{children}
Delete {name} material?
Are you sure you want to delete the material {name}?
Cancel
{
deleteMaterialMutation.mutate();
}}
>
Delete
);
};
const RestoreMaterialPrompt: React.FC<{
id: string;
name: string;
children: React.ReactNode;
}> = ({ id, name, children }) => {
const queryClient = useQueryClient();
const [isPromptOpen, setIsPromptOpen] = React.useState(false);
const restoreMaterialMutation = useMutation({
mutationKey: ['fashion', id, 'restore'],
mutationFn: async () => {
return fetch(`/admin/fashion/${id}/restore`, {
method: 'POST',
credentials: 'include',
}).then((res) => res.json());
},
onSuccess: async () => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'fashion',
});
setIsPromptOpen(false);
},
});
return (
{children}
Restore {name} material?
Are you sure you want to restore the material {name}?
Cancel
{
restoreMaterialMutation.mutate();
}}
>
Restore
);
};
const FashionPage = () => {
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get('page')) || 1;
const setPage = React.useCallback(
(page: number) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('page', page.toString());
return next;
});
},
[setSearchParams]
);
const deleted = searchParams.has('deleted');
const toggleDeleted = React.useCallback(() => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (prev.has('page')) {
next.delete('page');
}
if (!prev.has('deleted')) {
next.set('deleted', '');
} else {
next.delete('deleted');
}
return next;
});
}, [setSearchParams]);
const [isCreateModalOpen, setIsCreateModalOpen] = React.useState(false);
const { data, isLoading, isError, isSuccess } = useQuery({
queryKey: ['fashion', deleted, page],
queryFn: async () => {
return fetch(
`/admin/fashion?page=${page}${deleted ? '&deleted=true' : ''}`,
{
credentials: 'include',
}
).then(
(res) =>
res.json() as Promise<{
materials: MaterialModelType[];
count: number;
page: number;
last_page: number;
}>
);
},
});
const createMaterialMutation = useCreateMaterialMutation();
return (
Materials
{
toggleDeleted();
}}
/>
Show Deleted
Create
Create Material
Cancel
Create
Name
{isLoading && (
{/* @ts-ignore */}
Loading...
)}
{isError && (
{/* @ts-ignore */}
Error loading materials
)}
{isSuccess && data.materials.length === 0 && (
{/* @ts-ignore */}
No materials found
)}
{isSuccess &&
data.materials.length > 0 &&
data.materials.map((material) => (
{material.name}
Edit
{material.deleted_at ? (
Restore
) : (
Delete
)}
))}
1}
canNextPage={page < (data?.last_page ?? 1)}
previousPage={() => setPage(Math.max(1, page - 1))}
nextPage={() => setPage(Math.min(page + 1, data?.last_page ?? 1))}
/>
);
};
export default withQueryClient(FashionPage);
export const config = defineRouteConfig({
label: 'Materials & Colors',
icon: Swatch,
});
================================================
FILE: medusa/src/admin/tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["."]
}
================================================
FILE: medusa/src/admin/widgets/collection-details.tsx
================================================
import * as React from 'react';
import { defineWidgetConfig } from '@medusajs/admin-sdk';
import { DetailWidgetProps, AdminCollection } from '@medusajs/framework/types';
import { Container, Heading, Button, Drawer, Text } from '@medusajs/ui';
import { PencilSquare } from '@medusajs/icons';
import { z } from 'zod';
import { ImageField, imageFieldSchema } from '../components/Form/ImageField';
import { Form } from '../components/Form/Form';
import { TextareaField } from '../components/Form/TextareaField';
import { InputField } from '../components/Form/InputField';
const detailsFormSchema = z.object({
image: imageFieldSchema().optional(),
description: z.string().optional(),
collection_page_image: imageFieldSchema().optional(),
collection_page_heading: z.string().optional(),
collection_page_content: z.string().optional(),
product_page_heading: z.string().optional(),
product_page_image: imageFieldSchema().optional(),
product_page_wide_image: imageFieldSchema().optional(),
product_page_cta_image: imageFieldSchema().optional(),
product_page_cta_heading: z.string().optional(),
product_page_cta_link: z.string().optional(),
});
const UpdateDetailsDrawer: React.FC<{
children: React.ReactNode;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
id: string;
title: React.ReactNode;
initialValue: z.infer;
onSave: (values: z.infer) => void;
}> = ({ children, isOpen, onOpenChange, id, title, initialValue, onSave }) => {
return (
{children}
{title}
Cancel
Save
);
};
const CollectionDetailsWidget = ({
data,
}: DetailWidgetProps) => {
const [isEditModalOpen, setIsModalOpen] = React.useState(false);
const [details, setDetails] = React.useState | null>(null);
React.useEffect(() => {
fetch(`/admin/custom/collections/${data.id}/details`, {
credentials: 'include',
})
.then((res) => res.json())
.then((json) => {
setDetails(json);
})
.catch((e) => {
console.error(e);
});
}, [data.id]);
return (
Details
{details !== null && (
{
setDetails(value);
setIsModalOpen(false);
}}
>
{
event.preventDefault();
setIsModalOpen(true);
}}
>
Edit
)}
{details === null ? (
Loading...
) : (
{typeof details.image?.url === 'string' && (
)}
{(details.description?.length ?? 0) > 0 && (
{details.description}
)}
{typeof details.image?.url !== 'string' && !details.description && (
No details available
)}
Collection Page
{typeof details.collection_page_image?.url === 'string' && (
)}
{(details.collection_page_heading?.length ?? 0) > 0 && (
{details.collection_page_heading}
)}
{(details.collection_page_content?.length ?? 0) > 0 && (
{details.collection_page_content}
)}
{typeof details.collection_page_image?.url !== 'string' &&
!details.collection_page_heading &&
!details.collection_page_content && (
Collection page details not entered
)}
Product Page
{typeof details.product_page_heading?.length === 'string' && (
{details.product_page_heading}
)}
{typeof details.product_page_image?.url === 'string' && (
)}
{typeof details.product_page_wide_image?.url === 'string' && (
)}
{typeof details.product_page_cta_image?.url === 'string' && (
)}
{(details.product_page_cta_heading?.length ?? 0) > 0 && (
{details.product_page_cta_heading}
)}
{(details.product_page_cta_link?.length ?? 0) > 0 && (
{details.product_page_cta_link}
)}
{typeof details.product_page_heading?.length !== 'string' &&
typeof details.product_page_image?.url !== 'string' &&
typeof details.product_page_wide_image?.url !== 'string' &&
typeof details.product_page_cta_image?.url !== 'string' &&
!details.product_page_cta_heading &&
!details.product_page_cta_link && (
Product page details not entered
)}
)}
);
};
export const config = defineWidgetConfig({
zone: 'product_collection.details.after',
});
export default CollectionDetailsWidget;
================================================
FILE: medusa/src/admin/widgets/product-fashion.tsx
================================================
import * as React from 'react';
import { defineWidgetConfig } from '@medusajs/admin-sdk';
import { DetailWidgetProps, AdminProduct } from '@medusajs/framework/types';
import {
Container,
Heading,
Text,
Button,
Drawer,
IconButton,
} from '@medusajs/ui';
import { ArrowPath, PlusMini } from '@medusajs/icons';
import { z } from 'zod';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { MaterialModelType } from '../../modules/fashion/models/material';
import { Form } from '../components/Form/Form';
import { withQueryClient } from '../components/QueryClientProvider';
import {
useCreateColorMutation,
useCreateMaterialMutation,
} from '../hooks/fashion';
import { InputField } from '../components/Form/InputField';
// const SelectColorField: React.FC<{
// name: string;
// }> = ({ name }) => {
// const materialsQuery = useInfiniteQuery({
// queryKey: ['fashion'],
// queryFn: async ({ pageParam = 1, signal }) => {
// const res = await fetch(`/admin/fashion?page=${pageParam}`, {
// credentials: 'include',
// signal,
// });
// return res.json() as Promise<{
// materials: MaterialModelType[];
// count: number;
// page: number;
// last_page: number;
// }>;
// },
// initialPageParam: 1,
// getNextPageParam: (lastPage) => {
// return lastPage.page < lastPage.last_page ? lastPage.page + 1 : undefined;
// },
// getPreviousPageParam: (firstPage) => {
// return firstPage.page > 1 ? firstPage.page - 1 : undefined;
// },
// });
// return (
//
//
//
//
//
// {materialsQuery.isSuccess &&
// materialsQuery.data.pages.map((materialsPageData) =>
// materialsPageData.materials.map((material) => (
//
// {material.name}
// {material.colors.map((color) => (
//
// {color.name}
//
// ))}
//
// )),
// )}
// {materialsQuery.isSuccess && materialsQuery.hasNextPage && (
// {
// event.preventDefault();
// if (materialsQuery.isFetchingNextPage) {
// return;
// }
// materialsQuery.fetchNextPage();
// }}
// >
// {materialsQuery.isFetchingNextPage ? 'Loading...' : 'Load more'}
//
// )}
//
//
// );
// };
const addColorFormSchema = z.object({
name: z.string().min(1),
hex_code: z.string().min(7).max(7),
});
const AddColorDrawer: React.FC<{
materialId: string;
name: string;
children: React.ReactNode;
}> = ({ materialId, name, children }) => {
const queryClient = useQueryClient();
const [isDrawerOpen, setIsDrawerOpen] = React.useState(false);
const createColorMutation = useCreateColorMutation(materialId, {
onSuccess: async () => {
await queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey.length >= 3 &&
query.queryKey[0] === 'product' &&
query.queryKey[2] === 'fashion',
});
setIsDrawerOpen(false);
},
});
return (
{children}
Add new color
Cancel
Save
);
};
const ProductFashionWidget = withQueryClient(
({ data }: DetailWidgetProps) => {
const productFashion = useQuery({
queryKey: ['product', data.id, 'fashion'],
queryFn: async ({ signal }) => {
const res = await fetch(`/admin/products/${data.id}/fashion`, {
credentials: 'include',
signal,
});
return res.json() as Promise<{
missing_materials: string[];
materials: (MaterialModelType & { missing_colors: string[] })[];
}>;
},
});
const createMaterialMutation = useCreateMaterialMutation({
onSuccess: () => {
productFashion.refetch();
},
});
const materialsData = [
...(productFashion.data?.missing_materials ?? []),
...(productFashion.data?.materials ?? []),
].sort((a, b) => {
const aName = typeof a === 'string' ? a : a.name;
const bName = typeof b === 'string' ? b : b.name;
return aName.localeCompare(bName);
});
return (
Materials & Colors
{
event.preventDefault();
productFashion.refetch();
}}
disabled={productFashion.isFetching}
isLoading={productFashion.isFetching}
>
{productFashion.isLoading ? (
Loading...
) : productFashion.isError ? (
Error loading product materials
) : productFashion.isSuccess &&
productFashion.data &&
!materialsData.length ? (
No product variants with Material option
) : productFashion.isSuccess && productFashion.data ? (
{materialsData.map((material) => (
{typeof material === 'string' ? material : material.name}
{typeof material === 'string' ? (
{
event.preventDefault();
createMaterialMutation.mutate({
name: material,
});
}}
>
Create material
) : (
{material.colors.map((color) => (
))}
{material.missing_colors.map((color) => (
))}
)}
))}
) : (
No fashion details set
)}
);
}
);
export const config = defineWidgetConfig({
zone: 'product.details.side.before',
});
export default ProductFashionWidget;
================================================
FILE: medusa/src/admin/widgets/product-type-details.tsx
================================================
import * as React from 'react';
import { defineWidgetConfig } from '@medusajs/admin-sdk';
import { DetailWidgetProps, AdminCollection } from '@medusajs/framework/types';
import { Container, Heading, Button, Drawer, Text } from '@medusajs/ui';
import { PencilSquare } from '@medusajs/icons';
import { z } from 'zod';
import { ImageField, imageFieldSchema } from '../components/Form/ImageField';
import { Form } from '../components/Form/Form';
const detailsFormSchema = z.object({
image: imageFieldSchema().optional(),
});
const UpdateDetailsDrawer: React.FC<{
children: React.ReactNode;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
id: string;
title: React.ReactNode;
initialValue: z.infer;
onSave: (values: z.infer) => void;
}> = ({ children, isOpen, onOpenChange, id, title, initialValue, onSave }) => {
return (
{children}
{title}
Cancel
Save
);
};
const ProductTypeDetailsWidget = ({
data,
}: DetailWidgetProps) => {
const [isEditModalOpen, setIsModalOpen] = React.useState(false);
const [details, setDetails] = React.useState | null>(null);
React.useEffect(() => {
fetch(`/admin/custom/product-types/${data.id}/details`, {
credentials: 'include',
})
.then((res) => res.json())
.then((json) => {
setDetails(json);
})
.catch((e) => {
console.error(e);
});
}, [data.id]);
return (
Details
{details !== null && (
{
setDetails(value);
setIsModalOpen(false);
}}
>
{
event.preventDefault();
setIsModalOpen(true);
}}
>
Edit
)}
{details === null ? (
Loading...
) : (
{typeof details.image?.url === 'string' ? (
) : (
No image
)}
)}
);
};
export const config = defineWidgetConfig({
zone: 'product_type.details.after',
});
export default ProductTypeDetailsWidget;
================================================
FILE: medusa/src/api/README.md
================================================
# Custom API Routes
An API Route is a REST API endpoint.
An API Route is created in a TypeScript or JavaScript file under the `/src/api` directory of your Medusa application. The file’s name must be `route.ts` or `route.js`.
For example, to create a `GET` API Route at `/store/hello-world`, create the file `src/api/store/hello-world/route.ts` with the following content:
```ts
import type { MedusaRequest, MedusaResponse } from "@medusajs/medusa";
export async function GET(req: MedusaRequest, res: MedusaResponse) {
res.json({
message: "Hello world!",
});
}
```
## Supported HTTP methods
The file based routing supports the following HTTP methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
- HEAD
You can define a handler for each of these methods by exporting a function with the name of the method in the paths `route.ts` file.
For example:
```ts
import type { MedusaRequest, MedusaResponse } from "@medusajs/medusa";
export async function GET(req: MedusaRequest, res: MedusaResponse) {
// Handle GET requests
}
export async function POST(req: MedusaRequest, res: MedusaResponse) {
// Handle POST requests
}
export async function PUT(req: MedusaRequest, res: MedusaResponse) {
// Handle PUT requests
}
```
## Parameters
To create an API route that accepts a path parameter, create a directory within the route's path whose name is of the format `[param]`.
For example, if you want to define a route that takes a `productId` parameter, you can do so by creating a file called `/api/products/[productId]/route.ts`:
```ts
import type {
MedusaRequest,
MedusaResponse,
} from "@medusajs/medusa"
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const { productId } = req.params;
res.json({
message: `You're looking for product ${productId}`
})
}
```
To create an API route that accepts multiple path parameters, create within the file's path multiple directories whose names are of the format `[param]`.
For example, if you want to define a route that takes both a `productId` and a `variantId` parameter, you can do so by creating a file called `/api/products/[productId]/variants/[variantId]/route.ts`.
## Using the container
The Medusa container is available on `req.scope`. Use it to access modules' main services and other registered resources:
```ts
import type {
MedusaRequest,
MedusaResponse,
} from "@medusajs/medusa"
import { IProductModuleService } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
export const GET = async (
req: MedusaRequest,
res: MedusaResponse
) => {
const productModuleService: IProductModuleService =
req.scope.resolve(Modules.PRODUCT)
const [, count] = await productModuleService.listAndCount()
res.json({
count,
})
}
```
## Middleware
You can apply middleware to your routes by creating a file called `/api/middlewares.ts`. This file must export a configuration object with what middleware you want to apply to which routes.
For example, if you want to apply a custom middleware function to the `/store/custom` route, you can do so by adding the following to your `/api/middlewares.ts` file:
```ts
import { defineMiddlewares } from "@medusajs/medusa"
import type {
MedusaRequest,
MedusaResponse,
MedusaNextFunction,
} from "@medusajs/medusa";
async function logger(
req: MedusaRequest,
res: MedusaResponse,
next: MedusaNextFunction
) {
console.log("Request received");
next();
}
export default defineMiddlewares({
routes: [
{
matcher: "/store/custom",
middlewares: [logger],
},
],
})
```
The `matcher` property can be either a string or a regular expression. The `middlewares` property accepts an array of middleware functions.
================================================
FILE: medusa/src/api/admin/custom/collections/[collectionId]/details/route.ts
================================================
import { Modules } from '@medusajs/framework/utils';
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import { z } from '@medusajs/framework/zod';
const collectionFieldsMetadataSchema = z.object({
image: z
.object({
id: z.string(),
url: z.string().url(),
})
.optional(),
description: z.string().optional(),
collection_page_image: z
.object({
id: z.string(),
url: z.string().url(),
})
.optional(),
collection_page_heading: z.string().optional(),
collection_page_content: z.string().optional(),
product_page_heading: z.string().optional(),
product_page_image: z
.object({
id: z.string(),
url: z.string().url(),
})
.optional(),
product_page_wide_image: z
.object({
id: z.string(),
url: z.string().url(),
})
.optional(),
product_page_cta_image: z
.object({
id: z.string(),
url: z.string().url(),
})
.optional(),
product_page_cta_heading: z.string().optional(),
product_page_cta_link: z.string().optional(),
});
export async function GET(
req: MedusaRequest,
res: MedusaResponse,
): Promise {
const { collectionId } = req.params;
const productService = req.scope.resolve(Modules.PRODUCT);
const collection =
await productService.retrieveProductCollection(collectionId);
const parsed = collectionFieldsMetadataSchema.safeParse(
collection.metadata ?? {},
);
res.json({
image: parsed.success && parsed.data.image ? parsed.data.image : null,
description:
parsed.success && parsed.data.description ? parsed.data.description : '',
collection_page_image:
parsed.success && parsed.data.collection_page_image
? parsed.data.collection_page_image
: null,
collection_page_heading:
parsed.success && parsed.data.collection_page_heading
? parsed.data.collection_page_heading
: '',
collection_page_content:
parsed.success && parsed.data.collection_page_content
? parsed.data.collection_page_content
: '',
product_page_heading:
parsed.success && parsed.data.product_page_heading
? parsed.data.product_page_heading
: '',
product_page_image:
parsed.success && parsed.data.product_page_image
? parsed.data.product_page_image
: null,
product_page_wide_image:
parsed.success && parsed.data.product_page_wide_image
? parsed.data.product_page_wide_image
: null,
product_page_cta_image:
parsed.success && parsed.data.product_page_cta_image
? parsed.data.product_page_cta_image
: null,
product_page_cta_heading:
parsed.success && parsed.data.product_page_cta_heading
? parsed.data.product_page_cta_heading
: '',
product_page_cta_link:
parsed.success && parsed.data.product_page_cta_link
? parsed.data.product_page_cta_link
: '',
});
}
export async function POST(
req: MedusaRequest,
res: MedusaResponse,
): Promise {
const { collectionId } = req.params;
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
const customFields = collectionFieldsMetadataSchema.parse(body);
const productService = req.scope.resolve(Modules.PRODUCT);
const collection =
await productService.retrieveProductCollection(collectionId);
const updatedCollection = await productService.updateProductCollections(
collectionId,
{
metadata: {
...collection.metadata,
...customFields,
},
},
);
res.json(updatedCollection);
}
================================================
FILE: medusa/src/api/admin/custom/index-products/route.ts
================================================
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from '@medusajs/framework';
import { indexProductsWorkflow } from '../../../../workflows/index-products';
export async function POST(
req: AuthenticatedMedusaRequest,
res: MedusaResponse,
): Promise {
const result = await indexProductsWorkflow(req.scope).run();
res.json(result);
}
================================================
FILE: medusa/src/api/admin/custom/product-types/[productTypeId]/details/route.ts
================================================
import { Modules } from '@medusajs/framework/utils';
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import { z } from '@medusajs/framework/zod';
const productTypeFieldsMetadataSchema = z.object({
image: z
.object({
id: z.string(),
url: z.string().url(),
})
.optional(),
});
export async function GET(
req: MedusaRequest,
res: MedusaResponse,
): Promise {
const { productTypeId } = req.params;
const productService = req.scope.resolve(Modules.PRODUCT);
const productType = await productService.retrieveProductType(productTypeId);
const parsed = productTypeFieldsMetadataSchema.safeParse(
productType.metadata ?? {},
);
res.json({
image: parsed.success && parsed.data.image ? parsed.data.image : null,
});
}
export async function POST(
req: MedusaRequest,
res: MedusaResponse,
): Promise {
const { productTypeId } = req.params;
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
const customFields = productTypeFieldsMetadataSchema.parse(body);
const productService = req.scope.resolve(Modules.PRODUCT);
const productType = await productService.retrieveProductType(productTypeId);
const updatedProductType = await productService.updateProductTypes(
productTypeId,
{
metadata: {
...productType.metadata,
...customFields,
},
},
);
res.json(updatedProductType);
}
================================================
FILE: medusa/src/api/admin/fashion/[id]/colors/[colorId]/restore/route.ts
================================================
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import FashionModuleService from '../../../../../../../modules/fashion/service';
import { FASHION_MODULE } from '../../../../../../../modules/fashion';
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
await fashionModuleService.retrieveMaterial(req.params.id, {
withDeleted: true,
});
await fashionModuleService.restoreColors(req.params.colorId);
const color = await fashionModuleService.retrieveColor(req.params.colorId, {
withDeleted: true,
});
res.status(200).json(color);
};
================================================
FILE: medusa/src/api/admin/fashion/[id]/colors/[colorId]/route.ts
================================================
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import { z } from '@medusajs/framework/zod';
import FashionModuleService from '../../../../../../modules/fashion/service';
import { FASHION_MODULE } from '../../../../../../modules/fashion';
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
await fashionModuleService.retrieveMaterial(req.params.id, {
withDeleted: true,
});
const color = await fashionModuleService.retrieveColor(req.params.colorId, {
withDeleted: true,
});
res.status(200).json(color);
};
const colorsUpdateBodySchema = z.object({
name: z.string().min(1),
hex_code: z
.string()
.min(1)
.transform((val) => val.toUpperCase())
.refine((val) => /^#([A-F0-9]{6}|[A-F0-9]{3})$/.test(val), {
message: 'Invalid hex code',
}),
});
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
await fashionModuleService.retrieveMaterial(req.params.id, {
withDeleted: true,
});
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
const validatedData = colorsUpdateBodySchema.parse(body);
const color = await fashionModuleService.updateColors({
...validatedData,
id: req.params.colorId,
});
res.status(200).json(color);
};
export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
await fashionModuleService.retrieveMaterial(req.params.id, {
withDeleted: true,
});
await fashionModuleService.softDeleteColors(req.params.colorId);
const color = await fashionModuleService.retrieveColor(req.params.colorId, {
withDeleted: true,
});
res.status(200).json(color);
};
================================================
FILE: medusa/src/api/admin/fashion/[id]/colors/route.ts
================================================
import { z } from '@medusajs/framework/zod';
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import FashionModuleService from '../../../../../modules/fashion/service';
import { FASHION_MODULE } from '../../../../../modules/fashion';
const colorsListQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
deleted: z.coerce.boolean().optional().default(false),
});
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const { page, deleted } = colorsListQuerySchema.parse(req.query);
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
const [colors, count] = await fashionModuleService.listAndCountColors(
deleted
? {
deleted_at: { $lte: new Date() },
material_id: req.params.id,
}
: {
material_id: req.params.id,
},
{
skip: 20 * (page - 1),
take: 20,
withDeleted: deleted,
},
);
const last_page = Math.ceil(count / 20);
res.status(200).json({ colors, count, page, last_page });
};
const colorsCreateBodySchema = z.object({
name: z.string().min(1),
hex_code: z.string().min(7).max(7),
});
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
const validatedData = colorsCreateBodySchema.parse(body);
const color = await fashionModuleService.createColors({
...validatedData,
material_id: req.params.id,
});
res.status(200).json(color);
};
================================================
FILE: medusa/src/api/admin/fashion/[id]/restore/route.ts
================================================
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import FashionModuleService from '../../../../../modules/fashion/service';
import { FASHION_MODULE } from '../../../../../modules/fashion';
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
await fashionModuleService.restoreMaterials(req.params.id);
const material = await fashionModuleService.retrieveMaterial(req.params.id, {
relations: ['colors'],
withDeleted: true,
});
res.status(200).json(material);
};
================================================
FILE: medusa/src/api/admin/fashion/[id]/route.ts
================================================
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import { z } from '@medusajs/framework/zod';
import FashionModuleService from '../../../../modules/fashion/service';
import { FASHION_MODULE } from '../../../../modules/fashion';
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
const material = await fashionModuleService.retrieveMaterial(req.params.id, {
relations: ['colors'],
withDeleted: true,
});
res.status(200).json(material);
};
const updateMaterialBodySchema = z.object({
name: z.string().min(1),
});
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
const validatedData = updateMaterialBodySchema.parse(body);
const material = await fashionModuleService.updateMaterials({
...validatedData,
id: req.params.id,
});
res.status(200).json(material);
};
export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
await fashionModuleService.softDeleteMaterials(req.params.id);
const material = await fashionModuleService.retrieveMaterial(req.params.id, {
relations: ['colors'],
withDeleted: true,
});
res.status(200).json(material);
};
================================================
FILE: medusa/src/api/admin/fashion/route.ts
================================================
import { z } from '@medusajs/framework/zod';
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import FashionModuleService from '../../../modules/fashion/service';
import { FASHION_MODULE } from '../../../modules/fashion';
const materialsListQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
deleted: z.coerce.boolean().optional().default(false),
});
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const { page, deleted } = materialsListQuerySchema.parse(req.query);
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
const [materials, count] = await fashionModuleService.listAndCountMaterials(
deleted
? {
deleted_at: { $lte: new Date() },
}
: undefined,
{
skip: 20 * (page - 1),
take: 20,
withDeleted: deleted,
relations: ['colors'],
},
);
const last_page = Math.ceil(count / 20);
res.status(200).json({ materials, count, page, last_page });
};
const createMaterialBodySchema = z.object({
name: z.string().min(1),
});
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
const validatedData = createMaterialBodySchema.parse(body);
const material = await fashionModuleService.createMaterials(validatedData);
res.status(201).json(material);
};
================================================
FILE: medusa/src/api/admin/products/[id]/fashion/route.ts
================================================
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import { Modules } from '@medusajs/framework/utils';
import { IProductModuleService } from '@medusajs/framework/types';
import { FASHION_MODULE } from '../../../../../modules/fashion';
import FashionModuleService from '../../../../../modules/fashion/service';
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const productModuleService: IProductModuleService = req.scope.resolve(
Modules.PRODUCT,
);
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
const product = await productModuleService.retrieveProduct(req.params.id, {
relations: ['options', 'variants', 'variants.options'],
});
const materialOption = product.options.find(
(option) => option.title === 'Material',
);
const colorOption = product.options.find(
(option) => option.title === 'Color',
);
const materialsAndColorsNamesTree = new Map();
for (const productVariant of product.variants) {
const materialName = productVariant.options.find(
(option) => option.option_id === materialOption.id,
)?.value;
if (!materialName) {
continue;
}
const colorNames = productVariant.options
.filter((option) => option.option_id === colorOption.id)
.map((option) => option.value);
if (!materialsAndColorsNamesTree.has(materialName)) {
materialsAndColorsNamesTree.set(materialName, colorNames);
} else {
const existingColorNames = materialsAndColorsNamesTree.get(materialName);
materialsAndColorsNamesTree.set(
materialName,
Array.from(new Set([...existingColorNames, ...colorNames])),
);
}
}
const materials = await fashionModuleService.listMaterials(
{
name: Array.from(materialsAndColorsNamesTree.keys()),
},
{
relations: ['colors'],
},
);
res.status(200).json({
missing_materials: Array.from(materialsAndColorsNamesTree.keys()).filter(
(materialName) =>
materials.every((material) => material.name !== materialName),
),
materials: materials.map((material) => ({
...material,
colors: material.colors.filter((color) =>
materialsAndColorsNamesTree.get(material.name).includes(color.name),
),
missing_colors: materialsAndColorsNamesTree
.get(material.name)
.filter((colorName) =>
material.colors.every((color) => color.name !== colorName),
),
})),
});
};
================================================
FILE: medusa/src/api/middlewares.ts
================================================
import { defineMiddlewares } from '@medusajs/medusa';
import { adminProductTypeRoutesMiddlewares } from './store/custom/product-types/middlewares';
import { authenticate } from '@medusajs/framework';
export default defineMiddlewares([
...adminProductTypeRoutesMiddlewares,
{
method: 'ALL',
matcher: '/store/custom/customer/*',
middlewares: [authenticate('customer', ['session', 'bearer'])],
},
]);
================================================
FILE: medusa/src/api/store/custom/customer/send-welcome-email/route.ts
================================================
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from '@medusajs/framework';
import emitCustomerWelcomeEvent from '../../../../../workflows/emit-customer-welcome-event';
export const POST = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse,
) => {
const customerId = req.auth_context.actor_id;
await emitCustomerWelcomeEvent(req.scope).run({
input: {
id: customerId,
},
});
res.status(200).json({ success: true });
};
================================================
FILE: medusa/src/api/store/custom/fashion/[productHandle]/route.ts
================================================
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import { Modules } from '@medusajs/framework/utils';
import { IProductModuleService } from '@medusajs/framework/types';
import { FASHION_MODULE } from '../../../../../modules/fashion';
import FashionModuleService from '../../../../../modules/fashion/service';
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const productModuleService: IProductModuleService = req.scope.resolve(
Modules.PRODUCT,
);
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
const [product] = await productModuleService.listProducts(
{
handle: req.params.productHandle,
},
{
relations: ['options', 'variants', 'variants.options'],
take: 1,
},
);
const materialOption = product.options.find(
(option) => option.title === 'Material',
);
const colorOption = product.options.find(
(option) => option.title === 'Color',
);
if (!materialOption || !colorOption) {
res.status(200).json({
materials: [],
});
return;
}
const materialsAndColorsNamesTree = new Map();
for (const productVariant of product.variants) {
const materialName = productVariant.options.find(
(option) => option.option_id === materialOption.id,
)?.value;
if (!materialName) {
continue;
}
const colorNames = productVariant.options
.filter((option) => option.option_id === colorOption.id)
.map((option) => option.value);
if (!materialsAndColorsNamesTree.has(materialName)) {
materialsAndColorsNamesTree.set(materialName, colorNames);
} else {
const existingColorNames = materialsAndColorsNamesTree.get(materialName);
materialsAndColorsNamesTree.set(
materialName,
Array.from(new Set([...existingColorNames, ...colorNames])),
);
}
}
const materials = await fashionModuleService.listMaterials(
{
name: Array.from(materialsAndColorsNamesTree.keys()),
},
{
relations: ['colors'],
},
);
res.status(200).json({
materials: materials.map((material) => ({
id: material.id,
name: material.name,
colors: material.colors
.filter((color) =>
materialsAndColorsNamesTree.get(material.name).includes(color.name),
)
.map((color) => ({
id: color.id,
name: color.name,
hex_code: color.hex_code,
})),
})),
});
};
================================================
FILE: medusa/src/api/store/custom/product-types/[id]/route.ts
================================================
import { refetchProductType } from '../helpers';
import { AdminGetProductTypeParamsType } from '../validators';
import { ProductTypeDTO } from '@medusajs/framework/types';
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from '@medusajs/framework';
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const productType = await refetchProductType(
req.params.id,
req.scope,
req.remoteQueryConfig.fields as (keyof ProductTypeDTO)[],
);
res.status(200).json({ product_type: productType });
};
================================================
FILE: medusa/src/api/store/custom/product-types/helpers.ts
================================================
import { MedusaContainer, ProductTypeDTO } from "@medusajs/framework/types"
export const refetchProductType = async (
productTypeId: string,
scope: MedusaContainer,
fields: (keyof ProductTypeDTO)[]
) => {
const query = scope.resolve("query")
const { data: [ productType ] } = await query.graph({
entity: "product_type",
filters: { id: productTypeId },
fields,
})
return productType
}
================================================
FILE: medusa/src/api/store/custom/product-types/middlewares.ts
================================================
import * as QueryConfig from './query-config';
import {
MiddlewareRoute,
validateAndTransformQuery,
} from '@medusajs/framework/http';
import {
AdminGetProductTypeParams,
AdminGetProductTypesParams,
} from './validators';
export const adminProductTypeRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ['GET'],
matcher: '/store/custom/product-types',
middlewares: [
validateAndTransformQuery(
AdminGetProductTypesParams,
QueryConfig.listProductTypesTransformQueryConfig,
),
],
},
{
method: ['GET'],
matcher: '/store/custom/product-types/:id',
middlewares: [
validateAndTransformQuery(
AdminGetProductTypeParams,
QueryConfig.retrieveProductTypeTransformQueryConfig,
),
],
},
];
================================================
FILE: medusa/src/api/store/custom/product-types/query-config.ts
================================================
export const defaultAdminProductTypeFields = [
"id",
"value",
"created_at",
"updated_at",
]
export const retrieveProductTypeTransformQueryConfig = {
defaults: defaultAdminProductTypeFields,
isList: false,
}
export const listProductTypesTransformQueryConfig = {
...retrieveProductTypeTransformQueryConfig,
defaultLimit: 20,
isList: true,
}
================================================
FILE: medusa/src/api/store/custom/product-types/route.ts
================================================
import { HttpTypes, ProductTypeDTO } from '@medusajs/framework/types';
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from '@medusajs/framework';
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse,
) => {
const query = req.scope.resolve("query")
const { data: productTypes, metadata } = await query.graph({
entity: "product_types",
filters: req.filterableFields,
fields: req.remoteQueryConfig.fields as (keyof ProductTypeDTO)[],
pagination: req.remoteQueryConfig.pagination
})
res.json({
product_types: productTypes,
count: metadata.count,
offset: metadata.skip,
limit: metadata.take,
});
};
================================================
FILE: medusa/src/api/store/custom/product-types/validators.ts
================================================
import {
createSelectParams,
createFindParams,
createOperatorMap,
} from '@medusajs/medusa/api/utils/validators';
import { z } from '@medusajs/framework/zod';
export type AdminGetProductTypeParamsType = z.infer<
typeof AdminGetProductTypeParams
>;
export const AdminGetProductTypeParams = createSelectParams();
export type AdminGetProductTypesParamsType = z.infer<
typeof AdminGetProductTypesParams
>;
export const AdminGetProductTypesParams = createFindParams({
limit: 10,
offset: 0,
}).merge(
z.object({
q: z.string().optional(),
id: z.union([z.string(), z.array(z.string())]).optional(),
value: z.union([z.string(), z.array(z.string())]).optional(),
// TODO: To be added in next iteration
// discount_condition_id: z.string().nullish(),
created_at: createOperatorMap().optional(),
updated_at: createOperatorMap().optional(),
deleted_at: createOperatorMap().optional(),
$and: z.lazy(() => AdminGetProductTypesParams.array()).optional(),
$or: z.lazy(() => AdminGetProductTypesParams.array()).optional(),
}),
);
================================================
FILE: medusa/src/api/store/custom/stripe/get-payment-method/[id]/route.ts
================================================
import { MedusaResponse, MedusaStoreRequest } from "@medusajs/framework";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_API_KEY);
export const GET = async (req: MedusaStoreRequest, res: MedusaResponse) => {
const { id } = req.params;
const paymentMethod = await stripe.paymentMethods.retrieve(id);
res.status(200).json(paymentMethod);
};
================================================
FILE: medusa/src/api/store/custom/stripe/set-payment-method/route.ts
================================================
import { MedusaResponse, MedusaStoreRequest } from "@medusajs/framework";
import { IPaymentModuleService } from "@medusajs/framework/types";
import { Modules } from "@medusajs/framework/utils";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_API_KEY);
export const POST = async (
req: MedusaStoreRequest<{
session_id: string;
token: string;
}>,
res: MedusaResponse
) => {
const paymentModuleService: IPaymentModuleService = req.scope.resolve(
Modules.PAYMENT
);
const session = await paymentModuleService.retrievePaymentSession(
req.body.session_id
);
if (!req.body.token) {
await paymentModuleService.updatePaymentSession({
...session,
data: {
...session.data,
payment_method_id: null,
},
});
res.status(200).json({ success: true });
}
const paymentMethod = await stripe.paymentMethods.create({
type: "card",
card: { token: req.body.token },
});
await stripe.paymentIntents.update(session.data.id as string, {
payment_method: paymentMethod.id,
});
await paymentModuleService.updatePaymentSession({
...session,
data: {
...session.data,
payment_method_id: paymentMethod.id,
},
});
res.status(200).json({ success: true });
};
================================================
FILE: medusa/src/jobs/README.md
================================================
# Custom scheduled jobs
A scheduled job is a function executed at a specified interval of time in the background of your Medusa application.
A scheduled job is created in a TypeScript or JavaScript file under the `src/jobs` directory.
For example, create the file `src/jobs/hello-world.ts` with the following content:
```ts
import {
IProductModuleService,
MedusaContainer
} from "@medusajs/framework/types";
import { Modules } from "@medusajs/framework/utils";
export default async function myCustomJob(container: MedusaContainer) {
const productService: IProductModuleService = container.resolve(Modules.PRODUCT)
const products = await productService.listAndCountProducts();
// Do something with the products
}
export const config = {
name: "daily-product-report",
schedule: "0 0 * * *", // Every day at midnight
};
```
A scheduled job file must export:
- The function to be executed whenever it’s time to run the scheduled job.
- A configuration object defining the job. It has three properties:
- `name`: a unique name for the job.
- `schedule`: a [cron expression](https://crontab.guru/).
- `numberOfExecutions`: an optional integer, specifying how many times the job will execute before being removed
The `handler` is a function that accepts one parameter, `container`, which is a `MedusaContainer` instance used to resolve services.
================================================
FILE: medusa/src/links/README.md
================================================
# Module Links
A module link forms an association between two data models of different modules, while maintaining module isolation.
For example:
```ts
import HelloModule from "../modules/hello"
import ProductModule from "@medusajs/medusa/product"
import { defineLink } from "@medusajs/framework/utils"
export default defineLink(
ProductModule.linkable.product,
HelloModule.linkable.myCustom
)
```
This defines a link between the Product Module's `product` data model and the Hello Module (custom module)'s `myCustom` data model.
Learn more about links in [this documentation](https://docs.medusajs.com/v2/advanced-development/modules/module-links)
================================================
FILE: medusa/src/modules/README.md
================================================
# Custom Module
A module is a package of reusable functionalities. It can be integrated into your Medusa application without affecting the overall system.
To create a module:
## 1. Create a Service
A module must define a service. A service is a TypeScript or JavaScript class holding methods related to a business logic or commerce functionality.
For example, create the file `src/modules/hello/service.ts` with the following content:
```ts title="src/modules/hello/service.ts"
export default class HelloModuleService {
getMessage() {
return "Hello, world!"
}
}
```
## 2. Export Module Definition
A module must have an `index.ts` file in its root directory that exports its definition. The definition specifies the main service of the module.
For example, create the file `src/modules/hello.index.ts` with the following content:
```ts title="src/modules/hello.index.ts" highlights={[["4", "", "The main service of the module."]]}
import HelloModuleService from "./service"
import { Module } from "@medusajs/framework/utils"
export const HELLO_MODULE = "helloModuleService"
export default Module(HELLO_MODULE, {
service: HelloModuleService,
})
```
## 3. Add Module to Configurations
The last step is to add the module in Medusa’s configurations.
In `medusa-config.js`, add the module to the `modules` object:
```js title="medusa-config.js"
import { HELLO_MODULE } from "./src/modules/hello"
module.exports = defineConfig({
// ...
modules: {
[HELLO_MODULE]: {
resolve: "./modules/hello",
},
},
})
```
Its key (`helloModuleService` or `HELLO_MODULE`) is the name of the module’s main service. It will be registered in the Medusa container with that name.
## Use Module
You can resolve the main service of the module in other resources, such as an API route:
```ts
import { MedusaRequest, MedusaResponse } from "@medusajs/medusa"
import HelloModuleService from "../../../modules/hello/service"
import { HELLO_MODULE } from "../../../modules/hello"
export async function GET(
req: MedusaRequest,
res: MedusaResponse
): Promise {
const helloModuleService: HelloModuleService = req.scope.resolve(
HELLO_MODULE
)
res.json({
message: helloModuleService.getMessage(),
})
}
```
================================================
FILE: medusa/src/modules/fashion/index.ts
================================================
import { Module } from '@medusajs/framework/utils';
import FashionModuleService from './service';
export const FASHION_MODULE = 'fashionModuleService';
export default Module(FASHION_MODULE, {
service: FashionModuleService,
});
================================================
FILE: medusa/src/modules/fashion/migrations/.snapshot-medusa.json
================================================
{
"namespaces": [
"public"
],
"name": "public",
"tables": [
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"name": {
"name": "name",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "material",
"schema": "public",
"indexes": [
{
"keyName": "material_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"name": {
"name": "name",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"hex_code": {
"name": "hex_code",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"material_id": {
"name": "material_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "color",
"schema": "public",
"indexes": [
{
"keyName": "IDX_color_material_id",
"columnNames": [],
"composite": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_color_material_id\" ON \"color\" (material_id) WHERE deleted_at IS NULL"
},
{
"keyName": "color_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"color_material_id_foreign": {
"constraintName": "color_material_id_foreign",
"columnNames": [
"material_id"
],
"localTableName": "public.color",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.material",
"updateRule": "cascade"
}
}
}
]
}
================================================
FILE: medusa/src/modules/fashion/migrations/Migration20241002190028.ts
================================================
import { Migration } from '@mikro-orm/migrations';
export class Migration20241002190028 extends Migration {
async up(): Promise {
this.addSql('create table if not exists "material" ("id" text not null, "name" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "material_pkey" primary key ("id"));');
this.addSql('create table if not exists "color" ("id" text not null, "name" text not null, "hex_code" text not null, "material_id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "color_pkey" primary key ("id"));');
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_color_material_id" ON "color" (material_id) WHERE deleted_at IS NULL;');
this.addSql('alter table if exists "color" add constraint "color_material_id_foreign" foreign key ("material_id") references "material" ("id") on update cascade;');
}
async down(): Promise {
this.addSql('alter table if exists "color" drop constraint if exists "color_material_id_foreign";');
this.addSql('drop table if exists "material" cascade;');
this.addSql('drop table if exists "color" cascade;');
}
}
================================================
FILE: medusa/src/modules/fashion/models/color.ts
================================================
import { model } from '@medusajs/framework/utils';
import { InferTypeOf } from '@medusajs/framework/types';
import Material from './material';
const Color = model.define('color', {
id: model.id().primaryKey(),
name: model.text(),
hex_code: model.text(),
material: model.belongsTo(() => Material, {
mappedBy: 'colors',
}),
});
export type ColorModelType = InferTypeOf;
export default Color;
================================================
FILE: medusa/src/modules/fashion/models/material.ts
================================================
import { model } from '@medusajs/framework/utils';
import { InferTypeOf } from '@medusajs/framework/types';
import Color from './color';
const Material = model.define('material', {
id: model.id().primaryKey(),
name: model.text(),
colors: model.hasMany(() => Color),
});
export type MaterialModelType = InferTypeOf;
export default Material;
================================================
FILE: medusa/src/modules/fashion/service.ts
================================================
import { MedusaService } from '@medusajs/framework/utils';
import Material from './models/material';
import Color from './models/color';
export default class FashionModuleService extends MedusaService({
Material,
Color,
}) {}
================================================
FILE: medusa/src/modules/meilisearch/index.ts
================================================
import { Module } from '@medusajs/utils';
import Loader from './loader';
import { MeiliSearchService } from './service';
export default Module('meilisearchService', {
service: MeiliSearchService,
loaders: [Loader],
});
================================================
FILE: medusa/src/modules/meilisearch/loader.ts
================================================
import { LoaderOptions } from '@medusajs/types';
import { MeiliSearchService } from './service';
import { MeiliSearchPluginOptions } from './types';
import { asValue } from 'awilix';
export default async ({
container,
options,
}: LoaderOptions): Promise => {
if (!options) {
throw new Error('Missing meilisearch configuration');
}
const meilisearchService: MeiliSearchService = new MeiliSearchService(
container,
options,
);
container.register({
meilisearchService: asValue(meilisearchService),
});
if (options.settings) {
await Promise.all(
Object.entries(options.settings).map(([indexName, indexSettings]) =>
meilisearchService.updateSettings(indexName, indexSettings),
),
);
}
};
================================================
FILE: medusa/src/modules/meilisearch/service.ts
================================================
import { SearchTypes } from '@medusajs/types';
import { SearchUtils } from '@medusajs/utils';
// @ts-ignore
import { MeiliSearch, MeiliSearchApiError, Settings } from 'meilisearch';
import { MeiliSearchPluginOptions } from './types';
import { logger } from '@medusajs/framework';
export class MeiliSearchService extends SearchUtils.AbstractSearchService {
static identifier = 'meilisearch';
isDefault = false;
protected readonly client: MeiliSearch;
constructor(container: any, options: MeiliSearchPluginOptions) {
super(container, options);
if (process.env.NODE_ENV !== 'development') {
if (!options.config?.apiKey) {
throw Error(
'MeiliSearch API key is required for production environments.',
);
}
}
if (!options.config?.host) {
throw Error(
'MeiliSearch host is required. Please provide a host in the configuration.',
);
}
this.client = new MeiliSearch(options.config);
}
async createIndex(
indexName: string,
options: Record = { primaryKey: 'id' },
) {
return this.client.createIndex(indexName, options);
}
getIndex(indexName: string) {
return this.client.index(indexName);
}
async addDocuments(
indexName: string,
documents: Record[],
type: string,
) {
const indexSetting = this.options.settings?.[indexName];
const transformer = indexSetting?.transformer ?? ((doc: any) => doc);
const primaryKey = indexSetting?.primaryKey ?? 'id';
return this.client
.index(indexName)
.addDocuments(documents.map(transformer), { primaryKey });
}
async replaceDocuments(
indexName: string,
documents: Record[],
type: string,
) {
return this.addDocuments(indexName, documents, type);
}
async deleteDocument(indexName: string, documentId: string) {
return this.client.index(indexName).deleteDocument(documentId);
}
async deleteAllDocuments(indexName: string) {
return this.client.index(indexName).deleteAllDocuments();
}
async search(indexName: string, query: string, options: Record) {
const { paginationOptions, filter, additionalOptions } = options;
return this.client
.index(indexName)
.search(query, { filter, ...paginationOptions, ...additionalOptions });
}
async updateSettings(
indexName: string,
settings: SearchTypes.IndexSettings & { indexSettings: Settings },
) {
const indexSettings = settings.indexSettings ?? {};
try {
await this.client.getIndex(indexName);
} catch (error) {
if (
error instanceof MeiliSearchApiError &&
error.cause?.code === 'index_not_found'
) {
await this.createIndex(indexName, {
primaryKey: settings.primaryKey ?? 'id',
});
} else {
logger.error(error);
throw error;
}
}
return this.client.index(indexName).updateSettings(indexSettings);
}
}
================================================
FILE: medusa/src/modules/meilisearch/types.ts
================================================
import { SearchTypes } from '@medusajs/types';
// @ts-ignore
import type { Config, Settings } from 'meilisearch';
export interface MeiliSearchPluginOptions {
/**
* MeiliSearch client configuration
*/
config: Config;
/**
* MeiliSearch index settings
*/
settings?: Record<
string,
SearchTypes.IndexSettings & { indexSettings: Settings }
>;
}
================================================
FILE: medusa/src/modules/resend/emails/auth-email-confirm.tsx
================================================
// External packages
import { Text, Heading, Button } from '@react-email/components';
// Components
import EmailLayout, { EmailLayoutProps } from './components/EmailLayout';
export default function AuthEmailConfirm({
...emailLayoutProps
}: EmailLayoutProps) {
return (
Verify your email
Hey Jovana, thanks for registering for an account on Sofa Society!
Before we get started, we just need to confirm that this is you.
Click below to verify your email address:
Verify email
);
}
================================================
FILE: medusa/src/modules/resend/emails/auth-forgot-password.tsx
================================================
// External components
import { Text, Heading, Button } from '@react-email/components';
// Types
import { CustomerDTO } from '@medusajs/framework/types';
// Components
import EmailLayout, { EmailLayoutProps } from './components/EmailLayout';
type Props = {
customer: Pick;
token: string;
};
export default function AuthPasswordForgotResetEmail({
customer,
token,
...emailLayoutProps
}: Props & EmailLayoutProps) {
return (
Reset your password
We received a request to reset your Sofa Society account password. Click
below to set a new password:
Reset password
If you didn't request this change, please ignore this email, and
your current password will remain unchanged.
);
}
AuthPasswordForgotResetEmail.PreviewProps = {
customer: {
id: '1',
email: 'example@medusa.local',
first_name: 'John',
last_name: 'Doe',
},
token: '1234567789012345677890',
} satisfies Props;
================================================
FILE: medusa/src/modules/resend/emails/auth-password-reset.tsx
================================================
// External components
import { Text, Heading, Button } from '@react-email/components';
// Types
import { CustomerDTO } from '@medusajs/framework/types';
// Components
import EmailLayout, { EmailLayoutProps } from './components/EmailLayout';
type Props = {
customer: Pick;
token: string;
};
export default function AuthPasswordResetEmail({
customer,
token,
...emailLayoutProps
}: Props & EmailLayoutProps) {
return (
Reset your password
We received a request to reset your Sofa Society account password. Click
below to set a new password:
Reset password
If you didn't request this change, please ignore this email, and
your current password will remain unchanged.
);
}
AuthPasswordResetEmail.PreviewProps = {
customer: {
id: '1',
email: 'example@medusa.local',
first_name: 'John',
last_name: 'Doe',
},
token: '1234567789012345677890',
} satisfies Props;
================================================
FILE: medusa/src/modules/resend/emails/components/EmailLayout.tsx
================================================
// External packages
import {
Body,
Column,
Container,
Font,
Head,
Hr,
Html,
Link,
Row,
Section,
Text,
Tailwind,
} from '@react-email/components';
// Google Font API is used to load the Mona Sans font
// You can find other variants here: https://webfonts.googleapis.com/v1/webfonts?capability=WOFF2&family=Mona%20Sans&subset=latin-ext&key=[YOUR_API_KEY]
export type EmailLayoutProps = {
siteTitle?: string;
companyName?: string;
footerLinks?: {
url: string;
label: string;
}[];
};
export default function EmailLayout(
props: {
children: React.ReactNode;
} & EmailLayoutProps
) {
return (
{props.siteTitle || 'SofaSocietyCo.'}
{props.children}
{props.siteTitle || 'SofaSocietyCo.'}
© {new Date().getFullYear()},{' '}
{props.companyName || 'Sofa Society'}
{props.footerLinks && props.footerLinks.length > 0 && (
{props.footerLinks.map((link, index) => (
{link.label}
))}
)}
);
}
================================================
FILE: medusa/src/modules/resend/emails/index.ts
================================================
import AuthPasswordForgotResetEmail from "./auth-forgot-password";
import AuthPasswordResetEmail from "./auth-password-reset";
import OrderPlacedEmail from "./order-placed";
import WelcomeEmail from "./welcome";
// TODO: we should be able to use notification data in subjects too
export const subjects = {
"auth-password-reset": "Reset your password",
"order-placed": "Your order has been placed",
"customer-welcome": "Welcome to Sofa Society!",
"auth-forgot-password": "Reset your password",
};
export default {
"auth-password-reset": AuthPasswordResetEmail,
"order-placed": OrderPlacedEmail,
"customer-welcome": WelcomeEmail,
"auth-forgot-password": AuthPasswordForgotResetEmail,
};
================================================
FILE: medusa/src/modules/resend/emails/order-placed.tsx
================================================
// External packages
import { Fragment } from 'react';
import {
Text,
Column,
Heading,
Img,
Row,
Section,
Link,
Hr,
} from '@react-email/components';
import { HttpTypes } from '@medusajs/framework/types';
import EmailLayout, { EmailLayoutProps } from './components/EmailLayout';
export type OrderPlacedEmailProps = {
order: Pick<
HttpTypes.AdminOrder,
| 'currency_code'
| 'email'
| 'shipping_total'
| 'subtotal'
| 'total'
| 'tax_total'
> & {
shipping_address:
| (Pick<
HttpTypes.AdminOrderAddress,
| 'first_name'
| 'last_name'
| 'address_1'
| 'address_2'
| 'city'
| 'postal_code'
| 'province'
| 'phone'
> & {
country?: Pick<
HttpTypes.AdminRegionCountry,
'iso_2' | 'name' | 'display_name'
>;
})
| null;
billing_address:
| (Pick<
HttpTypes.AdminOrderAddress,
| 'first_name'
| 'last_name'
| 'address_1'
| 'address_2'
| 'city'
| 'postal_code'
| 'province'
| 'phone'
> & {
country?: Pick<
HttpTypes.AdminRegionCountry,
'iso_2' | 'name' | 'display_name'
>;
})
| null;
items: Pick<
HttpTypes.AdminOrder['items'][number],
| 'id'
| 'thumbnail'
| 'product_title'
| 'variant_title'
| 'total'
| 'quantity'
| 'variant_option_values'
>[];
};
} & EmailLayoutProps;
export default function OrderPlacedEmail({
order,
...emailLayoutProps
}: OrderPlacedEmailProps) {
const formatter = new Intl.NumberFormat([], {
style: 'currency',
currencyDisplay: 'narrowSymbol',
currency: order.currency_code,
});
return (
Order confirmation
We are pleased to confirm that your order has been successfully placed
and will be processed shortly. Your order number is #100002.
You'll receive another update once your order is shipped. For any
questions, feel free to contact us at info@sofasociety.com.
Thank you for shopping with us!
Delivery Address
{[
order.shipping_address.first_name,
order.shipping_address.last_name,
]
.filter(Boolean)
.join(' ')}
{[
order.shipping_address.address_1,
order.shipping_address.address_2,
[
order.shipping_address.postal_code,
order.shipping_address.city,
]
.filter(Boolean)
.join(' '),
order.shipping_address.province,
order.shipping_address.country.display_name,
]
.filter(Boolean)
.join(', ')}
{order.shipping_address.phone && (
{order.shipping_address.phone}
)}
Billing Address
{[
order.billing_address.first_name,
order.billing_address.last_name,
]
.filter(Boolean)
.join(' ')}
{[
order.billing_address.address_1,
order.billing_address.address_2,
[order.billing_address.postal_code, order.billing_address.city]
.filter(Boolean)
.join(' '),
order.billing_address.province,
order.billing_address.country.display_name,
]
.filter(Boolean)
.join(', ')}
{order.billing_address.phone && (
{order.billing_address.phone}
)}
{order.items.map((item, index) => {
return (
{index > 0 && (
)}
{!!item.thumbnail && (
)}
{item.product_title}
{Object.entries(item.variant_option_values).flatMap(
([key, value]) =>
typeof value === 'string' ? (
{key}:
{value}
) : (
[]
),
)}
Quantity:
{item.quantity}
{formatter.format(item.total)}
);
})}
Payment
Subtotal
{formatter.format(order.subtotal)}
Shipping
{formatter.format(order.shipping_total)}
Total
{formatter.format(order.total)}
Including
{formatter.format(order.tax_total)} tax
);
}
OrderPlacedEmail.PreviewProps = {
order: {
currency_code: 'EUR',
email: 'example@medusa.local',
shipping_address: {
first_name: 'John',
last_name: 'Doe',
address_1: '1234 Main St',
address_2: 'Apt 1',
city: 'Los Angeles',
postal_code: '90001',
country: {
iso_2: 'US',
name: 'United States',
display_name: 'United States',
},
phone: '+1 123 456 7890',
province: 'California',
},
billing_address: {
first_name: 'John',
last_name: 'Doe',
address_1: '1234 Main St',
address_2: 'Apt 1',
city: 'Los Angeles',
postal_code: '90001',
country: {
iso_2: 'US',
name: 'United States',
display_name: 'United States',
},
phone: '+1 123 456 7890',
province: 'California',
},
items: [
{
id: '1',
thumbnail:
'https://fashion-starter-demo.s3.eu-central-1.amazonaws.com/belime-estate-01JAR3JYD68D1YYR0BN7HHMAZE.png',
product_title: 'Belime Estate',
variant_title: 'Linen / Red',
total: 1500,
quantity: 1,
variant_option_values: {
Material: 'Linen',
Color: 'Red',
},
},
],
shipping_total: 100,
subtotal: 1400,
total: 1500,
tax_total: 100,
},
} satisfies OrderPlacedEmailProps;
================================================
FILE: medusa/src/modules/resend/emails/order-update.tsx
================================================
// External packages
import { Text, Heading, Button } from '@react-email/components';
// Types
import { CustomerDTO, OrderDTO } from '@medusajs/framework/types';
// Components
import EmailLayout, { EmailLayoutProps } from './components/EmailLayout';
type Props = {
customer: Pick;
order: Pick;
};
export default function OrderUpdateEmail({
customer,
order,
...emailLayoutProps
}: Props & EmailLayoutProps) {
return (
Shipping update
Great news! Your order #{order.display_id} is now on its way to you.
Here are the shipping details.
You can track your package by clicking below:
Order details
Thank you for choosing Sofa Society. We're excited for your new
sofa to find its home with you!
);
}
OrderUpdateEmail.PreviewProps = {
customer: {
id: '1',
email: 'example@medusa.local',
first_name: 'John',
last_name: 'Doe',
},
order: {
id: 'order_01JCNYH6VADAK90W7CBSPV5BT6',
display_id: 1,
},
} satisfies Props;
================================================
FILE: medusa/src/modules/resend/emails/welcome.tsx
================================================
// External packages
import { Text, Heading, Row, Column } from '@react-email/components';
import { CustomerDTO } from '@medusajs/framework/types';
// Components
import EmailLayout, { EmailLayoutProps } from './components/EmailLayout';
const UnorderedList: React.FC<{
children?: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
return (
{children}
);
};
const UnorderedListItem: React.FC<{
children?: React.ReactNode;
className?: string;
textClassName?: string;
}> = ({ children, className, textClassName }) => {
return (
);
};
type Props = {
customer: Pick;
};
export default function WelcomeEmail({
customer,
...emailLayoutProps
}: Props & EmailLayoutProps) {
return (
Welcome to Sofa Society!
Welcome to Sofa Society! We're excited to have you join our community of
comfort enthusiasts. With our carefully crafted sofas, you're just
steps away from adding elegance and coziness to your living space.
As a new member, here's what you can expect:
Premium, high-quality sofas in a range of styles and materials
Dedicated customer support ready to assist you
Exclusive offers and early access to new collections
Explore our collections and find the sofa that suits your style!
Best wishes,
The Sofa Society Team
);
}
WelcomeEmail.PreviewProps = {
customer: {
id: '1',
email: 'example@medusa.local',
first_name: 'John',
last_name: 'Doe',
},
} satisfies Props;
================================================
FILE: medusa/src/modules/resend/index.ts
================================================
import { ModuleProvider, Modules } from '@medusajs/framework/utils';
import ResendNotificationProviderService from './service';
export default ModuleProvider(Modules.NOTIFICATION, {
services: [ResendNotificationProviderService],
});
================================================
FILE: medusa/src/modules/resend/service.tsx
================================================
import { AbstractNotificationProviderService } from '@medusajs/framework/utils';
import { Logger } from '@medusajs/medusa';
import {
ProviderSendNotificationDTO,
ProviderSendNotificationResultsDTO,
} from '@medusajs/types';
import { Resend } from 'resend';
import emails, { subjects } from './emails';
import type { EmailLayoutProps } from './emails/components/EmailLayout';
type InjectedDependencies = {
logger: Logger;
};
export default class ResendNotificationProviderService extends AbstractNotificationProviderService {
public static identifier = 'resend';
private resendClient: Resend;
private from: string;
private layoutOptions?: EmailLayoutProps;
private logger: Logger;
constructor({ logger }: InjectedDependencies, options: unknown) {
super();
if (
typeof options !== 'object' ||
options === null ||
!('api_key' in options) ||
typeof options.api_key !== 'string' ||
!('from' in options) ||
typeof options.from !== 'string'
) {
throw new Error(
`Invalid options provided to Resend module. Expected { api_key: string, from: string }`,
);
}
const layoutOptions: EmailLayoutProps = {};
if ('siteTitle' in options && typeof options.siteTitle === 'string') {
layoutOptions.siteTitle = options.siteTitle;
}
if ('companyName' in options && typeof options.companyName === 'string') {
layoutOptions.companyName = options.companyName;
}
if ('footerLinks' in options) {
if (
!Array.isArray(options.footerLinks) ||
!options.footerLinks.every(
(l) => typeof l.url === 'string' && typeof l.label === 'string',
)
) {
this.logger.warn(
`Invalid footer links provided to Resend module. Expected an array of { url: string, label: string } objects.`,
);
} else {
layoutOptions.footerLinks = options.footerLinks;
}
}
this.resendClient = new Resend(options.api_key);
this.from = options.from;
this.logger = logger;
this.layoutOptions = layoutOptions;
}
async send(
notification: ProviderSendNotificationDTO,
): Promise {
const Template = emails[notification.template];
const subject = subjects[notification.template] || '';
if (!Template) {
this.logger.error(
`Couldn't find an email template for ${
notification.template
}. The valid options are ${Object.keys(emails).join(', ')}`,
);
return {};
}
if (!subject) {
this.logger.warn(
`No subject found for template ${notification.template}. Please add a subject to the emails file.`,
);
}
const { data, error } = await this.resendClient.emails.send({
from: this.from,
to: [notification.to],
subject,
react: ,
});
if (error) {
this.logger.error(`Failed to send email`, error);
return {};
}
return { id: data.id };
}
}
================================================
FILE: medusa/src/scripts/README.md
================================================
# Custom CLI Script
A custom CLI script is a function to execute through Medusa's CLI tool. This is useful when creating custom Medusa tooling to run as a CLI tool.
## How to Create a Custom CLI Script?
To create a custom CLI script, create a TypeScript or JavaScript file under the `src/scripts` directory. The file must default export a function.
For example, create the file `src/scripts/my-script.ts` with the following content:
```ts title="src/scripts/my-script.ts"
import {
ExecArgs,
IProductModuleService
} from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
export default async function myScript ({
container
}: ExecArgs) {
const productModuleService: IProductModuleService =
container.resolve(Modules.PRODUCT)
const [, count] = await productModuleService.listAndCount()
console.log(`You have ${count} product(s)`)
}
```
The function receives as a parameter an object having a `container` property, which is an instance of the Medusa Container. Use it to resolve resources in your Medusa application.
---
## How to Run Custom CLI Script?
To run the custom CLI script, run the `exec` command:
```bash
npx medusa exec ./src/scripts/my-script.ts
```
---
## Custom CLI Script Arguments
Your script can accept arguments from the command line. Arguments are passed to the function's object parameter in the `args` property.
For example:
```ts
import { ExecArgs } from "@medusajs/framework/types"
export default async function myScript ({
args
}: ExecArgs) {
console.log(`The arguments you passed: ${args}`)
}
```
Then, pass the arguments in the `exec` command after the file path:
```bash
npx medusa exec ./src/scripts/my-script.ts arg1 arg2
```
================================================
FILE: medusa/src/scripts/index-products.ts
================================================
import { ExecArgs, ISearchService } from '@medusajs/framework/types';
import { Modules } from '@medusajs/framework/utils';
export default async function indexProducts({ container }: ExecArgs) {
const logger = container.resolve('logger');
const meilisearchService = container.resolve(
'meilisearchService',
) as ISearchService;
const productModuleService = container.resolve(Modules.PRODUCT);
const [products, count] = await productModuleService.listAndCountProducts(
undefined,
{
relations: [
'variants',
'options',
'tags',
'collection',
'type',
'images',
'categories',
],
},
);
logger.info(`Adding ${count} products to MeiliSearch...`);
await meilisearchService.addDocuments('products', products, 'products');
logger.info('Products added to MeiliSearch');
}
================================================
FILE: medusa/src/scripts/seed.ts
================================================
import {
createApiKeysWorkflow,
createCollectionsWorkflow,
createProductCategoriesWorkflow,
createProductsWorkflow,
createProductTypesWorkflow,
createRegionsWorkflow,
createSalesChannelsWorkflow,
createShippingOptionsWorkflow,
createShippingProfilesWorkflow,
createStockLocationsWorkflow,
createTaxRegionsWorkflow,
linkSalesChannelsToApiKeyWorkflow,
linkSalesChannelsToStockLocationWorkflow,
updateStoresWorkflow,
uploadFilesWorkflow,
} from '@medusajs/medusa/core-flows';
import {
ExecArgs,
IFulfillmentModuleService,
ISalesChannelModuleService,
IStoreModuleService,
} from '@medusajs/framework/types';
import {
ContainerRegistrationKeys,
Modules,
ProductStatus,
} from '@medusajs/framework/utils';
import type FashionModuleService from '../modules/fashion/service';
import type { MaterialModelType } from '../modules/fashion/models/material';
async function getImageUrlContent(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch image "${url}": ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer).toString('binary');
}
export default async function seedDemoData({ container }: ExecArgs) {
const logger = container.resolve(ContainerRegistrationKeys.LOGGER);
const remoteLink = container.resolve(ContainerRegistrationKeys.LINK);
const fulfillmentModuleService: IFulfillmentModuleService = container.resolve(
Modules.FULFILLMENT,
);
const salesChannelModuleService: ISalesChannelModuleService =
container.resolve(Modules.SALES_CHANNEL);
const storeModuleService: IStoreModuleService = container.resolve(
Modules.STORE,
);
const fashionModuleService: FashionModuleService = container.resolve(
'fashionModuleService',
);
const countries = ['hr', 'gb', 'de', 'dk', 'se', 'fr', 'es', 'it'];
logger.info('Seeding store data...');
const [store] = await storeModuleService.listStores();
let defaultSalesChannel = await salesChannelModuleService.listSalesChannels({
name: 'Default Sales Channel',
});
if (!defaultSalesChannel.length) {
// create the default sales channel
const { result: salesChannelResult } = await createSalesChannelsWorkflow(
container,
).run({
input: {
salesChannelsData: [
{
name: 'Default Sales Channel',
},
],
},
});
defaultSalesChannel = salesChannelResult;
}
logger.info('Seeding region data...');
const { result: regionResult } = await createRegionsWorkflow(container).run({
input: {
regions: [
{
name: 'Europe',
currency_code: 'eur',
countries,
payment_providers: ['pp_stripe_stripe'],
},
],
},
});
const region = regionResult[0];
logger.info('Finished seeding regions.');
await updateStoresWorkflow(container).run({
input: {
selector: { id: store.id },
update: {
supported_currencies: [
{
currency_code: 'eur',
is_default: true,
},
{
currency_code: 'usd',
},
],
default_sales_channel_id: defaultSalesChannel[0].id,
default_region_id: region.id,
},
},
});
logger.info('Seeding tax regions...');
await createTaxRegionsWorkflow(container).run({
input: countries.map((country_code) => ({
country_code,
})),
});
logger.info('Finished seeding tax regions.');
logger.info('Seeding stock location data...');
const { result: stockLocationResult } = await createStockLocationsWorkflow(
container,
).run({
input: {
locations: [
{
name: 'European Warehouse',
address: {
city: 'Copenhagen',
country_code: 'DK',
address_1: '',
},
},
],
},
});
const stockLocation = stockLocationResult[0];
await remoteLink.create({
[Modules.STOCK_LOCATION]: {
stock_location_id: stockLocation.id,
},
[Modules.FULFILLMENT]: {
fulfillment_provider_id: 'manual_manual',
},
});
logger.info('Seeding fulfillment data...');
const { result: shippingProfileResult } =
await createShippingProfilesWorkflow(container).run({
input: {
data: [
{
name: 'Default',
type: 'default',
},
],
},
});
const shippingProfile = shippingProfileResult[0];
const fulfillmentSet = await fulfillmentModuleService.createFulfillmentSets({
name: 'European Warehouse delivery',
type: 'shipping',
service_zones: [
{
name: 'Europe',
geo_zones: [
{
country_code: 'hr',
type: 'country',
},
{
country_code: 'gb',
type: 'country',
},
{
country_code: 'de',
type: 'country',
},
{
country_code: 'dk',
type: 'country',
},
{
country_code: 'se',
type: 'country',
},
{
country_code: 'fr',
type: 'country',
},
{
country_code: 'es',
type: 'country',
},
{
country_code: 'it',
type: 'country',
},
],
},
],
});
await remoteLink.create({
[Modules.STOCK_LOCATION]: {
stock_location_id: stockLocation.id,
},
[Modules.FULFILLMENT]: {
fulfillment_set_id: fulfillmentSet.id,
},
});
await createShippingOptionsWorkflow(container).run({
input: [
{
name: 'Standard Shipping',
price_type: 'flat',
provider_id: 'manual_manual',
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
type: {
label: 'Standard',
description: 'Ship in 2-3 days.',
code: 'standard',
},
prices: [
{
currency_code: 'usd',
amount: 10,
},
{
currency_code: 'eur',
amount: 10,
},
{
region_id: region.id,
amount: 10,
},
],
rules: [
{
attribute: 'enabled_in_store',
value: '"true"',
operator: 'eq',
},
{
attribute: 'is_return',
value: 'false',
operator: 'eq',
},
],
},
{
name: 'Express Shipping',
price_type: 'flat',
provider_id: 'manual_manual',
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
type: {
label: 'Express',
description: 'Ship in 24 hours.',
code: 'express',
},
prices: [
{
currency_code: 'usd',
amount: 10,
},
{
currency_code: 'eur',
amount: 10,
},
{
region_id: region.id,
amount: 10,
},
],
rules: [
{
attribute: 'enabled_in_store',
value: '"true"',
operator: 'eq',
},
{
attribute: 'is_return',
value: 'false',
operator: 'eq',
},
],
},
],
});
const pickupFulfillmentSet =
await fulfillmentModuleService.createFulfillmentSets({
name: 'Store pickup',
type: 'pickup',
service_zones: [
{
name: 'Store pickup',
geo_zones: [
{
country_code: 'hr',
type: 'country',
},
{
country_code: 'dk',
type: 'country',
},
],
},
],
});
await remoteLink.create({
[Modules.STOCK_LOCATION]: {
stock_location_id: stockLocation.id,
},
[Modules.FULFILLMENT]: {
fulfillment_set_id: pickupFulfillmentSet.id,
},
});
await createShippingOptionsWorkflow(container).run({
input: [
{
name: 'Denmark Store Pickup',
price_type: 'flat',
provider_id: 'manual_manual',
service_zone_id: pickupFulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
type: {
label: 'Denmark Store Pickup',
description: 'Free in-store pickup.',
code: 'standard',
},
prices: [
{
currency_code: 'usd',
amount: 0,
},
{
currency_code: 'eur',
amount: 0,
},
{
region_id: region.id,
amount: 0,
},
],
rules: [
{
attribute: 'enabled_in_store',
value: '"true"',
operator: 'eq',
},
{
attribute: 'is_return',
value: 'false',
operator: 'eq',
},
],
},
],
});
logger.info('Finished seeding fulfillment data.');
await linkSalesChannelsToStockLocationWorkflow(container).run({
input: {
id: stockLocation.id,
add: [defaultSalesChannel[0].id],
},
});
logger.info('Finished seeding stock location data.');
logger.info('Seeding publishable API key data...');
const { result: publishableApiKeyResult } = await createApiKeysWorkflow(
container,
).run({
input: {
api_keys: [
{
title: 'Webshop',
type: 'publishable',
created_by: '',
},
],
},
});
const publishableApiKey = publishableApiKeyResult[0];
await linkSalesChannelsToApiKeyWorkflow(container).run({
input: {
id: publishableApiKey.id,
add: [defaultSalesChannel[0].id],
},
});
logger.info('Finished seeding publishable API key data.');
logger.info('Seeding product data...');
const { result: categoryResult } = await createProductCategoriesWorkflow(
container,
).run({
input: {
product_categories: [
{
name: 'One seater',
is_active: true,
},
{
name: 'Two seater',
is_active: true,
},
{
name: 'Three seater',
is_active: true,
},
],
},
});
const [sofasImage, armChairsImage] = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'sofas.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/product-types/sofas/image.png',
),
},
{
access: 'public',
filename: 'arm-chairs.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/product-types/arm-chairs/image.png',
),
},
],
},
})
.then((res) => res.result);
const { result: productTypes } = await createProductTypesWorkflow(
container,
).run({
input: {
product_types: [
{
value: 'Sofas',
metadata: {
image: sofasImage,
},
},
{
value: 'Arm Chairs',
metadata: {
image: armChairsImage,
},
},
],
},
});
const [
scandinavianSimplicityImage,
scandinavianSimplicityCollectionPageImage,
scandinavianSimplicityProductPageImage,
scandinavianSimplicityProductPageWideImage,
scandinavianSimplicityProductPageCtaImage,
modernLuxeImage,
modernLuxeCollectionPageImage,
modernLuxeProductPageImage,
modernLuxeProductPageWideImage,
modernLuxeProductPageCtaImage,
bohoChicImage,
bohoChicCollectionPageImage,
bohoChicProductPageImage,
bohoChicProductPageWideImage,
bohoChicProductPageCtaImage,
timelessClassicsImage,
timelessClassicsCollectionPageImage,
timelessClassicsProductPageImage,
timelessClassicsProductPageWideImage,
timelessClassicsProductPageCtaImage,
] = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'scandinavian-simplicity.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/scandinavian-simplicity/image.png',
),
},
{
access: 'public',
filename: 'scandinavian-simplicity-collection-page-image.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/scandinavian-simplicity/collection_page_image.png',
),
},
{
access: 'public',
filename: 'scandinavian-simplicity-product-page-image.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/scandinavian-simplicity/product_page_image.png',
),
},
{
access: 'public',
filename: 'scandinavian-simplicity-product-page-wide-image.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/scandinavian-simplicity/product_page_wide_image.png',
),
},
{
access: 'public',
filename: 'scandinavian-simplicity-product-page-cta-image.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/scandinavian-simplicity/product_page_cta_image.png',
),
},
{
access: 'public',
filename: 'modern-luxe.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/modern-luxe/image.png',
),
},
{
access: 'public',
filename: 'modern-luxe-collection-page-image.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/modern-luxe/collection_page_image.png',
),
},
{
access: 'public',
filename: 'modern-luxe-product-page-image.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/modern-luxe/product_page_image.png',
),
},
{
access: 'public',
filename: 'modern-luxe-product-page-wide-image.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/modern-luxe/product_page_wide_image.png',
),
},
{
access: 'public',
filename: 'modern-luxe-product-page-cta-image.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/modern-luxe/product_page_cta_image.png',
),
},
{
access: 'public',
filename: 'boho-chic.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/boho-chic/image.png',
),
},
{
access: 'public',
filename: 'boho-chic-collection-page-image.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/boho-chic/collection_page_image.png',
),
},
{
access: 'public',
filename: 'boho-chic-product-page-image.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/boho-chic/product_page_image.png',
),
},
{
access: 'public',
filename: 'boho-chic-product-page-wide-image.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/boho-chic/product_page_wide_image.png',
),
},
{
access: 'public',
filename: 'boho-chic-product-page-cta-image.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/boho-chic/product_page_cta_image.png',
),
},
{
access: 'public',
filename: 'timeless-classics.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/timeless-classics/image.png',
),
},
{
access: 'public',
filename: 'timeless-classics-collection-page-image.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/timeless-classics/collection_page_image.png',
),
},
{
access: 'public',
filename: 'timeless-classics-product-page-image.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/timeless-classics/product_page_image.png',
),
},
{
access: 'public',
filename: 'timeless-classics-product-page-wide-image.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/timeless-classics/product_page_wide_image.png',
),
},
{
access: 'public',
filename: 'timeless-classics-product-page-cta-image.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/collections/timeless-classics/product_page_cta_image.png',
),
},
],
},
})
.then((res) => res.result);
const { result: collections } = await createCollectionsWorkflow(
container,
).run({
input: {
collections: [
{
title: 'Scandinavian Simplicity',
handle: 'scandinavian-simplicity',
metadata: {
description:
'Minimalistic designs, neutral colors, and high-quality textures',
image: scandinavianSimplicityImage,
collection_page_image: scandinavianSimplicityCollectionPageImage,
collection_page_heading:
'Scandinavian Simplicity: Effortless elegance, timeless comfort',
collection_page_content: `Minimalistic designs, neutral colors, and high-quality textures. Perfect for those who seek comfort with a clean and understated aesthetic.
This collection brings the essence of Scandinavian elegance to your living room.`,
product_page_heading: 'Collection Inspired Interior',
product_page_image: scandinavianSimplicityProductPageImage,
product_page_wide_image: scandinavianSimplicityProductPageWideImage,
product_page_cta_image: scandinavianSimplicityProductPageCtaImage,
product_page_cta_heading:
"The 'Name of sofa' embodies Scandinavian minimalism with clean lines and a soft, neutral palette.",
product_page_cta_link:
'See more out of ‘Scandinavian Simplicity’ collection',
},
},
{
title: 'Modern Luxe',
handle: 'modern-luxe',
metadata: {
description:
'Sophisticated and sleek, these sofas blend modern design with luxurious comfort',
image: modernLuxeImage,
collection_page_image: modernLuxeCollectionPageImage,
collection_page_heading:
'Modern Luxe: Where modern design meets luxurious living',
collection_page_content: `Sophisticated and sleek, these sofas blend modern design with luxurious comfort. Bold lines and premium materials create the ultimate statement pieces for any contemporary home.
Elevate your space with timeless beauty.`,
product_page_heading: 'Collection Inspired Interior',
product_page_image: modernLuxeProductPageImage,
product_page_wide_image: modernLuxeProductPageWideImage,
product_page_cta_image: modernLuxeProductPageCtaImage,
product_page_cta_heading:
"The 'Name of sofa' is a masterpiece of minimalism and luxury.",
product_page_cta_link: 'See more out of ‘Modern Luxe’ collection',
},
},
{
title: 'Boho Chic',
handle: 'boho-chic',
metadata: {
description:
'Infused with playful textures and vibrant patterns with eclectic vibes',
image: bohoChicImage,
collection_page_image: bohoChicCollectionPageImage,
collection_page_heading:
'Boho Chic: Relaxed, eclectic style with a touch of free-spirited charm',
collection_page_content: `Infused with playful textures and vibrant patterns, this collection embodies relaxed, eclectic vibes. Soft fabrics and creative designs add warmth and personality to any room.
It’s comfort with a bold, carefree spirit.`,
product_page_heading: 'Collection Inspired Interior',
product_page_image: bohoChicProductPageImage,
product_page_wide_image: bohoChicProductPageWideImage,
product_page_cta_image: bohoChicProductPageCtaImage,
product_page_cta_heading:
"The 'Name of sofa' captures the essence of boho style with its relaxed, oversized form and eclectic fabric choices.",
product_page_cta_link: 'See more out of ‘Boho Chic’ collection',
},
},
{
title: 'Timeless Classics',
handle: 'timeless-classics',
metadata: {
description:
'Elegant shapes and rich textures, traditional craftsmanship with modern comfort',
image: timelessClassicsImage,
collection_page_image: timelessClassicsCollectionPageImage,
collection_page_heading:
'Timeless Classics: Enduring style, crafted for comfort and lasting beauty',
collection_page_content: `Designed for those who appreciate enduring style, this collection features elegant shapes and rich textures. These sofas combine traditional craftsmanship with modern comfort.
Perfect for creating a warm, inviting atmosphere that never goes out of style.`,
product_page_heading: 'Collection Inspired Interior',
product_page_image: timelessClassicsProductPageImage,
product_page_wide_image: timelessClassicsProductPageWideImage,
product_page_cta_image: timelessClassicsProductPageCtaImage,
product_page_cta_heading:
"The 'Name of sofa' brings a touch of traditional charm with its elegant curves and classic silhouette",
product_page_cta_link:
'See more out of ‘Timeless Classics’ collection',
},
},
],
},
});
const materials: MaterialModelType[] =
await fashionModuleService.createMaterials([
{
name: 'Velvet',
},
{
name: 'Linen',
},
{
name: 'Boucle',
},
{
name: 'Leather',
},
{
name: 'Microfiber',
},
]);
await fashionModuleService.createColors([
// Velvet
{
name: 'Black',
hex_code: '#4C4D4E',
material_id: materials.find((m) => m.name === 'Velvet').id,
},
{
name: 'Purple',
hex_code: '#904C94',
material_id: materials.find((m) => m.name === 'Velvet').id,
},
// Linen
{
name: 'Green',
hex_code: '#438849',
material_id: materials.find((m) => m.name === 'Linen').id,
},
{
name: 'Light Gray',
hex_code: '#B1B1B1',
material_id: materials.find((m) => m.name === 'Linen').id,
},
{
name: 'Yellow',
hex_code: '#F1BD37',
material_id: materials.find((m) => m.name === 'Linen').id,
},
{
name: 'Red',
hex_code: '#CD1F23',
material_id: materials.find((m) => m.name === 'Linen').id,
},
{
name: 'Blue',
hex_code: '#475F8A',
material_id: materials.find((m) => m.name === 'Linen').id,
},
// Microfiber
{
name: 'Orange',
hex_code: '#EF7218',
material_id: materials.find((m) => m.name === 'Microfiber').id,
},
{
name: 'Dark Gray',
hex_code: '#4A4A4A',
material_id: materials.find((m) => m.name === 'Microfiber').id,
},
{
name: 'Black',
hex_code: '#282828',
material_id: materials.find((m) => m.name === 'Microfiber').id,
},
// Boucle
{
name: 'Beige',
hex_code: '#C8BCB3',
material_id: materials.find((m) => m.name === 'Boucle').id,
},
{
name: 'White',
hex_code: '#EAEAEA',
material_id: materials.find((m) => m.name === 'Boucle').id,
},
{
name: 'Light Gray',
hex_code: '#C3C0BE',
material_id: materials.find((m) => m.name === 'Boucle').id,
},
// Leather
{
name: 'Violet',
hex_code: '#B1ABBF',
material_id: materials.find((m) => m.name === 'Leather').id,
},
{
name: 'Beige',
hex_code: '#A79D9B',
material_id: materials.find((m) => m.name === 'Leather').id,
},
]);
const astridCurveImages = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'astrid-curve.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/astrid-curve/image.png',
),
},
{
access: 'public',
filename: 'astrid-curve-2.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/astrid-curve/image1.png',
),
},
],
},
})
.then((res) => res.result);
await createProductsWorkflow(container).run({
input: {
products: [
{
title: 'Astrid Curve',
handle: 'astrid-curve',
description:
'The Astrid Curve combines flowing curves and cozy, textured fabric for a truly bohemian vibe. Its relaxed design adds character and comfort, perfect for eclectic living spaces with a free-spirited charm.',
category_ids: [
categoryResult.find((cat) => cat.name === 'Three seater').id,
],
collection_id: collections.find((c) => c.handle === 'boho-chic').id,
type_id: productTypes.find((pt) => pt.value === 'Sofas').id,
status: ProductStatus.PUBLISHED,
images: astridCurveImages,
options: [
{
title: 'Material',
values: ['Microfiber', 'Velvet'],
},
{
title: 'Color',
values: ['Dark Gray', 'Purple'],
},
],
variants: [
{
title: 'Microfiber / Dark Gray',
sku: 'ASTRID-CURVE-MICROFIBER-DARK-GRAY',
options: {
Material: 'Microfiber',
Color: 'Dark Gray',
},
manage_inventory: false,
prices: [
{
amount: 1500,
currency_code: 'eur',
},
{
amount: 1700,
currency_code: 'usd',
},
],
},
{
title: 'Velvet / Purple',
sku: 'ASTRID-CURVE-VELVET-PURPLE',
options: {
Material: 'Velvet',
Color: 'Purple',
},
manage_inventory: false,
prices: [
{
amount: 2000,
currency_code: 'eur',
},
{
amount: 2200,
currency_code: 'usd',
},
],
},
],
sales_channels: [
{
id: defaultSalesChannel[0].id,
},
],
},
],
},
});
const belimeEstateImages = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'belime-estate.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/belime-estate/image.png',
),
},
{
access: 'public',
filename: 'belime-estate-2.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/belime-estate/image1.png',
),
},
],
},
})
.then((res) => res.result);
await createProductsWorkflow(container).run({
input: {
products: [
{
title: 'Belime Estate',
handle: 'belime-estate',
description:
'The Belime Estate exudes classic sophistication with its tufted back and rich fabric. Its luxurious look and enduring comfort make it a perfect fit for traditional, elegant interiors.',
category_ids: [
categoryResult.find((cat) => cat.name === 'Two seater').id,
],
collection_id: collections.find(
(c) => c.handle === 'timeless-classics',
).id,
type_id: productTypes.find((pt) => pt.value === 'Sofas').id,
status: ProductStatus.PUBLISHED,
images: belimeEstateImages,
options: [
{
title: 'Material',
values: ['Linen', 'Boucle'],
},
{
title: 'Color',
values: ['Red', 'Blue', 'Beige'],
},
],
variants: [
{
title: 'Linen / Red',
sku: 'BELIME-ESTATE-LINEN-RED',
options: {
Material: 'Linen',
Color: 'Red',
},
manage_inventory: false,
prices: [
{
amount: 1500,
currency_code: 'eur',
},
{
amount: 1700,
currency_code: 'usd',
},
],
},
{
title: 'Linen / Blue',
sku: 'BELIME-ESTATE-LINEN-BLUE',
options: {
Material: 'Linen',
Color: 'Blue',
},
manage_inventory: false,
prices: [
{
amount: 1500,
currency_code: 'eur',
},
{
amount: 1700,
currency_code: 'usd',
},
],
},
{
title: 'Boucle / Beige',
sku: 'BELIME-ESTATE-BOUCLE-BEIGE',
options: {
Material: 'Boucle',
Color: 'Beige',
},
manage_inventory: false,
prices: [
{
amount: 2000,
currency_code: 'eur',
},
{
amount: 2200,
currency_code: 'usd',
},
],
},
],
sales_channels: [
{
id: defaultSalesChannel[0].id,
},
],
},
],
},
});
const cypressRetreatImages = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'cypress-retreat.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/cypress-retreat/image.png',
),
},
{
access: 'public',
filename: 'cypress-retreat-2.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/cypress-retreat/image1.png',
),
},
],
},
})
.then((res) => res.result);
await createProductsWorkflow(container).run({
input: {
products: [
{
title: 'Cypress Retreat',
handle: 'cypress-retreat',
description:
'The Cypress Retreat is a nod to traditional design with its elegant lines and durable, high-quality upholstery. A timeless choice, it offers long-lasting comfort and a refined aesthetic for any home.',
category_ids: [
categoryResult.find((cat) => cat.name === 'Three seater').id,
],
collection_id: collections.find(
(c) => c.handle === 'timeless-classics',
).id,
type_id: productTypes.find((pt) => pt.value === 'Sofas').id,
status: ProductStatus.PUBLISHED,
images: cypressRetreatImages,
options: [
{
title: 'Material',
values: ['Leather'],
},
{
title: 'Color',
values: ['Beige', 'Violet'],
},
],
variants: [
{
title: 'Leather / Beige',
sku: 'CYPRESS-RETREAT-LEATHER-BEIGE',
options: {
Material: 'Leather',
Color: 'Beige',
},
manage_inventory: false,
prices: [
{
amount: 1500,
currency_code: 'eur',
},
{
amount: 1700,
currency_code: 'usd',
},
],
},
{
title: 'Leather / Violet',
sku: 'CYPRESS-RETREAT-LEATHER-VIOLET',
options: {
Material: 'Leather',
Color: 'Violet',
},
manage_inventory: false,
prices: [
{
amount: 2000,
currency_code: 'eur',
},
{
amount: 2200,
currency_code: 'usd',
},
],
},
],
sales_channels: [
{
id: defaultSalesChannel[0].id,
},
],
},
],
},
});
const everlyEstateImages = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'everly-estate.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/everly-estate/image.png',
),
},
{
access: 'public',
filename: 'everly-estate-2.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/everly-estate/image1.png',
),
},
],
},
})
.then((res) => res.result);
await createProductsWorkflow(container).run({
input: {
products: [
{
title: 'Everly Estate',
handle: 'everly-estate',
description:
'The Everly Estate offers a blend of modern elegance and plush luxury, with its sleek lines and soft velvet upholstery. Perfect for upscale interiors, it exudes sophistication and comfort in equal measure.',
category_ids: [
categoryResult.find((cat) => cat.name === 'Two seater').id,
],
collection_id: collections.find((c) => c.handle === 'modern-luxe').id,
type_id: productTypes.find((pt) => pt.value === 'Sofas').id,
status: ProductStatus.PUBLISHED,
images: everlyEstateImages,
options: [
{
title: 'Material',
values: ['Microfiber', 'Velvet'],
},
{
title: 'Color',
values: ['Orange', 'Black'],
},
],
variants: [
{
title: 'Microfiber / Orange',
sku: 'EVERLY-ESTATE-MICROFIBER-ORANGE',
options: {
Material: 'Microfiber',
Color: 'Orange',
},
manage_inventory: false,
prices: [
{
amount: 1500,
currency_code: 'eur',
},
{
amount: 1700,
currency_code: 'usd',
},
],
},
{
title: 'Velvet / Black',
sku: 'EVERLY-ESTATE-VELVET-BLACK',
options: {
Material: 'Velvet',
Color: 'Black',
},
manage_inventory: false,
prices: [
{
amount: 2000,
currency_code: 'eur',
},
{
amount: 2200,
currency_code: 'usd',
},
],
},
],
sales_channels: [
{
id: defaultSalesChannel[0].id,
},
],
},
],
},
});
const havenhillEstateImages = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'havenhill-estate.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/havenhill-estate/image.png',
),
},
{
access: 'public',
filename: 'havenhill-estate-2.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/havenhill-estate/image1.png',
),
},
],
},
})
.then((res) => res.result);
await createProductsWorkflow(container).run({
input: {
products: [
{
title: 'Havenhill Estate',
handle: 'havenhill-estate',
description:
'The Havenhill Estate brings a touch of traditional charm with its elegant curves and classic silhouette. Upholstered in durable, luxurious fabric, it’s a timeless piece that combines comfort and style, fitting seamlessly into any sophisticated home.',
category_ids: [
categoryResult.find((cat) => cat.name === 'One seater').id,
],
collection_id: collections.find(
(c) => c.handle === 'timeless-classics',
).id,
type_id: productTypes.find((pt) => pt.value === 'Arm Chairs').id,
status: ProductStatus.PUBLISHED,
images: havenhillEstateImages,
options: [
{
title: 'Material',
values: ['Linen', 'Boucle'],
},
{
title: 'Color',
values: ['Green', 'Light Gray', 'Yellow'],
},
],
variants: [
{
title: 'Linen / Green',
sku: 'HAVENHILL-ESTATE-LINEN-GREEN',
options: {
Material: 'Linen',
Color: 'Green',
},
manage_inventory: false,
prices: [
{
amount: 1000,
currency_code: 'eur',
},
{
amount: 1200,
currency_code: 'usd',
},
],
},
{
title: 'Boucle / Light Gray',
sku: 'HAVENHILL-ESTATE-BOUCLE-LIGHT-GRAY',
options: {
Material: 'Boucle',
Color: 'Light Gray',
},
manage_inventory: false,
prices: [
{
amount: 1200,
currency_code: 'eur',
},
{
amount: 1400,
currency_code: 'usd',
},
],
},
],
sales_channels: [
{
id: defaultSalesChannel[0].id,
},
],
},
],
},
});
const monacoFlairImages = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'monaco-flair.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/monaco-flair/image.png',
),
},
{
access: 'public',
filename: 'monaco-flair-2.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/monaco-flair/image1.png',
),
},
],
},
})
.then((res) => res.result);
await createProductsWorkflow(container).run({
input: {
products: [
{
title: 'Monaco Flair',
handle: 'monaco-flair',
description:
'The Monaco Flair combines sleek metallic accents with rich fabric, delivering a bold, luxurious statement. Its minimalist design and deep seating make it a standout piece for modern living rooms.',
category_ids: [
categoryResult.find((cat) => cat.name === 'Three seater').id,
],
collection_id: collections.find((c) => c.handle === 'modern-luxe').id,
type_id: productTypes.find((pt) => pt.value === 'Sofas').id,
status: ProductStatus.PUBLISHED,
images: monacoFlairImages,
options: [
{
title: 'Material',
values: ['Linen', 'Boucle'],
},
{
title: 'Color',
values: ['Green', 'Light Gray', 'Beige'],
},
],
variants: [
{
title: 'Linen / Green',
sku: 'MONACO-FLAIR-LINEN-GREEN',
options: {
Material: 'Linen',
Color: 'Green',
},
manage_inventory: false,
prices: [
{
amount: 1500,
currency_code: 'eur',
},
{
amount: 1700,
currency_code: 'usd',
},
],
},
{
title: 'Boucle / Light Gray',
sku: 'MONACO-FLAIR-BOUCLE-LIGHT-GRAY',
options: {
Material: 'Boucle',
Color: 'Light Gray',
},
manage_inventory: false,
prices: [
{
amount: 2000,
currency_code: 'eur',
},
{
amount: 2200,
currency_code: 'usd',
},
],
},
{
title: 'Boucle / Beige',
sku: 'MONACO-FLAIR-BOUCLE-BEIGE',
options: {
Material: 'Boucle',
Color: 'Beige',
},
manage_inventory: false,
prices: [
{
amount: 2000,
currency_code: 'eur',
},
{
amount: 2200,
currency_code: 'usd',
},
],
},
],
sales_channels: [
{
id: defaultSalesChannel[0].id,
},
],
},
],
},
});
const nordicBreezeImages = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'nordic-breeze.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/nordic-breeze/image.png',
),
},
{
access: 'public',
filename: 'nordic-breeze-2.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/nordic-breeze/image1.png',
),
},
],
},
})
.then((res) => res.result);
await createProductsWorkflow(container).run({
input: {
products: [
{
title: 'Nordic Breeze',
handle: 'nordic-breeze',
description:
'The Nordic Breeze is a refined expression of Scandinavian minimalism, with its crisp silhouette and airy aesthetic. Crafted for both comfort and simplicity, it’s perfect for creating a serene living space.',
category_ids: [
categoryResult.find((cat) => cat.name === 'One seater').id,
],
collection_id: collections.find(
(c) => c.handle === 'scandinavian-simplicity',
).id,
type_id: productTypes.find((pt) => pt.value === 'Arm Chairs').id,
status: ProductStatus.PUBLISHED,
images: nordicBreezeImages,
options: [
{
title: 'Material',
values: ['Boucle', 'Linen'],
},
{
title: 'Color',
values: ['Beige', 'White', 'Light Gray'],
},
],
variants: [
{
title: 'Boucle / Beige',
sku: 'NORDIC-BREEZE-BOUCLE-BEIGE',
options: {
Material: 'Boucle',
Color: 'Beige',
},
manage_inventory: false,
prices: [
{
amount: 1200,
currency_code: 'eur',
},
{
amount: 1400,
currency_code: 'usd',
},
],
},
{
title: 'Boucle / White',
sku: 'NORDIC-BREEZE-BOUCLE-WHITE',
options: {
Material: 'Boucle',
Color: 'White',
},
manage_inventory: false,
prices: [
{
amount: 1200,
currency_code: 'eur',
},
{
amount: 1400,
currency_code: 'usd',
},
],
},
{
title: 'Linen / Light Gray',
sku: 'NORDIC-BREEZE-LINEN-LIGHT-GRAY',
options: {
Material: 'Linen',
Color: 'Light Gray',
},
manage_inventory: false,
prices: [
{
amount: 1800,
currency_code: 'eur',
},
{
amount: 2000,
currency_code: 'usd',
},
],
},
],
sales_channels: [
{
id: defaultSalesChannel[0].id,
},
],
},
],
},
});
const nordicHavenImages = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'nordic-haven.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/nordic-haven/image.png',
),
},
{
access: 'public',
filename: 'nordic-haven-2.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/nordic-haven/image1.png',
),
},
],
},
})
.then((res) => res.result);
await createProductsWorkflow(container).run({
input: {
products: [
{
title: 'Nordic Haven',
handle: 'nordic-haven',
description:
'The Nordic Haven features clean lines and soft textures, embodying the essence of Scandinavian design. Its natural tones and minimalist frame bring effortless serenity and comfort to any home.',
category_ids: [
categoryResult.find((cat) => cat.name === 'Three seater').id,
],
collection_id: collections.find(
(c) => c.handle === 'scandinavian-simplicity',
).id,
type_id: productTypes.find((pt) => pt.value === 'Sofas').id,
status: ProductStatus.PUBLISHED,
images: nordicHavenImages,
options: [
{
title: 'Material',
values: ['Linen', 'Boucle'],
},
{
title: 'Color',
values: ['Light Gray', 'White', 'Beige'],
},
],
variants: [
{
title: 'Linen / Light Gray',
sku: 'NORDIC-HAVEN-LINEN-LIGHT-GRAY',
options: {
Material: 'Linen',
Color: 'Light Gray',
},
manage_inventory: false,
prices: [
{
amount: 1500,
currency_code: 'eur',
},
{
amount: 1700,
currency_code: 'usd',
},
],
},
{
title: 'Boucle / White',
sku: 'NORDIC-HAVEN-BOUCLE-WHITE',
options: {
Material: 'Boucle',
Color: 'White',
},
manage_inventory: false,
prices: [
{
amount: 2000,
currency_code: 'eur',
},
{
amount: 2200,
currency_code: 'usd',
},
],
},
{
title: 'Boucle / Beige',
sku: 'NORDIC-HAVEN-BOUCLE-BEIGE',
options: {
Material: 'Boucle',
Color: 'Beige',
},
manage_inventory: false,
prices: [
{
amount: 2000,
currency_code: 'eur',
},
{
amount: 2200,
currency_code: 'usd',
},
],
},
],
sales_channels: [
{
id: defaultSalesChannel[0].id,
},
],
},
],
},
});
const osloDriftImages = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'oslo-drift.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/oslo-drift/image.png',
),
},
{
access: 'public',
filename: 'oslo-drift-2.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/oslo-drift/image1.png',
),
},
],
},
})
.then((res) => res.result);
await createProductsWorkflow(container).run({
input: {
products: [
{
title: 'Oslo Drift',
handle: 'oslo-drift',
description:
'The Oslo Drift is designed for ultimate relaxation, with soft, supportive cushions and a sleek, modern frame. Its understated elegance and neutral tones make it an ideal fit for contemporary, minimalist homes.',
category_ids: [
categoryResult.find((cat) => cat.name === 'Two seater').id,
],
collection_id: collections.find(
(c) => c.handle === 'scandinavian-simplicity',
).id,
type_id: productTypes.find((pt) => pt.value === 'Sofas').id,
status: ProductStatus.PUBLISHED,
images: osloDriftImages,
options: [
{
title: 'Material',
values: ['Boucle', 'Linen'],
},
{
title: 'Color',
values: ['White', 'Beige', 'Light Gray'],
},
],
variants: [
{
title: 'Boucle / White',
sku: 'OSLO-DRIFT-BOUCLE-WHITE',
options: {
Material: 'Boucle',
Color: 'White',
},
manage_inventory: false,
prices: [
{
amount: 1500,
currency_code: 'eur',
},
{
amount: 1700,
currency_code: 'usd',
},
],
},
{
title: 'Boucle / Beige',
sku: 'OSLO-DRIFT-BOUCLE-BEIGE',
options: {
Material: 'Boucle',
Color: 'Beige',
},
manage_inventory: false,
prices: [
{
amount: 2000,
currency_code: 'eur',
},
{
amount: 2200,
currency_code: 'usd',
},
],
},
{
title: 'Linen / Light Gray',
sku: 'OSLO-DRIFT-LINEN-LIGHT-GRAY',
options: {
Material: 'Linen',
Color: 'Light Gray',
},
manage_inventory: false,
prices: [
{
amount: 2000,
currency_code: 'eur',
},
{
amount: 2200,
currency_code: 'usd',
},
],
},
],
sales_channels: [
{
id: defaultSalesChannel[0].id,
},
],
},
],
},
});
const osloSerenityImages = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'oslo-serenity.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/oslo-serenity/image.png',
),
},
{
access: 'public',
filename: 'oslo-serenity-2.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/oslo-serenity/image1.png',
),
},
],
},
})
.then((res) => res.result);
await createProductsWorkflow(container).run({
input: {
products: [
{
title: 'Oslo Serenity',
handle: 'oslo-serenity',
description:
'The Oslo Serenity embodies Scandinavian minimalism with clean lines and a soft, neutral palette. Its tailored silhouette and plush cushions deliver a balance of simplicity and comfort, making it perfect for those who value understated elegance.',
category_ids: [
categoryResult.find((cat) => cat.name === 'Two seater').id,
],
collection_id: collections.find(
(c) => c.handle === 'scandinavian-simplicity',
).id,
type_id: productTypes.find((pt) => pt.value === 'Sofas').id,
status: ProductStatus.PUBLISHED,
images: osloSerenityImages,
options: [
{
title: 'Material',
values: ['Leather'],
},
{
title: 'Color',
values: ['Violet', 'Beige'],
},
],
variants: [
{
title: 'Leather / Violet',
sku: 'OSLO-SERENITY-LEATHER-VIOLET',
options: {
Material: 'Leather',
Color: 'Violet',
},
manage_inventory: false,
prices: [
{
amount: 1500,
currency_code: 'eur',
},
{
amount: 1700,
currency_code: 'usd',
},
],
},
{
title: 'Leather / Beige',
sku: 'OSLO-SERENITY-LEATHER-BEIGE',
options: {
Material: 'Leather',
Color: 'Beige',
},
manage_inventory: false,
prices: [
{
amount: 2000,
currency_code: 'eur',
},
{
amount: 2200,
currency_code: 'usd',
},
],
},
],
sales_channels: [
{
id: defaultSalesChannel[0].id,
},
],
},
],
},
});
const palomaHavenImages = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'paloma-haven.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/paloma-haven/image.png',
),
},
{
access: 'public',
filename: 'paloma-haven-2.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/paloma-haven/image1.png',
),
},
],
},
})
.then((res) => res.result);
await createProductsWorkflow(container).run({
input: {
products: [
{
title: 'Paloma Haven',
handle: 'paloma-haven',
description:
'Minimalistic designs, neutral colors, and high-quality textures. Perfect for those who seek comfort with a clean and understated aesthetic. This collection brings the essence of Scandinavian elegance to your living room.',
category_ids: [
categoryResult.find((cat) => cat.name === 'One seater').id,
],
collection_id: collections.find((c) => c.handle === 'modern-luxe').id,
type_id: productTypes.find((pt) => pt.value === 'Arm Chairs').id,
status: ProductStatus.PUBLISHED,
images: palomaHavenImages,
options: [
{
title: 'Material',
values: ['Linen', 'Boucle'],
},
{
title: 'Color',
values: ['Light Gray', 'Green', 'Beige'],
},
],
variants: [
{
title: 'Linen / Light Gray',
sku: 'PALOMA-HAVEN-LINEN-LIGHT-GRAY',
options: {
Material: 'Linen',
Color: 'Light Gray',
},
manage_inventory: false,
prices: [
{
amount: 900,
currency_code: 'eur',
},
{
amount: 1100,
currency_code: 'usd',
},
],
},
{
title: 'Linen / Green',
sku: 'PALOMA-HAVEN-LINEN-GREEN',
options: {
Material: 'Linen',
Color: 'Green',
},
manage_inventory: false,
prices: [
{
amount: 900,
currency_code: 'eur',
},
{
amount: 1100,
currency_code: 'usd',
},
],
},
{
title: 'Boucle / Beige',
sku: 'PALOMA-HAVEN-BOUCLE-BEIGE',
options: {
Material: 'Boucle',
Color: 'Beige',
},
manage_inventory: false,
prices: [
{
amount: 1200,
currency_code: 'eur',
},
{
amount: 1400,
currency_code: 'usd',
},
],
},
],
sales_channels: [
{
id: defaultSalesChannel[0].id,
},
],
},
],
},
});
const savannahGroveImages = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'savannah-grove.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/savannah-grove/image.png',
),
},
{
access: 'public',
filename: 'savannah-grove-2.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/savannah-grove/image1.png',
),
},
],
},
})
.then((res) => res.result);
await createProductsWorkflow(container).run({
input: {
products: [
{
title: 'Savannah Grove',
handle: 'savannah-grove',
description:
'The Savannah Grove captures the essence of boho style with its relaxed, oversized form and eclectic fabric choices. Designed for both comfort and personality, it’s the ideal piece for those who seek a cozy, free-spirited vibe in their living spaces.',
category_ids: [
categoryResult.find((cat) => cat.name === 'One seater').id,
],
collection_id: collections.find((c) => c.handle === 'boho-chic').id,
type_id: productTypes.find((pt) => pt.value === 'Arm Chairs').id,
status: ProductStatus.PUBLISHED,
images: savannahGroveImages,
options: [
{
title: 'Material',
values: ['Boucle', 'Linen'],
},
{
title: 'Color',
values: ['Light Gray', 'Yellow'],
},
],
variants: [
{
title: 'Boucle / Light Gray',
sku: 'SAVANNAH-GROVE-BOUCLE-LIGHT-GRAY',
options: {
Material: 'Boucle',
Color: 'Light Gray',
},
manage_inventory: false,
prices: [
{
amount: 1200,
currency_code: 'eur',
},
{
amount: 1400,
currency_code: 'usd',
},
],
},
{
title: 'Linen / Yellow',
sku: 'SAVANNAH-GROVE-LINEN-YELLOW',
options: {
Material: 'Linen',
Color: 'Yellow',
},
manage_inventory: false,
prices: [
{
amount: 900,
currency_code: 'eur',
},
{
amount: 1100,
currency_code: 'usd',
},
],
},
{
title: 'Linen / Light Gray',
sku: 'SAVANNAH-GROVE-LINEN-LIGHT-GRAY',
options: {
Material: 'Linen',
Color: 'Light Gray',
},
manage_inventory: false,
prices: [
{
amount: 900,
currency_code: 'eur',
},
{
amount: 1100,
currency_code: 'usd',
},
],
},
],
sales_channels: [
{
id: defaultSalesChannel[0].id,
},
],
},
],
},
});
const serenaMeadowImages = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'serena-meadow.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/serena-meadow/image.png',
),
},
{
access: 'public',
filename: 'serena-meadow-2.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/serena-meadow/image1.png',
),
},
],
},
})
.then((res) => res.result);
await createProductsWorkflow(container).run({
input: {
products: [
{
title: 'Serena Meadow',
handle: 'serena-meadow',
description:
'The Serena Meadow combines a classic silhouette with modern comfort, offering a relaxed yet polished look. Its soft upholstery and subtle curves bring a timeless elegance to any living room.',
category_ids: [
categoryResult.find((cat) => cat.name === 'Two seater').id,
],
collection_id: collections.find(
(c) => c.handle === 'timeless-classics',
).id,
type_id: productTypes.find((pt) => pt.value === 'Sofas').id,
status: ProductStatus.PUBLISHED,
images: serenaMeadowImages,
options: [
{
title: 'Material',
values: ['Microfiber', 'Velvet'],
},
{
title: 'Color',
values: ['Black', 'Dark Gray'],
},
],
variants: [
{
title: 'Microfiber / Black',
sku: 'SERENA-MEADOW-MICROFIBER-BLACK',
options: {
Material: 'Microfiber',
Color: 'Black',
},
manage_inventory: false,
prices: [
{
amount: 1500,
currency_code: 'eur',
},
{
amount: 1700,
currency_code: 'usd',
},
],
},
{
title: 'Microfiber / Dark Gray',
sku: 'SERENA-MEADOW-MICROFIBER-DARK-GRAY',
options: {
Material: 'Microfiber',
Color: 'Dark Gray',
},
manage_inventory: false,
prices: [
{
amount: 2000,
currency_code: 'eur',
},
{
amount: 2200,
currency_code: 'usd',
},
],
},
{
title: 'Velvet / Black',
sku: 'SERENA-MEADOW-VELVET-BLACK',
options: {
Material: 'Velvet',
Color: 'Black',
},
manage_inventory: false,
prices: [
{
amount: 2000,
currency_code: 'eur',
},
{
amount: 2200,
currency_code: 'usd',
},
],
},
],
sales_channels: [
{
id: defaultSalesChannel[0].id,
},
],
},
],
},
});
const suttonRoyaleImages = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'sutton-royale.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/sutton-royale/image.png',
),
},
{
access: 'public',
filename: 'sutton-royale-2.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/sutton-royale/image1.png',
),
},
],
},
})
.then((res) => res.result);
await createProductsWorkflow(container).run({
input: {
products: [
{
title: 'Sutton Royale',
handle: 'sutton-royale',
description:
'The Sutton Royale blends eclectic design with classic bohemian comfort, featuring soft, tufted fabric and a wide, welcoming frame. Its unique style adds a touch of vintage flair to any space.',
category_ids: [
categoryResult.find((cat) => cat.name === 'Two seater').id,
],
collection_id: collections.find((c) => c.handle === 'boho-chic').id,
type_id: productTypes.find((pt) => pt.value === 'Sofas').id,
status: ProductStatus.PUBLISHED,
images: suttonRoyaleImages,
options: [
{
title: 'Material',
values: ['Velvet', 'Microfiber'],
},
{
title: 'Color',
values: ['Purple', 'Dark Gray'],
},
],
variants: [
{
title: 'Velvet / Purple',
sku: 'SUTTON-ROYALE-VELVET-PURPLE',
options: {
Material: 'Velvet',
Color: 'Purple',
},
manage_inventory: false,
prices: [
{
amount: 1500,
currency_code: 'eur',
},
{
amount: 1700,
currency_code: 'usd',
},
],
},
{
title: 'Microfiber / Dark Gray',
sku: 'SUTTON-ROYALE-MICROFIBER-DARK-GRAY',
options: {
Material: 'Microfiber',
Color: 'Dark Gray',
},
manage_inventory: false,
prices: [
{
amount: 2000,
currency_code: 'eur',
},
{
amount: 2200,
currency_code: 'usd',
},
],
},
],
sales_channels: [
{
id: defaultSalesChannel[0].id,
},
],
},
],
},
});
const velarLoftImages = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'velar-loft.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/velar-loft/image.png',
),
},
{
access: 'public',
filename: 'velar-loft-2.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/velar-loft/image1.png',
),
},
],
},
})
.then((res) => res.result);
await createProductsWorkflow(container).run({
input: {
products: [
{
title: 'Velar Loft',
handle: 'velar-loft',
description:
'The Velar Loft offers a refined blend of modern design and opulent comfort. Upholstered in rich fabric with sleek metallic accents, this sofa delivers both luxury and a contemporary edge, making it a striking centerpiece for sophisticated interiors.',
category_ids: [
categoryResult.find((cat) => cat.name === 'One seater').id,
],
collection_id: collections.find((c) => c.handle === 'modern-luxe').id,
type_id: productTypes.find((pt) => pt.value === 'Arm Chairs').id,
status: ProductStatus.PUBLISHED,
images: velarLoftImages,
options: [
{
title: 'Material',
values: ['Velvet', 'Microfiber'],
},
{
title: 'Color',
values: ['Black', 'Orange'],
},
],
variants: [
{
title: 'Velvet / Black',
sku: 'VELAR-LOFT-VELVET-BLACK',
options: {
Material: 'Velvet',
Color: 'Black',
},
manage_inventory: false,
prices: [
{
amount: 1300,
currency_code: 'eur',
},
{
amount: 1500,
currency_code: 'usd',
},
],
},
{
title: 'Microfiber / Orange',
sku: 'VELAR-LOFT-MICROFIBER-ORANGE',
options: {
Material: 'Microfiber',
Color: 'Orange',
},
manage_inventory: false,
prices: [
{
amount: 1100,
currency_code: 'eur',
},
{
amount: 1300,
currency_code: 'usd',
},
],
},
],
sales_channels: [
{
id: defaultSalesChannel[0].id,
},
],
},
],
},
});
const veloraLuxeImages = await uploadFilesWorkflow(container)
.run({
input: {
files: [
{
access: 'public',
filename: 'velora-luxe.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/velora-luxe/image.png',
),
},
{
access: 'public',
filename: 'velora-luxe-2.png',
mimeType: 'image/png',
content: await getImageUrlContent(
'https://assets.agilo.com/fashion-starter/products/velora-luxe/image1.png',
),
},
],
},
})
.then((res) => res.result);
await createProductsWorkflow(container).run({
input: {
products: [
{
title: 'Velora Luxe',
handle: 'velora-luxe',
description:
'The Velora Luxe brings a touch of luxury to bohemian design with its bold patterns and plush comfort. Its oversized shape and inviting cushions make it an ideal centerpiece for laid-back, stylish interiors.',
category_ids: [
categoryResult.find((cat) => cat.name === 'Three seater').id,
],
collection_id: collections.find((c) => c.handle === 'boho-chic').id,
type_id: productTypes.find((pt) => pt.value === 'Sofas').id,
status: ProductStatus.PUBLISHED,
images: veloraLuxeImages,
options: [
{
title: 'Material',
values: ['Linen', 'Boucle'],
},
{
title: 'Color',
values: ['Yellow', 'Light Gray'],
},
],
variants: [
{
title: 'Linen / Yellow',
sku: 'VELORA-LUXE-LINEN-YELLOW',
options: {
Material: 'Linen',
Color: 'Yellow',
},
manage_inventory: false,
prices: [
{
amount: 1500,
currency_code: 'eur',
},
{
amount: 1700,
currency_code: 'usd',
},
],
},
{
title: 'Boucle / Light Gray',
sku: 'VELORA-LUXE-BOUCLE-LIGHT-GRAY',
options: {
Material: 'Boucle',
Color: 'Light Gray',
},
manage_inventory: false,
prices: [
{
amount: 2000,
currency_code: 'eur',
},
{
amount: 2200,
currency_code: 'usd',
},
],
},
],
sales_channels: [
{
id: defaultSalesChannel[0].id,
},
],
},
],
},
});
logger.info('Finished seeding product data.');
}
================================================
FILE: medusa/src/subscribers/README.md
================================================
# Custom subscribers
Subscribers handle events emitted in the Medusa application.
The subscriber is created in a TypeScript or JavaScript file under the `src/subscribers` directory.
For example, create the file `src/subscribers/product-created.ts` with the following content:
```ts
import {
type SubscriberConfig,
} from "@medusajs/medusa"
// subscriber function
export default async function productCreateHandler() {
console.log("A product was created")
}
// subscriber config
export const config: SubscriberConfig = {
event: "product.created",
}
```
A subscriber file must export:
- The subscriber function that is an asynchronous function executed whenever the associated event is triggered.
- A configuration object defining the event this subscriber is listening to.
## Subscriber Parameters
A subscriber receives an object having the following properties:
- `event`: An object holding the event's details. It has a `data` property, which is the event's data payload.
- `container`: The Medusa container. Use it to resolve modules' main services and other registered resources.
```ts
import type {
SubscriberArgs,
SubscriberConfig,
} from "@medusajs/medusa"
import { IProductModuleService } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
export default async function productCreateHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const productId = data.id
const productModuleService: IProductModuleService =
container.resolve(Modules.PRODUCT)
const product = await productModuleService.retrieve(productId)
console.log(`The product ${product.title} was created`)
}
export const config: SubscriberConfig = {
event: "product.created",
}
```
================================================
FILE: medusa/src/subscribers/auth-password-reset-notification.ts
================================================
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/medusa";
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils";
import type { CustomerDTO } from "@medusajs/framework/types";
export default async function sendPasswordResetNotification({
event: { data },
container,
}: SubscriberArgs<{ entity_id: string; token: string; actor_type: string }>) {
const query = container.resolve(ContainerRegistrationKeys.QUERY);
const notificationModuleService = container.resolve(Modules.NOTIFICATION);
const fields = [
"id",
"email",
"first_name",
"last_name",
] as const satisfies (keyof CustomerDTO)[];
const { data: customers } = await query.graph({
entity: "customer",
fields,
filters: { email: data.entity_id },
});
const customer = customers[0] as Pick;
await notificationModuleService.createNotifications({
to: customer.email,
channel: "email",
template:
data.actor_type === "logged-in-customer"
? "auth-password-reset"
: "auth-forgot-password",
data: { customer, token: data.token },
});
}
export const config: SubscriberConfig = {
event: "auth.password_reset",
};
================================================
FILE: medusa/src/subscribers/customer-welcome-notification.ts
================================================
import type { SubscriberArgs, SubscriberConfig } from '@medusajs/medusa';
import { ContainerRegistrationKeys, Modules } from '@medusajs/framework/utils';
import type { CustomerDTO } from '@medusajs/framework/types';
export default async function sendCustomerWelcomeNotification({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const query = container.resolve(ContainerRegistrationKeys.QUERY);
const notificationModuleService = container.resolve(Modules.NOTIFICATION);
const fields = [
'id',
'email',
'first_name',
'last_name',
] as const satisfies (keyof CustomerDTO)[];
const { data: customers } = await query.graph({
entity: 'customer',
fields,
filters: { id: data.id },
});
const customer = customers[0] as Pick;
await notificationModuleService.createNotifications({
to: customer.email,
channel: 'email',
template: 'customer-welcome',
data: { customer },
});
}
export const config: SubscriberConfig = {
event: 'customer.welcome',
};
================================================
FILE: medusa/src/subscribers/index-products.ts
================================================
import type { SubscriberArgs, SubscriberConfig } from '@medusajs/medusa';
import { Modules } from '@medusajs/framework/utils';
import { ISearchService } from '@medusajs/framework/types';
export default async function indexProductHandler({
event: { data, name },
container,
}: SubscriberArgs<{ id: string }>) {
const productId = data.id;
const logger = container.resolve('logger');
const productModuleService = container.resolve(Modules.PRODUCT);
const meilisearchService = container.resolve(
'meilisearchService',
) as ISearchService;
if (name === 'product.deleted') {
await meilisearchService.deleteDocument('products', productId);
logger.info(`The product ${productId} was deleted from MeiliSearch`);
return;
}
const product = await productModuleService.retrieveProduct(productId, {
relations: ['variants', 'options', 'tags', 'collection', 'type', 'images', 'categories'],
});
if (name === 'product.updated') {
await meilisearchService.replaceDocuments(
'products',
[product],
'products',
);
logger.info(
`The product ${productId} ${product.title} was updated in MeiliSearch`,
);
return;
}
await meilisearchService.addDocuments('products', [product], 'products');
logger.info(
`The product ${productId} ${product.title} was added to MeiliSearch`,
);
}
export const config: SubscriberConfig = {
event: ['product.created', 'product.updated', 'product.deleted'],
};
================================================
FILE: medusa/src/subscribers/order-placed-notification.ts
================================================
import type { SubscriberArgs, SubscriberConfig } from '@medusajs/medusa';
import {
ContainerRegistrationKeys,
MathBN,
Modules,
} from '@medusajs/framework/utils';
import type { OrderPlacedEmailProps } from '../modules/resend/emails/order-placed';
type Country = {
iso_2: string;
name: string;
display_name: string;
};
type MathBNInput = Parameters[0];
const toNumber = (value: MathBNInput | null | undefined): number =>
MathBN.convert(value ?? 0).toNumber();
const buildVariantOptionValues = (item: {
variant_option_values?: Record;
variant?: { options?: unknown[] };
}): Record => {
if (
item.variant_option_values &&
Object.keys(item.variant_option_values).length
) {
return Object.entries(item.variant_option_values).reduce<
Record
>((acc, [key, value]) => {
if (typeof value === 'string') {
acc[key] = value;
}
return acc;
}, {});
}
return (
item.variant?.options?.reduce>((acc, option) => {
if (!option || typeof option !== 'object') {
return acc;
}
const optionRecord = option as Record;
const optionObject = optionRecord.option;
const optionTitle =
optionObject && typeof optionObject === 'object'
? (optionObject as Record).title
: undefined;
const optionValue = optionRecord.value;
if (typeof optionTitle === 'string' && typeof optionValue === 'string') {
acc[optionTitle] = optionValue;
}
return acc;
}, {}) ?? {}
);
};
export default async function sendOrderConfirmationHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const query = container.resolve(ContainerRegistrationKeys.QUERY);
const notificationModuleService = container.resolve(Modules.NOTIFICATION);
const {
data: [order],
} = await query.graph({
entity: 'order',
fields: [
'id',
'currency_code',
'total',
'subtotal',
'tax_total',
'discount_total',
'discount_tax_total',
'original_total',
'original_tax_total',
'item_total',
'item_subtotal',
'item_tax_total',
'original_item_total',
'original_item_subtotal',
'original_item_tax_total',
'shipping_total',
'shipping_subtotal',
'shipping_tax_total',
'original_shipping_tax_total',
'original_shipping_subtotal',
'original_shipping_total',
'email',
'shipping_address.*',
'billing_address.*',
'customer_id',
'items.*',
'items.variant.options.value',
'items.variant.options.option.title',
'summary.*',
],
filters: { id: data.id },
});
if (!order || !order.email) {
return;
}
const countryCodes = [
order.shipping_address?.country_code,
order.billing_address?.country_code,
].filter(Boolean);
const countryMap: Map = new Map();
if (countryCodes.length > 0) {
const { data: countries } = await query.graph({
entity: 'country',
fields: ['iso_2', 'name', 'display_name'],
filters: {
iso_2: countryCodes,
},
});
countries.forEach((country) => {
countryMap.set(country.iso_2, {
iso_2: country.iso_2,
name: country.name,
display_name: country.display_name,
});
});
}
const getFallbackCountry = (countryCode: string): Country => ({
iso_2: countryCode,
name: countryCode.toUpperCase(),
display_name: countryCode.toUpperCase(),
});
const shippingAddressForEmail = order.shipping_address
? {
...order.shipping_address,
country: order.shipping_address.country_code
? (countryMap.get(order.shipping_address.country_code) ??
getFallbackCountry(order.shipping_address.country_code))
: undefined,
}
: order.shipping_address;
const billingAddressForEmail = order.billing_address
? {
...order.billing_address,
country: order.billing_address.country_code
? (countryMap.get(order.billing_address.country_code) ??
getFallbackCountry(order.billing_address.country_code))
: undefined,
}
: order.billing_address;
const transformedItems = order.items.map((item) => ({
id: item.id,
quantity: Math.trunc(toNumber(item.quantity)),
total: toNumber(item.total),
thumbnail:
item.thumbnail ??
item.product.thumbnail ??
item.product.images?.[0]?.url ??
null,
product_title: item.product_title ?? '',
variant_title: item.variant_title ?? '',
variant_option_values: buildVariantOptionValues(item),
}));
const orderForEmail = {
...order,
subtotal: toNumber(order.subtotal),
shipping_total: toNumber(order.shipping_total),
total: toNumber(order.total),
tax_total: toNumber(order.tax_total),
shipping_address: shippingAddressForEmail,
billing_address: billingAddressForEmail,
items: transformedItems,
};
await notificationModuleService.createNotifications({
to: order.email,
channel: 'email',
template: 'order-placed',
data: { order: orderForEmail } satisfies OrderPlacedEmailProps,
});
}
export const config: SubscriberConfig = {
event: 'order.placed',
};
================================================
FILE: medusa/src/workflows/README.md
================================================
# Custom Workflows
A workflow is a series of queries and actions that complete a task.
The workflow is created in a TypeScript or JavaScript file under the `src/workflows` directory.
For example:
```ts
import {
createStep,
createWorkflow,
StepResponse,
} from "@medusajs/framework/workflows-sdk"
const step1 = createStep("step-1", async () => {
return new StepResponse(`Hello from step one!`)
})
type WorkflowInput = {
name: string
}
const step2 = createStep(
"step-2",
async ({ name }: WorkflowInput) => {
return new StepResponse(`Hello ${name} from step two!`)
}
)
type WorkflowOutput = {
message: string
}
const myWorkflow = createWorkflow<
WorkflowInput,
WorkflowOutput
>("hello-world", function (input) {
const str1 = step1()
// to pass input
step2(input)
return {
message: str1,
}
})
export default myWorkflow
```
## Execute Workflow
You can execute the workflow from other resources, such as API routes, scheduled jobs, or subscribers.
For example, to execute the workflow in an API route:
```ts
import type {
MedusaRequest,
MedusaResponse,
} from "@medusajs/medusa"
import myWorkflow from "../../../workflows/hello-world"
export async function GET(
req: MedusaRequest,
res: MedusaResponse
) {
const { result } = await myWorkflow(req.scope)
.run({
input: {
name: req.query.name as string,
},
})
res.send(result)
}
```
================================================
FILE: medusa/src/workflows/emit-customer-welcome-event.ts
================================================
import {
createWorkflow,
WorkflowResponse,
} from '@medusajs/framework/workflows-sdk';
import { emitEventStep } from '@medusajs/medusa/core-flows';
const emitCustomerWelcomeEvent = createWorkflow(
'emit-customer-welcome-event',
function (input: { id: string }) {
emitEventStep({
eventName: 'customer.welcome',
data: {
id: input.id,
},
});
return new WorkflowResponse({ id: input.id });
},
);
export default emitCustomerWelcomeEvent;
================================================
FILE: medusa/src/workflows/index-products.ts
================================================
import {
createStep,
createWorkflow,
StepResponse,
WorkflowResponse,
} from '@medusajs/framework/workflows-sdk';
import { Modules } from '@medusajs/framework/utils';
import { ISearchService, ProductDTO } from '@medusajs/framework/types';
const retrieveProductsStep = createStep(
{
name: 'retrieveProductsStep',
},
async (input: undefined, context) => {
const productModuleService = context.container.resolve(Modules.PRODUCT);
const products = await productModuleService.listProducts(undefined, {
relations: [
'variants',
'options',
'tags',
'collection',
'type',
'images',
],
});
return new StepResponse(products);
},
);
const indexProductsStep = createStep(
{
name: 'indexProductsStep',
},
async (input: ProductDTO[], context) => {
const meilisearchService = context.container.resolve(
'meilisearchService',
) as ISearchService;
const result = await meilisearchService.addDocuments(
'products',
input,
'products',
);
return new StepResponse(result);
},
);
export const indexProductsWorkflow = createWorkflow(
{
name: 'indexProducts',
idempotent: true,
retentionTime: 60 * 60 * 24 * 3, // 3 days
store: true,
},
() => {
const products = retrieveProductsStep();
const result = indexProductsStep(products);
return new WorkflowResponse(result);
},
);
================================================
FILE: medusa/tsconfig.json
================================================
{
"compilerOptions": {
"lib": ["es2021"],
"target": "es2021",
"allowJs": true,
"esModuleInterop": true,
"module": "Node16",
"moduleResolution": "Node16",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"declaration": false,
"sourceMap": false,
"outDir": "./.medusa/server",
"rootDir": "./",
"baseUrl": ".",
"jsx": "react-jsx",
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"checkJs": false
},
"ts-node": {
"swc": true
},
"include": ["**/*", ".medusa/types/*"],
"exclude": [
"**/__tests__",
"**/__fixtures__",
"node_modules",
".medusa/server",
".medusa/admin",
".cache"
]
}
================================================
FILE: storefront/.github/scripts/medusa-config.js
================================================
const { defineConfig, loadEnv } = require("@medusajs/utils")
loadEnv(process.env.NODE_ENV || "development", process.cwd())
// CORS when consuming Medusa from admin
// Medusa's docs are added for a better learning experience. Feel free to remove.
const ADMIN_CORS = `${
process.env.ADMIN_CORS?.length
? `${process.env.ADMIN_CORS},`
: "http://localhost:7000,http://localhost:7001,"
}https://docs.medusajs.com,https://medusa-docs-v2-git-docs-v2-medusajs.vercel.app,https://medusa-resources-git-docs-v2-medusajs.vercel.app`
// CORS to avoid issues when consuming Medusa from a client
// Medusa's docs are added for a better learning experience. Feel free to remove.
const STORE_CORS = `${
process.env.STORE_CORS?.length
? `${process.env.STORE_CORS},`
: "http://localhost:8000,"
}https://docs.medusajs.com,https://medusa-docs-v2-git-docs-v2-medusajs.vercel.app,https://medusa-resources-git-docs-v2-medusajs.vercel.app`
const DATABASE_URL =
process.env.DATABASE_URL || "postgres://medusa:password@localhost/medusa"
const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379"
export default defineConfig({
plugins: [
`medusa-fulfillment-manual`,
`medusa-payment-manual`,
{
resolve: `@medusajs/file-local`,
options: {
upload_dir: "uploads",
},
},
{
resolve: `medusa-plugin-meilisearch`,
options: {
config: {
host: process.env.MEILISEARCH_HOST,
apiKey: process.env.MEILISEARCH_API_KEY,
},
settings: {
products: {
indexSettings: {
searchableAttributes: ["title", "description", "variant_sku"],
displayedAttributes: [
"id",
"title",
"description",
"variant_sku",
"thumbnail",
"handle",
],
},
primaryKey: "id",
},
},
},
},
],
admin: {
backendUrl: "http://localhost:9000",
},
projectConfig: {
databaseUrl: DATABASE_URL,
http: {
storeCors: STORE_CORS,
adminCors: ADMIN_CORS,
authCors: process.env.AUTH_CORS || ADMIN_CORS,
jwtSecret: process.env.JWT_SECRET || "supersecret",
cookieSecret: process.env.COOKIE_SECRET || "supersecret",
},
redisUrl: REDIS_URL,
},
})
================================================
FILE: storefront/.github/workflows/test-e2e.yaml
================================================
name: Medusa NextJS Template Tests
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
env:
PGHOST: localhost
PGPORT: 5432
PGUSER: postgres
PGPASSWORD: password
PGDATABASE: postgres
TEST_POSTGRES_USER: test_medusa_user
TEST_POSTGRES_PASSWORD: password
TEST_POSTGRES_DATABASE: test_medusa_db
TEST_POSTGRES_DATABASE_TEMPLATE: test_medusa_db_template
TEST_POSTGRES_HOST: localhost
TEST_POSTGREST_PORT: 5432
PRODUCTION_POSTGRES_DATABASE: medusa_db
CLIENT_SERVER: http://localhost:9000
JWT_SECRET: something
COOKIE_SECRET: something
DATABASE_TYPE: "postgres"
REDIS_URL: redis://localhost:6379
DATABASE_URL: postgres://test_medusa_user:password@localhost/test_medusa_db
MEILISEARCH_HOST: http://localhost:7700
MEILISEARCH_API_KEY: meili_api_key
NEXT_PUBLIC_BASE_URL: http://localhost:8000
NEXT_PUBLIC_DEFAULT_REGION: us
NEXT_PUBLIC_MEDUSA_BACKEND_URL: http://localhost:9000
NEXT_PUBLIC_INDEX_NAME: products
NEXT_PUBLIC_SEARCH_ENDPOINT: http://127.0.0.1:7700
NEXT_PUBLIC_SEARCH_API_KEY: meili_api_key
REVALIDATE_SECRET: supersecret
jobs:
e2e-test-runner:
timeout-minutes: 20
runs-on:
- ubuntu-latest
services:
postgres:
image: postgres:latest
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
meilisearch:
image: getmeili/meilisearch:v1.7
env:
MEILI_MASTER_KEY: meili_api_key
MEILI_ENV: development
ports:
- 7700:7700
options: >-
--health-cmd "curl --fail http://localhost:7700/health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:latest
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "20"
- name: Initialize PostgreSQL
run: |
echo "Initializing Databases"
psql -h localhost -U postgres -d test -c "CREATE USER ${{ env.TEST_POSTGRES_USER }} WITH PASSWORD '${{ env.TEST_POSTGRES_PASSWORD }}';"
psql -h localhost -U postgres -d test -c "CREATE DATABASE ${{ env.TEST_POSTGRES_DATABASE }} OWNER ${{ env.TEST_POSTGRES_USER }};"
- name: Install Medusa CLI
run: npm install @medusajs/medusa-cli@preview -g
- name: Setup medusa backend server
working-directory: ../
# https://docs.medusajs.com/cli/reference#options
run: |
medusa new backend \
-y \
--v2 \
--branch feat/v2 \
--skip-db \
--skip-migrations \
--skip-env \
--db-user ${{ env.TEST_POSTGRES_USER }} \
--db-pass ${{ env.TEST_POSTGRES_PASSWORD }} \
--db-database ${{ env.TEST_POSTGRES_DATABASE }} \
--db-host ${{ env.TEST_POSTGRES_HOST }} \
--db-port ${{ env.TEST_POSTGREST_PORT }}
- name: Setup search in the backend
working-directory: ../backend
run: yarn add medusa-plugin-meilisearch
- name: Move custom medusa config to the backend
run: cp .github/scripts/medusa-config.js ../backend/medusa-config.js
- name: Seed data from default seed file
working-directory: ../backend
run: medusa seed --seed-file=data/seed.json
- name: Run backend server
working-directory: ../backend
run: medusa develop
- name: Install packages
run: yarn install -y
- name: Install playwright
run: yarn playwright install --with-deps
- name: Setup frontend
run: yarn build
- name: Run Tests
run: yarn test-e2e
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: test-results
retention-days: 30
================================================
FILE: storefront/.gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# IDEs
.idea
.vscode
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# typescript
*.tsbuildinfo
node_modules
.swc
dump.rdb
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth
================================================
FILE: storefront/.prettierrc
================================================
{
"arrowParens": "always",
"semi": false,
"endOfLine": "auto",
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5"
}
================================================
FILE: storefront/.yarnrc.yml
================================================
nodeLinker: node-modules
================================================
FILE: storefront/LICENSE
================================================
MIT License
Copyright (c) 2022 Medusa
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: storefront/README.md
================================================
Medusa Next.js Starter Template
Combine Medusa's modules for your commerce backend with the newest Next.js 14 features for a performant storefront.
### 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/create-medusa-app) 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
- Search with Algolia / MeiliSearch
- Product Collections
- Cart
- Checkout with PayPal and Stripe
- User Accounts
- Order Details
- Full Next.js 14 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/)
- [Paypal](https://www.paypal.com/)
To enable the integrations you need to add the following to your `.env.local` file:
```shell
NEXT_PUBLIC_STRIPE_KEY=
NEXT_PUBLIC_PAYPAL_CLIENT_ID=
```
You will 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/add-plugins/stripe) and [PayPal](https://docs.medusajs.com/add-plugins/paypal) in your Medusa project.
# Search integration
This starter is configured to support using the `medusa-search-meilisearch` plugin out of the box. To enable search you will need to enable the feature flag in `./store.config.json`, which you do by changing the config to this:
```javascript
{
"features": {
// other features...
"search": true
}
}
```
Before you can search you will need to install the plugin in your Medusa server, for a written guide on how to do this – [see our documentation](https://docs.medusajs.com/add-plugins/meilisearch).
The search components in this starter are developed with Algolia's `react-instant-search-hooks-web` library which should make it possible for you to seemlesly change your search provider to Algolia instead of MeiliSearch.
To do this you will need to add `algoliasearch` to the project, by running
```shell
yarn add algoliasearch
```
After this you will need to switch the current MeiliSearch `SearchClient` out with a Alogolia client. To do this update `@lib/search-client`.
```ts
import algoliasearch from "algoliasearch/lite"
const appId = process.env.NEXT_PUBLIC_SEARCH_APP_ID || "test_app_id" // You should add this to your environment variables
const apiKey = process.env.NEXT_PUBLIC_SEARCH_API_KEY || "test_key"
export const searchClient = algoliasearch(appId, apiKey)
export const SEARCH_INDEX_NAME =
process.env.NEXT_PUBLIC_INDEX_NAME || "products"
```
Then, in `src/app/(main)/search/actions.ts`, remove the MeiliSearch code (line 10-16) and uncomment the Algolia code.
```ts
"use server"
import { searchClient, SEARCH_INDEX_NAME } from "@lib/search-client"
/**
* Uses MeiliSearch or Algolia to search for a query
* @param {string} query - search query
*/
export async function search(query: string) {
const index = searchClient.initIndex(SEARCH_INDEX_NAME)
const { hits } = await index.search(query)
return hits
}
```
After this you will need to set up Algolia with your Medusa server, and then you should be good to go. For a more thorough walkthrough of using Algolia with Medusa – [see our documentation](https://docs.medusajs.com/add-plugins/algolia), and the [documentation for using `react-instantsearch-hooks-web`](https://www.algolia.com/doc/guides/building-search-ui/getting-started/react-hooks/).
## App structure
For the new version, the main folder structure remains unchanged. The contents have changed quite a bit though.
```
.
└── src
├── app
├── lib
├── modules
├── styles
├── types
└── middleware.ts
```
### `/app` directory
The app folder contains all Next.js App Router pages and layouts, and takes care of the routing.
```
.
└── [countryCode]
├── (checkout)
└── checkout
└── (main)
├── account
│ ├── addresses
│ └── orders
│ └── details
│ └── [id]
├── cart
├── categories
│ └── [...category]
├── collections
│ └── [handle]
├── order
│ └── confirmed
│ └── [id]
├── products
│ └── [handle]
├── results
│ └── [query]
├── search
└── store
```
The app router folder structure represents the routes of the Starter. In this case, the structure is as follows:
- The root directory is represented by the `[countryCode]` folder. This indicates a dynamic route based on the country code. The this will be populated by the countries you set up in your Medusa server. The param is then used to fetch region specific prices, languages, etc.
- Within the root directory, there two Route Groups: `(checkout)` and `(main)`. This is done because the checkout flow uses a different layout. All other parts of the app share the same layout and are in subdirectories of the `(main)` group. Route Groups do not affect the url.
- Each of these subdirectories may have further subdirectories. For instance, the `account` directory has `addresses` and `orders` subdirectories. The `orders` directory further has a `details` subdirectory, which itself has a dynamic `[id]` subdirectory.
- This nested structure allows for specific routing to various pages within the application. For example, a URL like `/account/orders/details/123` would correspond to the `account > orders > details > [id]` path in the router structure, with `123` being the dynamic `[id]`.
This structure enables efficient routing and organization of different parts of the Starter.
### `/lib` **directory**
The lib directory contains all utilities like the Medusa JS client functions, util functions, config and constants.
The most important file here is `/lib/data/index.ts`. This file defines various functions for interacting with the Medusa API, using the JS client. The functions cover a range of actions related to shopping carts, orders, shipping, authentication, customer management, regions, products, collections, and categories. It also includes utility functions for handling headers and errors, as well as some functions for sorting and transforming product data.
These functions are used in different Server Actions.
### `/modules` directory
This is where all the components, templates and Server Actions are, grouped by section. Some subdirectories have an `actions.ts` file. These files contain all Server Actions relevant to that section of the app.
### `/styles` directory
`global.css` imports Tailwind classes and defines a couple of global CSS classes. Tailwind and Medusa UI classes are used for styling throughout the app.
### `/types` directory
Contains global TypeScript type defintions.
### `middleware.ts`
Next.js Middleware, which is basically an Edge function that runs before (almost) every request. In our case it enforces a `countryCode` in the url. So when a user visits any url on your storefront without a `countryCode` param, it will redirect the user to the url for the most relevant region.
The region will be decided as follows:
- When deployed on Vercel and you’re active in the user’s current country, it will use the country code from the `x-vercel-ip-country` header.
- Else, if you have defined a `NEXT_PUBLIC_DEFAULT_REGION` environment variable, it will redirect to that.
- Else, it will redirect the user to the first region it finds on your Medusa server.
If you want to use the `countryCode` param in your code, there’s two ways to do that:
1. On the server in any `page.tsx` - the `countryCode` is in the `params` object:
```tsx
export default async function Page({
params: { countryCode },
}: {
params: { countryCode: string }
}) {
const region = await getRegion(countryCode)
// rest of code
```
2. From client components, with the `useParam` hook:
```tsx
import { useParams } from "next/navigation"
const Component = () => {
const { countryCode } = useParams()
// rest of code
```
The middleware also sets a cookie based on the onboarding status of a user. This is related to the Medusa Admin onboarding flow, and may be safely removed in your production storefront.
# 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)
================================================
FILE: storefront/check-env-variables.js
================================================
const c = require("ansi-colors")
const requiredEnvs = [
{
key: "NEXT_PUBLIC_MEDUSA_BACKEND_URL",
description:
"Your Medusa backend, should be updated to where you are hosting your server. Remember to update CORS settings for your server. See - https://docs.medusajs.com/usage/configurations#admin_cors-and-store_cors.",
},
{
key: "NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY",
description:
"Your publishable key that can be attached to sales channels. See - https://docs.medusajs.com/development/publishable-api-keys.",
},
{
key: "NEXT_PUBLIC_BASE_URL",
description:
"Your store URL, should be updated to where you are hosting your storefront.",
},
{
key: "NEXT_PUBLIC_DEFAULT_REGION",
description:
'Your preferred default region. When middleware cannot determine the user region from the "x-vercel-country" header, the default region will be used. ISO-2 lowercase format.',
},
{
key: "NEXT_PUBLIC_STRIPE_KEY",
description:
"Your Stripe public key. See - https://docs.medusajs.com/add-plugins/stripe.",
},
]
function checkEnvVariables() {
const missingEnvs = requiredEnvs.filter((env) => {
return !process.env[env.key]
})
if (missingEnvs.length > 0) {
console.error(
c.red.bold("\n🚫 Error: Missing required environment variables\n")
)
missingEnvs.forEach((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
================================================
FILE: storefront/e2e/README.md
================================================
# About
This folder contains an end to end testing suite written with playwright checking all of the main functionality provided by this template. Note it assumes you are using a postgres database on the backend and have configured a test database. This is required because the tests will **drop and recreate the test database** in order to ensure replicability between test runs.
This test suite was built off of using the [medusa-starter-default](https://github.com/medusajs/medusa-starter-default) repository with the seed data from `data/seed.json`.
# Setup
## .env
These tests have a number of dependent environment variables, with an example found in `.env.example`. You can setup your local environment by copying the example environment file
```sh
cat e2e/.env.example >> .env
```
and configuring the `.env` file from there. There are more details below about what the test values correspond to and how to set them. But we mention that
* `CLIENT_SERVER` - is the server the next server is listening on
## Playwright
In order to run these tests, make sure playwright and a playwright-enabled browser is installed. You can do this by running
```sh
npx playwright install
```
## Database
Note that **these tests drop and reset the database** after each test run. This means you will need to configure a separate test database based on your development or production database. We give some instructions for doing so, and enforce a rule which requires the test database to have the prefix `test_` in its name.
### Environment variables
- `TEST_POSTGRES_USER` - user for connecting to the test database, for example, `medusa`
- `TEST_POSTGRES_PASSWORD` - password for connecting to the test database, for example `my_secret_password`
- `TEST_POSTGRES_DATABASE` - name of the test database, must start with the prefix `test*`, for example `test_medusa_db`
- `TEST_POSTGRES_HOST` - optional - host for the postgres database, defaults to `localhost`
- `TEST_POSTGREST_PORT` - optional - host for the postgres
- `PRODUCTION_POSTGRES_DATABASE` - name of the production database, for example `medusa_db`
in addition, there are environment variables for connecting to the database as a superuser, so we can efficiently reset the database.
* `PGHOST` - host for the postgres instance
* `PGPORT` - port for the postgres instance
* `PGUSER` - superuser for the postgres instance
* `PGPASSWORD` - superuser password for the postgres instance
* `PGDATABASE` - database we connect to while updating the other databases
### Test Database Failsafes
There are a few failsafes to ensure the test and production databases don't get mixed up. This includes:
- Ensuring the production database doesn't have the same name as the test database
- Ensuring the test database starts with the prefix `test_`
Note running the test suite will trigger database drops and recreations of the test database.
### Using a separate database
If you need to run your project with a separate database, such as sqlite, MySQL, or something else, please refer to `seed/reset.ts` and implement your own `resetDatabase` function which can be run between test runs.
# Running the test suite
## Test environment
Before running the test suite, make sure to start the backend server the medusa client is using. In addition, make sure to run in the nextjs template directory
```sh
yarn build
```
so the project is built.
## Calling the tests
You can run the test suite in the base directory of the project with either
```sh
yarn test-e2e
```
or
```sh
npm run test-e2e
```
While the test suite is running, it is configured to automatically run the nextjs template during test execution.
================================================
FILE: storefront/e2e/data/reset.ts
================================================
import { Client } from "pg"
async function getDatabaseClient() {
testEnvChecks()
const env = getEnv()
const client = new Client(env.superuser)
await client.connect()
return client
}
function getEnv() {
return {
host: process.env.TEST_POSTGRES_HOST || "localhost",
port: process.env.TEST_POSTGRES_HOST
? Number(process.env.TEST_POSTGRES_HOST)
: 5432,
user: process.env.TEST_POSTGRES_USER || "test_medusa_user",
testDatabase: process.env.TEST_POSTGRES_DATABASE || "test_medusa_db",
testDatabaseTemplate:
process.env.TEST_POSTGRES_DATABASE_TEMPLATE || "test_medusa_db_template",
productionDatabase: process.env.PRODUCTION_POSTGRES_DATABASE || "medusa_db",
superuser: {
host: process.env.PGHOST || "localhost",
port: process.env.PGPORT ? Number(process.env.PGPORT) : 5432,
user: process.env.PGUSER || "postgres",
password: process.env.PGPASSWORD || "password",
database: process.env.PGDATABASE || "postgres",
},
}
}
async function testEnvChecks() {
const env = getEnv()
if (!env.testDatabase.startsWith("test_")) {
const msg =
"Please make sure your test environment database name starts with test_"
console.error(msg)
throw new Error(msg)
}
if (env.testDatabase === env.productionDatabase) {
const msg =
"Please make sure your test environment database and production environment database names are not equal"
console.error(msg)
throw new Error(msg)
}
}
async function createTemplateDatabase(client: Client) {
const { user, testDatabase, testDatabaseTemplate } = getEnv()
try {
// close current connections
await client.query(`
ALTER DATABASE ${testDatabase} WITH ALLOW_CONNECTIONS false;
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname='${testDatabase}';
`)
await client.query(`
CREATE DATABASE ${testDatabaseTemplate} WITH
OWNER=${user}
TEMPLATE=${testDatabase}
IS_TEMPLATE=true;
`)
} catch (e: any) {
// duplicate database code
if (e.code === "42P04") {
return
}
throw e
}
}
async function createTestDatabase(client: Client) {
const { user, testDatabase, testDatabaseTemplate } = getEnv()
const deleteDatabase = `${testDatabase}_del`
// drop connections and alter database name
await client.query(`
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname='${testDatabase}';
ALTER DATABASE ${testDatabase}
RENAME TO ${deleteDatabase};
`)
await client.query(`
CREATE DATABASE ${testDatabase}
WITH OWNER ${user}
TEMPLATE=${testDatabaseTemplate};
`)
await client.query(`DROP DATABASE ${deleteDatabase}`)
}
export async function resetDatabase() {
const client = await getDatabaseClient()
await createTemplateDatabase(client)
await createTestDatabase(client)
await client.end()
}
export async function dropTemplate() {
const client = await getDatabaseClient()
const env = getEnv()
await client.query(
`ALTER DATABASE ${env.testDatabaseTemplate} is_template false`
)
await client.query(`DROP DATABASE ${env.testDatabaseTemplate}`)
await client.end()
}
================================================
FILE: storefront/e2e/data/seed.ts
================================================
import axios, { AxiosError, AxiosInstance } from "axios"
axios.defaults.baseURL = process.env.CLIENT_SERVER || "http://localhost:9000"
let region = undefined as any
export async function seedData() {
const axios = getOrInitAxios()
return {
user: await seedUser(),
}
}
export async function seedUser(email?: string, password?: string) {
const user = {
first_name: "Test",
last_name: "User",
email: email || "test@example.com",
password: password || "password",
}
try {
await axios.post("/store/customers", user)
return user
} catch (e: unknown) {
if (e instanceof AxiosError) {
if (e.response && e.response.status) {
const status = e.response.status
// https://docs.medusajs.com/api/store#customers_postcustomers
if (status === 422) {
return user
}
}
throw e
}
}
}
async function loadRegion(axios: AxiosInstance) {
const resp = await axios.get("/admin/regions")
region = resp.data.regions.filter((r: any) => r.currency_code === "usd")[0]
}
async function getOrInitAxios(axios?: AxiosInstance) {
if (!axios) {
axios = await loginAdmin()
}
if (!region) {
await loadRegion(axios)
}
return axios
}
export async function seedGiftcard(axios?: AxiosInstance) {
axios = await getOrInitAxios(axios)
const resp = await axios.post("/admin/gift-cards", {
region_id: region.id,
value: 10000,
})
resp.data.gift_card.amount = resp.data.gift_card.value.toString()
return resp.data.gift_card as {
id: string
code: string
value: number
amount: string
balance: string
}
}
export async function seedDiscount(axios?: AxiosInstance) {
axios = await getOrInitAxios(axios)
const amount = 2000
const resp = await axios.post("/admin/discounts", {
code: "TEST_DISCOUNT_FIXED",
regions: [region.id],
rule: {
type: "fixed",
value: amount,
allocation: "total",
},
})
const discount = resp.data.discount
return {
id: discount.id,
code: discount.code,
rule_id: discount.rule_id,
amount,
}
}
async function loginAdmin() {
const resp = await axios.post("/admin/auth/token", {
email: process.env.MEDUSA_ADMIN_EMAIL || "admin@medusa-test.com",
password: process.env.MEDUSA_ADMIN_PASSWORD || "supersecret",
})
if (resp.status !== 200) {
throw { error: "must be able to log in user" }
}
return axios.create({
headers: {
Authorization: `Bearer ${resp.data.access_token}`,
},
})
}
================================================
FILE: storefront/e2e/fixtures/account/account-page.ts
================================================
import { Locator, Page } from "@playwright/test"
import { BasePage } from "../base/base-page"
export class AccountPage extends BasePage {
container: Locator
accountNav: Locator
overviewLink: Locator
profileLink: Locator
addressesLink: Locator
ordersLink: Locator
logoutLink: Locator
mobileAccountNav: Locator
mobileAccountMainLink : Locator
mobileOverviewLink : Locator
mobileProfileLink : Locator
mobileAddressesLink : Locator
mobileOrdersLink : Locator
mobileLogoutLink : Locator
constructor(page: Page) {
super(page)
this.container = page.getByTestId("account-page")
this.accountNav = this.container.getByTestId("account-nav")
this.overviewLink = this.accountNav.getByTestId("overview-link")
this.profileLink = this.accountNav.getByTestId("profile-link")
this.addressesLink = this.accountNav.getByTestId("addresses-link")
this.ordersLink = this.accountNav.getByTestId("orders-link")
this.logoutLink = this.accountNav.getByTestId("logout-button")
this.mobileAccountNav = this.container.getByTestId("mobile-account-nav")
this.mobileAccountMainLink = this.mobileAccountNav.getByTestId("account-main-link")
this.mobileOverviewLink = this.mobileAccountNav.getByTestId("overview-link")
this.mobileProfileLink = this.mobileAccountNav.getByTestId("profile-link")
this.mobileAddressesLink = this.mobileAccountNav.getByTestId("addresses-link")
this.mobileOrdersLink = this.mobileAccountNav.getByTestId("orders-link")
this.mobileLogoutLink = this.mobileAccountNav.getByTestId("logout-button")
}
async goto() {
await this.navMenu.navAccountLink.click()
await this.container.waitFor({ state: "visible" })
}
}
================================================
FILE: storefront/e2e/fixtures/account/addresses-page.ts
================================================
import { Locator, Page } from "@playwright/test"
import { AccountPage } from "./account-page"
import { AddressModal } from "./modals/address-modal"
export class AddressesPage extends AccountPage {
addAddressModal: AddressModal
editAddressModal: AddressModal
addressContainer: Locator
addressesWrapper: Locator
newAddressButton: Locator
constructor(page: Page) {
super(page)
this.addAddressModal = new AddressModal(page, "add")
this.editAddressModal = new AddressModal(page, "edit")
this.addressContainer = this.container.getByTestId("address-container")
this.addressesWrapper = page.getByTestId("addresses-page-wrapper")
this.newAddressButton = this.container.getByTestId("add-address-button")
}
getAddressContainer(text: string) {
const container = this.page
.getByTestId("address-container")
.filter({ hasText: text })
return {
container,
editButton: container.getByTestId('address-edit-button'),
deleteButton: container.getByTestId("address-delete-button"),
name: container.getByTestId("address-name"),
company: container.getByTestId("address-company"),
address: container.getByTestId("address-address"),
postalCity: container.getByTestId("address-postal-city"),
provinceCountry: container.getByTestId("address-province-country"),
}
}
async goto() {
await super.goto()
await this.addressesLink.click()
await this.addressesWrapper.waitFor({ state: "visible" })
}
}
================================================
FILE: storefront/e2e/fixtures/account/index.ts
================================================
import { test as base } from "@playwright/test"
import { AddressesPage } from "./addresses-page"
import { LoginPage } from "./login-page"
import { OrderPage } from "./order-page"
import { OrdersPage } from "./orders-page"
import { OverviewPage } from "./overview-page"
import { ProfilePage } from "./profile-page"
import { RegisterPage } from "./register-page"
export const accountFixtures = base.extend<{
accountAddressesPage: AddressesPage
accountOrderPage: OrderPage
accountOrdersPage: OrdersPage
accountOverviewPage: OverviewPage
accountProfilePage: ProfilePage
loginPage: LoginPage
registerPage: RegisterPage
}>({
accountAddressesPage: async ({ page }, use) => {
const addressesPage = new AddressesPage(page)
await use(addressesPage)
},
accountOrderPage: async ({ page }, use) => {
const orderPage = new OrderPage(page)
await use(orderPage)
},
accountOrdersPage: async ({ page }, use) => {
const ordersPage = new OrdersPage(page)
await use(ordersPage)
},
accountOverviewPage: async ({ page }, use) => {
const overviewPage = new OverviewPage(page)
await use(overviewPage)
},
accountProfilePage: async ({ page }, use) => {
const profilePage = new ProfilePage(page)
await use(profilePage)
},
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page)
await use(loginPage)
},
registerPage: async ({ page }, use) => {
const registerPage = new RegisterPage(page)
await use(registerPage)
},
})
================================================
FILE: storefront/e2e/fixtures/account/login-page.ts
================================================
import { Locator, Page } from "@playwright/test"
import { BasePage } from "../base/base-page"
export class LoginPage extends BasePage {
container: Locator
emailInput: Locator
passwordInput: Locator
signInButton: Locator
registerButton: Locator
errorMessage: Locator
constructor(page: Page) {
super(page)
this.container = page.getByTestId("login-page")
this.emailInput = this.container.getByTestId("email-input")
this.passwordInput = this.container.getByTestId("password-input")
this.signInButton = this.container.getByTestId("sign-in-button")
this.registerButton = this.container.getByTestId("register-button")
this.errorMessage = this.container.getByTestId("login-error-message")
}
async goto() {
await this.page.goto("/account")
await this.container.waitFor({ state: "visible" })
}
}
================================================
FILE: storefront/e2e/fixtures/account/modals/address-modal.ts
================================================
import { Page, Locator } from "@playwright/test"
import { BaseModal } from "../../base/base-modal"
export class AddressModal extends BaseModal {
saveButton: Locator
cancelButton: Locator
firstNameInput: Locator
lastNameInput: Locator
companyInput: Locator
address1Input: Locator
address2Input: Locator
postalCodeInput: Locator
cityInput: Locator
stateInput: Locator
countrySelect: Locator
phoneInput: Locator
constructor(page: Page, modalType: "add" | "edit") {
if (modalType === "add") {
super(page, page.getByTestId("add-address-modal"))
} else {
super(page, page.getByTestId("edit-address-modal"))
}
this.saveButton = this.container.getByTestId("save-button")
this.cancelButton = this.container.getByTestId("cancel-button")
this.firstNameInput = this.container.getByTestId("first-name-input")
this.lastNameInput = this.container.getByTestId("last-name-input")
this.companyInput = this.container.getByTestId("company-input")
this.address1Input = this.container.getByTestId("address-1-input")
this.address2Input = this.container.getByTestId("address-2-input")
this.postalCodeInput = this.container.getByTestId("postal-code-input")
this.cityInput = this.container.getByTestId("city-input")
this.stateInput = this.container.getByTestId("state-input")
this.countrySelect = this.container.getByTestId("country-select")
this.phoneInput = this.container.getByTestId("phone-input")
}
}
================================================
FILE: storefront/e2e/fixtures/account/order-page.ts
================================================
import { Locator, Page } from "@playwright/test"
import { AccountPage } from "./account-page"
export class OrderPage extends AccountPage {
container: Locator
backToOverviewButton: Locator
orderEmail: Locator
orderDate: Locator
orderId: Locator
orderStatus: Locator
orderPaymentStatus: Locator
shippingAddressSummary: Locator
shippingContactSummary: Locator
shippingMethodSummary: Locator
paymentMethod: Locator
paymentAmount: Locator
productsTable: Locator
productRow: Locator
productTitle: Locator
productVariant: Locator
productQuantity: Locator
productOriginalPrice: Locator
productPrice: Locator
productUnitOriginalPrice: Locator
productUnitPrice: Locator
constructor(page: Page) {
super(page)
this.container = page.getByTestId("order-details-container")
this.backToOverviewButton = page.getByTestId("back-to-overview-button")
this.orderEmail = this.container.getByTestId("order-email")
this.orderDate = this.container.getByTestId("order-date")
this.orderId = this.container.getByTestId("order-id")
this.orderStatus = this.container.getByTestId("order-status")
this.orderPaymentStatus = this.container.getByTestId("order-payment-status")
this.shippingAddressSummary = this.container.getByTestId(
"shipping-address-summary"
)
this.shippingContactSummary = this.container.getByTestId(
"shipping-contact-summary"
)
this.shippingMethodSummary = this.container.getByTestId(
"shipping-method-summary"
)
this.paymentMethod = this.container.getByTestId("payment-method")
this.paymentAmount = this.container.getByTestId("payment-amount")
this.productsTable = this.container.getByTestId("products-table")
this.productRow = this.container.getByTestId("product-row")
this.productTitle = this.container.getByTestId("product-title")
this.productVariant = this.container.getByTestId("product-variant")
this.productQuantity = this.container.getByTestId("product-quantity")
this.productOriginalPrice = this.container.getByTestId(
"product-original-price"
)
this.productPrice = this.container.getByTestId("product-price")
this.productUnitOriginalPrice = this.container.getByTestId(
"product-unit-original-price"
)
this.productUnitPrice = this.container.getByTestId("product-unit-price")
}
async getProduct(title: string, variant: string) {
const productRow = this.productRow
.filter({
hasText: title,
})
.filter({
hasText: `Variant: ${variant}`,
})
return {
productRow,
name: productRow.getByTestId("product-name"),
variant: productRow.getByTestId("product-variant"),
quantity: productRow.getByTestId("product-quantity"),
price: productRow.getByTestId("product-unit-price"),
total: productRow.getByTestId("product-price"),
}
}
}
================================================
FILE: storefront/e2e/fixtures/account/orders-page.ts
================================================
import { Locator, Page } from "@playwright/test"
import { AccountPage } from "./account-page"
export class OrdersPage extends AccountPage {
ordersWrapper: Locator
noOrdersContainer: Locator
continueShoppingButton: Locator
orderCard: Locator
orderDisplayId: Locator
constructor(page: Page) {
super(page)
this.ordersWrapper = page.getByTestId("orders-page-wrapper")
this.noOrdersContainer = page.getByTestId("no-orders-container")
this.continueShoppingButton = page.getByTestId("continue-shopping-button")
this.orderCard = page.getByTestId("order-card")
this.orderDisplayId = page.getByTestId("order-display-id")
this.orderCard = page.getByTestId("order-card")
this.orderDisplayId = page.getByTestId("order-display-id")
}
async getOrderById(orderId: string) {
const orderIdLocator = this.page
.getByTestId("order-display-id")
.filter({
hasText: orderId,
})
.first()
const card = this.orderCard.filter({ has: orderIdLocator }).first()
const items = (await card.getByTestId("order-item").all()).map(
(orderItem) => {
return {
item: orderItem,
title: orderItem.getByTestId("item-title"),
quantity: orderItem.getByTestId("item-quantity"),
}
}
)
return {
card,
displayId: card.getByTestId("order-display-id"),
createdAt: card.getByTestId("order-created-at"),
orderId: card.getByTestId("order-display-id"),
amount: card.getByTestId("order-amount"),
detailsLink: card.getByTestId("order-details-link"),
itemsLocator: card.getByTestId("order-item"),
items,
}
}
async goto() {
await super.goto()
await this.ordersLink.click()
await this.ordersWrapper.waitFor({ state: "visible" })
}
}
================================================
FILE: storefront/e2e/fixtures/account/overview-page.ts
================================================
import { Locator, Page } from "@playwright/test"
import { AccountPage } from "./account-page"
export class OverviewPage extends AccountPage {
welcomeMessage: Locator
customerEmail: Locator
profileCompletion: Locator
addressesCount: Locator
noOrdersMessage: Locator
ordersWrapper: Locator
orderWrapper: Locator
overviewWrapper: Locator
constructor(page: Page) {
super(page)
this.overviewWrapper = this.container.getByTestId("overview-page-wrapper")
this.welcomeMessage = this.container.getByTestId("welcome-message")
this.customerEmail = this.container.getByTestId("customer-email")
this.profileCompletion = this.container.getByTestId(
"customer-profile-completion"
)
this.addressesCount = this.container.getByTestId("addresses-count")
this.noOrdersMessage = this.container.getByTestId("no-orders-message")
this.ordersWrapper = this.container.getByTestId("orders-wrapper")
this.orderWrapper = this.container.getByTestId("order-wrapper")
}
async getOrder(orderId: string) {
const order = this.ordersWrapper.locator(
`[data-testid="order-wrapper"][data-value="${orderId}"]`
)
return {
locator: order,
id: await order.getAttribute("value"),
createdDate: await order.getByTestId("order-created-date"),
displayId: await order.getByTestId("order-id").getAttribute("value"),
amount: await order.getByTestId("order-amount").textContent(),
openButton: order.getByTestId("open-order-button"),
}
}
async goto() {
await this.navMenu.navAccountLink.click()
await this.container.waitFor({ state: "visible" })
}
}
================================================
FILE: storefront/e2e/fixtures/account/profile-page.ts
================================================
import { Locator, Page } from "@playwright/test"
import { AccountPage } from "./account-page"
import { camelCase } from "lodash"
export class ProfilePage extends AccountPage {
profileWrapper: Locator
accountNameEditor: Locator
accountEmailEditor: Locator
accountPhoneEditor: Locator
accountPasswordEditor: Locator
accountBillingAddressEditor: Locator
nameEditButton: Locator
emailEditButton: Locator
phoneEditButton: Locator
passwordEditButton: Locator
billingAddressEditButton: Locator
nameSaveButton: Locator
emailSaveButton: Locator
phoneSaveButton: Locator
passwordSaveButton: Locator
billingAddressSaveButton: Locator
savedName: Locator
savedEmail: Locator
savedPhone: Locator
savedPassword: Locator
savedBillingAddress: Locator
nameSuccessMessage: Locator
emailSuccessMessage: Locator
phoneSuccessMessage: Locator
passwordSuccessMessage: Locator
billingAddressSuccessMessage: Locator
nameErrorMessage: Locator
emailErrorMessage: Locator
phoneErrorMessage: Locator
passwordErrorMessage: Locator
billingAddressErrorMessage: Locator
emailInput: Locator
firstNameInput: Locator
lastNameInput: Locator
phoneInput: Locator
oldPasswordInput: Locator
newPasswordInput: Locator
confirmPasswordInput: Locator
billingAddress1Input: Locator
billingAddress2Input: Locator
billingCityInput: Locator
billingCompanyInput: Locator
billingFirstNameInput: Locator
billingLastNameInput: Locator
billingPostcalCodeInput: Locator
billingProvinceInput: Locator
billingCountryCodeSelect: Locator
constructor(page: Page) {
super(page)
this.profileWrapper = page.getByTestId("profile-page-wrapper")
this.accountNameEditor = this.container.getByTestId("account-name-editor")
this.accountEmailEditor = this.container.getByTestId("account-email-editor")
this.accountPhoneEditor = this.container.getByTestId("account-phone-editor")
this.accountPasswordEditor = this.container.getByTestId(
"account-password-editor"
)
this.accountBillingAddressEditor = this.container.getByTestId(
"account-billing-address-editor"
)
this.nameEditButton = this.accountNameEditor.getByTestId("edit-button")
this.emailEditButton = this.accountEmailEditor.getByTestId("edit-button")
this.phoneEditButton = this.accountPhoneEditor.getByTestId("edit-button")
this.passwordEditButton =
this.accountPasswordEditor.getByTestId("edit-button")
this.billingAddressEditButton =
this.accountBillingAddressEditor.getByTestId("edit-button")
this.nameSaveButton = this.accountNameEditor.getByTestId("save-button")
this.emailSaveButton = this.accountEmailEditor.getByTestId("save-button")
this.phoneSaveButton = this.accountPhoneEditor.getByTestId("save-button")
this.passwordSaveButton =
this.accountPasswordEditor.getByTestId("save-button")
this.billingAddressSaveButton =
this.accountBillingAddressEditor.getByTestId("save-button")
this.savedName = this.accountNameEditor.getByTestId("current-info")
this.savedEmail = this.accountEmailEditor.getByTestId("current-info")
this.savedPhone = this.accountPhoneEditor.getByTestId("current-info")
this.savedPassword = this.accountPasswordEditor.getByTestId("current-info")
this.savedBillingAddress =
this.accountBillingAddressEditor.getByTestId("current-info")
this.nameSuccessMessage =
this.accountNameEditor.getByTestId("success-message")
this.emailSuccessMessage =
this.accountEmailEditor.getByTestId("success-message")
this.phoneSuccessMessage =
this.accountPhoneEditor.getByTestId("success-message")
this.passwordSuccessMessage =
this.accountPasswordEditor.getByTestId("success-message")
this.billingAddressSuccessMessage =
this.accountBillingAddressEditor.getByTestId("success-message")
this.nameErrorMessage = this.accountNameEditor.getByTestId("error-message")
this.emailErrorMessage =
this.accountEmailEditor.getByTestId("error-message")
this.phoneErrorMessage =
this.accountPhoneEditor.getByTestId("error-message")
this.passwordErrorMessage =
this.accountPasswordEditor.getByTestId("error-message")
this.billingAddressErrorMessage =
this.accountBillingAddressEditor.getByTestId("error-message")
this.firstNameInput = page.getByTestId("first-name-input")
this.lastNameInput = page.getByTestId("last-name-input")
this.emailInput = page.getByTestId("email-input")
this.phoneInput = page.getByTestId("phone-input")
this.oldPasswordInput = page.getByTestId("old-password-input")
this.newPasswordInput = page.getByTestId("new-password-input")
this.confirmPasswordInput = page.getByTestId("confirm-password-input")
this.billingAddress1Input = page.getByTestId("billing-address-1-input")
this.billingAddress2Input = page.getByTestId("billing-address-2-input")
this.billingCityInput = page.getByTestId("billing-city-input")
this.billingCompanyInput = page.getByTestId("billing-company-input")
this.billingFirstNameInput = page.getByTestId("billing-first-name-input")
this.billingLastNameInput = page.getByTestId("billing-last-name-input")
this.billingPostcalCodeInput = page.getByTestId(
"billing-postcal-code-input"
)
this.billingProvinceInput = page.getByTestId("billing-province-input")
this.billingCountryCodeSelect = page.getByTestId(
"billing-country-code-select"
)
}
async getEditorInputs(editor: Locator) {
const editButton = editor.getByTestId("edit-button")
if ((await editButton.getAttribute("active")) !== "true") {
await editButton.click()
}
// get all the inputs
const inputs = editor.locator(
'[data-testid]:not([data-testid="edit-button"])'
)
const o = {
editButton,
} as { [k: string]: Locator }
for (const input of await inputs.all()) {
const testId = (await input.getAttribute("data-testid")) as string
const key = camelCase(testId)
o[key] = input
}
return o
}
async goto() {
super.goto()
await this.profileLink.click()
await this.profileWrapper.waitFor({ state: "visible" })
}
}
================================================
FILE: storefront/e2e/fixtures/account/register-page.ts
================================================
import { Locator, Page } from "@playwright/test"
import { BasePage } from "../base/base-page"
export class RegisterPage extends BasePage {
container: Locator
firstNameInput: Locator
lastNameInput: Locator
emailInput: Locator
phoneInput: Locator
passwordInput: Locator
registerButton: Locator
registerError: Locator
loginLink: Locator
constructor(page: Page) {
super(page)
this.container = page.getByTestId("register-page")
this.firstNameInput = this.container.getByTestId("first-name-input")
this.lastNameInput = this.container.getByTestId("last-name-input")
this.emailInput = this.container.getByTestId("email-input")
this.phoneInput = this.container.getByTestId("phone-input")
this.passwordInput = this.container.getByTestId("password-input")
this.registerButton = this.container.getByTestId("register-button")
this.registerError = this.container.getByTestId("register-error")
this.loginLink = this.container.getByTestId("login-link")
}
}
================================================
FILE: storefront/e2e/fixtures/base/base-modal.ts
================================================
import { Page, Locator } from "@playwright/test"
export class BaseModal {
page: Page
container: Locator
closeButton: Locator
constructor(page: Page, container: Locator) {
this.page = page
this.container = container
this.closeButton = this.container.getByTestId("close-modal-button")
}
async close() {
const button = this.container.getByTestId("close-modal-button")
await button.click()
}
async isOpen() {
return await this.container.isVisible()
}
}
================================================
FILE: storefront/e2e/fixtures/base/base-page.ts
================================================
import { CartDropdown } from "./cart-dropdown"
import { NavMenu } from "./nav-menu"
import { Page, Locator } from "@playwright/test"
import { SearchModal } from "./search-modal"
export class BasePage {
page: Page
navMenu: NavMenu
cartDropdown: CartDropdown
searchModal: SearchModal
accountLink: Locator
cartLink: Locator
searchLink: Locator
storeLink: Locator
categoriesList: Locator
constructor(page: Page) {
this.page = page
this.navMenu = new NavMenu(page)
this.cartDropdown = new CartDropdown(page)
this.searchModal = new SearchModal(page)
this.accountLink = page.getByTestId("nav-account-link")
this.cartLink = page.getByTestId("nav-cart-link")
this.storeLink = page.getByTestId("nav-store-link")
this.searchLink = page.getByTestId("nav-search-link")
this.categoriesList = page.getByTestId("footer-categories")
}
async clickCategoryLink(category: string) {
const link = this.categoriesList.getByTestId("category-link")
await link.click()
}
}
================================================
FILE: storefront/e2e/fixtures/base/cart-dropdown.ts
================================================
import { Locator, Page } from "@playwright/test"
export class CartDropdown {
page: Page
navCartLink: Locator
cartDropdown: Locator
cartSubtotal: Locator
goToCartButton: Locator
constructor(page: Page) {
this.page = page
this.navCartLink = page.getByTestId("nav-cart-link")
this.cartDropdown = page.getByTestId("nav-cart-dropdown")
this.cartSubtotal = this.cartDropdown.getByTestId("cart-subtotal")
this.goToCartButton = this.cartDropdown.getByTestId("go-to-cart-button")
}
async displayCart() {
await this.navCartLink.hover()
}
async close() {
if (await this.cartDropdown.isVisible()) {
const box = await this.cartDropdown.boundingBox()
if (!box) {
return
}
await this.page.mouse.move(box.x + box.width / 4, box.y + box.height / 4)
await this.page.mouse.move(5, 10)
}
}
async getCartItem(name: string, variant: string) {
const cartItem = this.cartDropdown
.getByTestId("cart-item")
.filter({
hasText: name,
})
.filter({
hasText: `Variant: ${variant}`,
})
return {
locator: cartItem,
productLink: cartItem.getByTestId("product-link"),
removeButton: cartItem.getByTestId("cart-item-remove-button"),
name,
quantity: cartItem.getByTestId("cart-item-quantity"),
variant: cartItem.getByTestId("cart-item-variant"),
}
}
}
================================================
FILE: storefront/e2e/fixtures/base/nav-menu.ts
================================================
import { Locator, Page } from "@playwright/test"
export class NavMenu {
page: Page
navMenuButton: Locator
navMenu: Locator
navAccountLink: Locator
homeLink: Locator
storeLink: Locator
searchLink: Locator
accountLink: Locator
cartLink: Locator
closeButton: Locator
shippingToLink: Locator
shippingToMenu: Locator
constructor(page: Page) {
this.page = page
this.navMenuButton = page.getByTestId("nav-menu-button")
this.navMenu = page.getByTestId("nav-menu-popup")
this.navAccountLink = page.getByTestId("nav-account-link")
this.homeLink = this.navMenu.getByTestId("home-link")
this.storeLink = this.navMenu.getByTestId("store-link")
this.searchLink = this.navMenu.getByTestId("search-link")
this.accountLink = this.navMenu.getByTestId("account-link")
this.cartLink = this.navMenu.getByTestId("nav-cart-link")
this.closeButton = this.navMenu.getByTestId("close-menu-button")
this.shippingToLink = this.navMenu.getByTestId("shipping-to-button")
this.shippingToMenu = this.navMenu.getByTestId("shipping-to-choices")
}
async selectShippingCountry(country: string) {
if (!(await this.navMenu.isVisible())) {
throw {
error:
`You cannot call ` +
`NavMenu.selectShippingCountry("${country}") without having the ` +
`navMenu visible first!`,
}
}
const countryLink = this.navMenu.getByTestId(
`select-${country.toLowerCase()}-choice`
)
await this.shippingToLink.hover()
await this.shippingToMenu.waitFor({
state: "visible",
})
await countryLink.click()
}
async open() {
await this.navMenuButton.click()
await this.navMenu.waitFor({ state: "visible" })
}
}
================================================
FILE: storefront/e2e/fixtures/base/search-modal.ts
================================================
import { Page, Locator } from "@playwright/test"
import { BaseModal } from "./base-modal"
import { NavMenu } from "./nav-menu"
export class SearchModal extends BaseModal {
searchInput: Locator
searchResults: Locator
noSearchResultsContainer: Locator
searchResult: Locator
searchResultTitle: Locator
constructor(page: Page) {
super(page, page.getByTestId("search-modal-container"))
this.searchInput = this.container.getByTestId("search-input")
this.searchResults = this.container.getByTestId("search-results")
this.noSearchResultsContainer = this.container.getByTestId(
"no-search-results-container"
)
this.searchResult = this.container.getByTestId("search-result")
this.searchResultTitle = this.container.getByTestId("search-result-title")
}
async open() {
const menu = new NavMenu(this.page)
await menu.open()
await menu.searchLink.click()
await this.container.waitFor({ state: "visible" })
}
async close() {
const viewport = this.page.viewportSize()
const y = viewport ? viewport.height / 2 : 100
await this.page.mouse.click(1, y, { clickCount: 2, delay: 100 })
await this.container.waitFor({ state: "hidden" })
}
}
================================================
FILE: storefront/e2e/fixtures/cart-page.ts
================================================
import { Locator, Page } from "@playwright/test"
import { BasePage } from "./base/base-page"
export class CartPage extends BasePage {
container: Locator
emptyCartMessage: Locator
signInButton: Locator
productRow: Locator
productTitle: Locator
productVariant: Locator
productDeleteButton: Locator
productQuantitySelect: Locator
discountButton: Locator
discountInput: Locator
discountApplyButton: Locator
discountErrorMessage: Locator
discountRow: Locator
giftCardRow: Locator
giftCardCode: Locator
giftCardAmount: Locator
giftCardRemoveButton: Locator
cartSubtotal: Locator
cartDiscount: Locator
cartGiftCardAmount: Locator
cartShipping: Locator
cartTaxes: Locator
cartTotal: Locator
checkoutButton: Locator
constructor(page: Page) {
super(page)
this.container = page.getByTestId("cart-container")
this.emptyCartMessage = this.container.getByTestId("empty-cart-message")
this.signInButton = this.container.getByTestId("sign-in-button")
this.productRow = this.container.getByTestId("product-row")
this.productTitle = this.container.getByTestId("product-title")
this.productVariant = this.container.getByTestId("product-variant")
this.productDeleteButton = this.container.getByTestId(
"product-delete-button"
)
this.productQuantitySelect = this.container.getByTestId(
"product-quantity-select"
)
this.checkoutButton = this.container.getByTestId("checkout-button")
this.discountButton = this.container.getByTestId("add-discount-button")
this.discountInput = this.container.getByTestId("discount-input")
this.discountApplyButton = this.container.getByTestId(
"discount-apply-button"
)
this.discountErrorMessage = this.container.getByTestId(
"discount-error-message"
)
this.discountRow = this.container.getByTestId("discount-row")
this.giftCardRow = this.container.getByTestId("gift-card")
this.giftCardCode = this.container.getByTestId("gift-card-code")
this.giftCardAmount = this.container.getByTestId("gift-card-amount")
this.giftCardRemoveButton = this.container.getByTestId(
"remove-gift-card-button"
)
this.cartSubtotal = this.container.getByTestId("cart-subtotal")
this.cartDiscount = this.container.getByTestId("cart-discount")
this.cartGiftCardAmount = this.container.getByTestId(
"cart-gift-card-amount"
)
this.cartShipping = this.container.getByTestId("cart-shipping")
this.cartTaxes = this.container.getByTestId("cart-taxes")
this.cartTotal = this.container.getByTestId("cart-total")
}
async getProduct(title: string, variant: string) {
const productRow = this.productRow
.filter({
hasText: title,
})
.filter({
hasText: `Variant: ${variant}`,
})
return {
productRow,
title: productRow.getByTestId("product-title"),
variant: productRow.getByTestId("product-variant"),
deleteButton: productRow.getByTestId("delete-button"),
quantitySelect: productRow.getByTestId("product-select-button"),
price: productRow.getByTestId("product-unit-price"),
total: productRow.getByTestId("product-price"),
}
}
async getGiftCard(code: string) {
const giftCardRow = this.giftCardRow.filter({
hasText: code,
})
const amount = giftCardRow.getByTestId("gift-card-amount")
return {
locator: giftCardRow,
code: giftCardRow.getByTestId("gift-card-code"),
amount,
amountValue: await amount.getAttribute("data-value"),
removeButton: giftCardRow.getByTestId("remove-gift-card-button"),
}
}
async getDiscount(code: string) {
const discount = this.discountRow
const amount = discount.getByTestId("discount-amount")
return {
locator: discount,
code: discount.getByTestId("discount-code"),
amount,
amountValue: await amount.getAttribute("data-value"),
removeButton: discount.getByTestId("remove-discount-button"),
}
}
async goto() {
await this.cartLink.click({ clickCount: 2 })
await this.container.waitFor({ state: "visible" })
}
}
================================================
FILE: storefront/e2e/fixtures/category-page.ts
================================================
import { Locator, Page } from "@playwright/test"
import { BasePage } from "./base/base-page"
export class CategoryPage extends BasePage {
container: Locator
sortByContainer: Locator
pageTitle: Locator
pagination: Locator
productsListLoader: Locator
productsList: Locator
productWrapper: Locator
constructor(page: Page) {
super(page)
this.container = page.getByTestId("category-container")
this.pageTitle = page.getByTestId("category-page-title")
this.sortByContainer = page.getByTestId("sort-by-container")
this.productsListLoader = this.container.getByTestId("products-list-loader")
this.productsList = this.container.getByTestId("products-list")
this.productWrapper = this.productsList.getByTestId("product-wrapper")
this.pagination = this.container.getByTestId("product-pagination")
}
async getProduct(name: string) {
const product = this.productWrapper.filter({ hasText: name })
return {
locator: product,
title: product.getByTestId("product-title"),
price: product.getByTestId("price"),
originalPrice: product.getByTestId("original-price"),
}
}
async sortBy(sortString: string) {
const link = this.sortByContainer.getByTestId("sort-by-link").filter({
hasText: sortString,
})
await link.click()
// wait for page change
await this.page.waitForFunction((linkElement) => {
if (!linkElement) {
return true
}
return linkElement.dataset.active === "true"
}, await link.elementHandle())
}
}
================================================
FILE: storefront/e2e/fixtures/checkout-page.ts
================================================
import { ElementHandle, Locator, Page } from "@playwright/test"
import { BasePage } from "./base/base-page"
export class CheckoutPage extends BasePage {
backToCartLink: Locator
storeLink: Locator
container: Locator
editAddressButton: Locator
editDeliveryButton: Locator
editPaymentButton: Locator
shippingAddressSelect: Locator
shippingAddressOptions: Locator
shippingAddressOption: Locator
billingAddressCheckbox: Locator
billingAddressInput: Locator
billingCityInput: Locator
billingCompanyInput: Locator
billingFirstNameInput: Locator
billingLastNameInput: Locator
billingPhoneInput: Locator
billingPostalInput: Locator
billingProvinceInput: Locator
shippingAddressInput: Locator
shippingCityInput: Locator
shippingCompanyInput: Locator
shippingEmailInput: Locator
shippingFirstNameInput: Locator
shippingLastNameInput: Locator
shippingPhoneInput: Locator
shippingPostalCodeInput: Locator
shippingProvinceInput: Locator
billingCountrySelect: Locator
shippingCountrySelect: Locator
shippingAddressSummary: Locator
shippingContactSummary: Locator
billingAddressSummary: Locator
submitAddressButton: Locator
addressErrorMessage: Locator
deliveryOptionsContainer: Locator
deliveryOptionRadio: Locator
deliveryOptionErrorMessage: Locator
submitDeliveryOptionButton: Locator
deliveryOptionSummary: Locator
paymentMethodSummary: Locator
paymentDetailsSummary: Locator
paymentMethodErrorMessage: Locator
stripePaymentErrorMessage: Locator
paypalPaymentErrorMessage: Locator
manualPaymentErrorMessage: Locator
submitPaymentButton: Locator
submitOrderButton: Locator
discountButton: Locator
discountInput: Locator
discountApplyButton: Locator
discountErrorMessage: Locator
discountRow: Locator
giftCardRow: Locator
giftCardCode: Locator
giftCardAmount: Locator
giftCardRemoveButton: Locator
cartSubtotal: Locator
cartDiscount: Locator
cartGiftCardAmount: Locator
cartShipping: Locator
cartTaxes: Locator
cartTotal: Locator
itemsTable: Locator
itemRow: Locator
itemTitle: Locator
itemVariant: Locator
itemQuantity: Locator
itemOriginalPrice: Locator
itemReducedPrice: Locator
itemUnitOriginalPrice: Locator
itemUnitReducedPrice: Locator
constructor(page: Page) {
super(page)
this.backToCartLink = page.getByTestId("back-to-cart-link")
this.storeLink = page.getByTestId("store-link")
this.container = page.getByTestId("checkout-container")
this.editAddressButton = this.container.getByTestId("edit-address-button")
this.editDeliveryButton = this.container.getByTestId("edit-delivery-button")
this.editPaymentButton = this.container.getByTestId("edit-payment-button")
this.shippingAddressSelect = this.container.getByTestId(
"shipping-address-select"
)
this.shippingAddressOptions = this.container.getByTestId(
"shipping-address-options"
)
this.shippingAddressOption = this.container.getByTestId(
"shipping-address-option"
)
this.billingAddressCheckbox = this.container.getByTestId(
"billing-address-checkbox"
)
this.billingAddressInput = this.container.getByTestId(
"billing-address-input"
)
this.billingCityInput = this.container.getByTestId("billing-city-input")
this.billingCompanyInput = this.container.getByTestId(
"billing-company-input"
)
this.billingFirstNameInput = this.container.getByTestId(
"billing-first-name-input"
)
this.billingLastNameInput = this.container.getByTestId(
"billing-last-name-input"
)
this.billingPhoneInput = this.container.getByTestId("billing-phone-input")
this.billingPostalInput = this.container.getByTestId("billing-postal-input")
this.billingProvinceInput = this.container.getByTestId(
"billing-province-input"
)
this.shippingAddressInput = this.container.getByTestId(
"shipping-address-input"
)
this.shippingCityInput = this.container.getByTestId("shipping-city-input")
this.shippingCompanyInput = this.container.getByTestId(
"shipping-company-input"
)
this.shippingEmailInput = this.container.getByTestId("shipping-email-input")
this.shippingFirstNameInput = this.container.getByTestId(
"shipping-first-name-input"
)
this.shippingLastNameInput = this.container.getByTestId(
"shipping-last-name-input"
)
this.shippingPhoneInput = this.container.getByTestId("shipping-phone-input")
this.shippingPostalCodeInput = this.container.getByTestId(
"shipping-postal-code-input"
)
this.shippingProvinceInput = this.container.getByTestId(
"shipping-province-input"
)
this.billingCountrySelect = this.container.getByTestId(
"billing-country-select"
)
this.shippingCountrySelect = this.container.getByTestId(
"shipping-country-select"
)
this.shippingAddressSummary = this.container.getByTestId(
"shipping-address-summary"
)
this.shippingContactSummary = this.container.getByTestId(
"shipping-contact-summary"
)
this.billingAddressSummary = this.container.getByTestId(
"billing-address-summary"
)
this.submitAddressButton = this.container.getByTestId(
"submit-address-button"
)
this.addressErrorMessage = this.container.getByTestId(
"address-error-message"
)
this.deliveryOptionsContainer = this.container.getByTestId(
"delivery-options-container"
)
this.deliveryOptionRadio = this.container.getByTestId(
"delivery-option-radio"
)
this.deliveryOptionErrorMessage = this.container.getByTestId(
"delivery-option-error-message"
)
this.submitDeliveryOptionButton = this.container.getByTestId(
"submit-delivery-option-button"
)
this.deliveryOptionSummary = this.container.getByTestId(
"delivery-option-summary"
)
this.paymentMethodSummary = this.container.getByTestId(
"payment-method-summary"
)
this.paymentDetailsSummary = this.container.getByTestId(
"payment-details-summary"
)
this.paymentMethodErrorMessage = this.container.getByTestId(
"payment-method-error-message"
)
this.submitPaymentButton = this.container.getByTestId(
"submit-payment-button"
)
this.stripePaymentErrorMessage = this.container.getByTestId(
"stripe-payment-error-message"
)
this.paypalPaymentErrorMessage = this.container.getByTestId(
"paypal-payment-error-message"
)
this.manualPaymentErrorMessage = this.container.getByTestId(
"manual-payment-error-message"
)
this.submitOrderButton = this.container.getByTestId("submit-order-button")
this.discountButton = this.container.getByTestId("add-discount-button")
this.discountInput = this.container.getByTestId("discount-input")
this.discountApplyButton = this.container.getByTestId(
"discount-apply-button"
)
this.discountErrorMessage = this.container.getByTestId(
"discount-error-message"
)
this.discountRow = this.container.getByTestId("discount-row")
this.giftCardRow = this.container.getByTestId("gift-card")
this.giftCardCode = this.container.getByTestId("gift-card-code")
this.giftCardAmount = this.container.getByTestId("gift-card-amount")
this.giftCardRemoveButton = this.container.getByTestId(
"remove-gift-card-button"
)
this.cartSubtotal = this.container.getByTestId("cart-subtotal")
this.cartDiscount = this.container.getByTestId("cart-discount")
this.cartGiftCardAmount = this.container.getByTestId(
"cart-gift-card-amount"
)
this.cartShipping = this.container.getByTestId("cart-shipping")
this.cartTaxes = this.container.getByTestId("cart-taxes")
this.cartTotal = this.container.getByTestId("cart-total")
this.itemsTable = this.container.getByTestId("items-table")
this.itemRow = this.container.getByTestId("item-row")
this.itemTitle = this.container.getByTestId("item-title")
this.itemVariant = this.container.getByTestId("item-variant")
this.itemQuantity = this.container.getByTestId("item-quantity")
this.itemOriginalPrice = this.container.getByTestId("item-original-price")
this.itemReducedPrice = this.container.getByTestId("item-reduced-price")
this.itemUnitOriginalPrice = this.container.getByTestId(
"item-unit-original-price"
)
this.itemUnitReducedPrice = this.container.getByTestId(
"item-unit-reduced-price"
)
}
async selectSavedAddress(address: string) {
await this.shippingAddressSelect.click()
const addressOption = this.shippingAddressOption.filter({
hasText: address,
})
await addressOption.getByTestId("shipping-address-radio").click()
const selectHandle = await this.shippingAddressSelect.elementHandle()
await this.page.waitForFunction(
(opts) => {
const select = opts[0]
const choice = opts[1]
return (select.textContent || "").includes(choice)
},
[selectHandle, address] as [ElementHandle, string]
)
}
async selectDeliveryOption(option: string) {
await this.deliveryOptionRadio.filter({ hasText: option }).click()
}
async getGiftCard(code: string) {
const giftCardRow = this.giftCardRow.filter({
hasText: code,
})
const amount = giftCardRow.getByTestId("gift-card-amount")
return {
locator: giftCardRow,
code: giftCardRow.getByTestId("gift-card-code"),
amount,
amountValue: await amount.getAttribute("data-value"),
removeButton: giftCardRow.getByTestId("remove-gift-card-button"),
}
}
async getDiscount(code: string) {
const discount = this.discountRow
const amount = discount.getByTestId("discount-amount")
return {
locator: discount,
code: discount.getByTestId("discount-code"),
amount,
amountValue: await amount.getAttribute("data-value"),
removeButton: discount.getByTestId("remove-discount-button"),
}
}
}
================================================
FILE: storefront/e2e/fixtures/index.ts
================================================
import { test as base, Page } from "@playwright/test"
import { resetDatabase } from "../data/reset"
import { CartPage } from "./cart-page"
import { CategoryPage } from "./category-page"
import { CheckoutPage } from "./checkout-page"
import { OrderPage } from "./order-page"
import { ProductPage } from "./product-page"
import { StorePage } from "./store-page"
export const fixtures = base.extend<{
resetDatabaseFixture: void
cartPage: CartPage
categoryPage: CategoryPage
checkoutPage: CheckoutPage
orderPage: OrderPage
productPage: ProductPage
storePage: StorePage
}>({
page: async ({ page }, use) => {
await page.goto("/")
use(page)
},
resetDatabaseFixture: [
async function ({}, use) {
await resetDatabase()
await use()
},
{ auto: true, timeout: 10000 },
],
cartPage: async ({ page }, use) => {
const cartPage = new CartPage(page)
await use(cartPage)
},
categoryPage: async ({ page }, use) => {
const categoryPage = new CategoryPage(page)
await use(categoryPage)
},
checkoutPage: async ({ page }, use) => {
const checkoutPage = new CheckoutPage(page)
await use(checkoutPage)
},
orderPage: async ({ page }, use) => {
const orderPage = new OrderPage(page)
await use(orderPage)
},
productPage: async ({ page }, use) => {
const productPage = new ProductPage(page)
await use(productPage)
},
storePage: async ({ page }, use) => {
const storePage = new StorePage(page)
await use(storePage)
},
})
================================================
FILE: storefront/e2e/fixtures/modals/mobile-actions-modal.ts
================================================
import { Page, Locator } from "@playwright/test"
import { BaseModal } from "../base/base-modal"
export class MobileActionsModal extends BaseModal {
optionButton: Locator
constructor(page: Page) {
super(page, page.getByTestId("mobile-actions-modal"))
this.optionButton = this.container.getByTestId("option-button")
}
getOption(option: string) {
return this.optionButton.filter({
hasText: option,
})
}
async selectOption(option: string) {
const optionButton = this.getOption(option)
await optionButton.click()
}
}
================================================
FILE: storefront/e2e/fixtures/order-page.ts
================================================
import { Locator, Page } from "@playwright/test"
import { BasePage } from "./base/base-page"
export class OrderPage extends BasePage {
container: Locator
cartSubtotal: Locator
cartDiscount: Locator
cartGiftCardAmount: Locator
cartShipping: Locator
cartTaxes: Locator
cartTotal: Locator
orderEmail: Locator
orderDate: Locator
orderId: Locator
orderStatus: Locator
orderPaymentStatus: Locator
shippingAddressSummary: Locator
shippingContactSummary: Locator
shippingMethodSummary: Locator
paymentMethod: Locator
paymentAmount: Locator
productsTable: Locator
productRow: Locator
productTitle: Locator
productVariant: Locator
productQuantity: Locator
productOriginalPrice: Locator
productPrice: Locator
productUnitOriginalPrice: Locator
productUnitPrice: Locator
constructor(page: Page) {
super(page)
this.container = page.getByTestId("order-complete-container")
this.orderEmail = this.container.getByTestId("order-email")
this.orderDate = this.container.getByTestId("order-date")
this.orderId = this.container.getByTestId("order-id")
this.orderStatus = this.container.getByTestId("order-status")
this.cartSubtotal = this.container.getByTestId("cart-subtotal")
this.cartDiscount = this.container.getByTestId("cart-discount")
this.cartGiftCardAmount = this.container.getByTestId(
"cart-gift-card-amount"
)
this.cartShipping = this.container.getByTestId("cart-shipping")
this.cartTaxes = this.container.getByTestId("cart-taxes")
this.cartTotal = this.container.getByTestId("cart-total")
this.orderPaymentStatus = this.container.getByTestId("order-payment-status")
this.shippingAddressSummary = this.container.getByTestId(
"shipping-address-summary"
)
this.shippingContactSummary = this.container.getByTestId(
"shipping-contact-summary"
)
this.shippingMethodSummary = this.container.getByTestId(
"shipping-method-summary"
)
this.paymentMethod = this.container.getByTestId("payment-method")
this.paymentAmount = this.container.getByTestId("payment-amount")
this.productsTable = this.container.getByTestId("products-table")
this.productRow = this.container.getByTestId("product-row")
this.productTitle = this.container.getByTestId("product-title")
this.productVariant = this.container.getByTestId("product-variant")
this.productQuantity = this.container.getByTestId("product-quantity")
this.productOriginalPrice = this.container.getByTestId(
"product-original-price"
)
this.productPrice = this.container.getByTestId("product-price")
this.productUnitOriginalPrice = this.container.getByTestId(
"product-unit-original-price"
)
this.productUnitPrice = this.container.getByTestId("product-unit-price")
}
async getProduct(title: string, variant: string) {
const productRow = this.productRow
.filter({
hasText: title,
})
.filter({
hasText: `Variant: ${variant}`,
})
return {
productRow,
name: productRow.getByTestId("product-name"),
variant: productRow.getByTestId("product-variant"),
quantity: productRow.getByTestId("product-quantity"),
price: productRow.getByTestId("product-unit-price"),
total: productRow.getByTestId("product-price"),
}
}
}
================================================
FILE: storefront/e2e/fixtures/product-page.ts
================================================
import { Locator, Page } from "@playwright/test"
import { BasePage } from "./base/base-page"
import { MobileActionsModal } from "./modals/mobile-actions-modal"
export class ProductPage extends BasePage {
mobileActionsModal: MobileActionsModal
container: Locator
productTitle: Locator
productDescription: Locator
productOptions: Locator
productPrice: Locator
addProductButton: Locator
mobileActionsContainer: Locator
mobileTitle: Locator
mobileActionsButton: Locator
mobileAddToCartButton: Locator
constructor(page: Page) {
super(page)
this.mobileActionsModal = new MobileActionsModal(page)
this.container = page.getByTestId("product-container")
this.productTitle = this.container.getByTestId("product-title")
this.productDescription = this.container.getByTestId("product-description")
this.productOptions = this.container.getByTestId("product-options")
this.productPrice = this.container.getByTestId("product-price")
this.addProductButton = this.container.getByTestId("add-product-button")
this.mobileActionsContainer = page.getByTestId("mobile-actions")
this.mobileTitle = this.mobileActionsContainer.getByTestId("mobile-title")
this.mobileAddToCartButton = this.mobileActionsContainer.getByTestId(
"mobile-actions-button"
)
this.mobileActionsButton = this.mobileActionsContainer.getByTestId(
"mobile-actions-select"
)
}
async clickAddProduct() {
await this.addProductButton.click()
await this.cartDropdown.cartDropdown.waitFor({ state: "visible" })
}
async selectOption(option: string) {
await this.page.mouse.move(0, 0) // hides the checkout container
const optionButton = this.productOptions
.getByTestId("option-button")
.filter({ hasText: option })
await optionButton.click({ clickCount: 2 })
}
}
================================================
FILE: storefront/e2e/fixtures/store-page.ts
================================================
import { Locator, Page } from "@playwright/test"
import { CategoryPage } from "./category-page"
export class StorePage extends CategoryPage {
pageTitle: Locator
constructor(page: Page) {
super(page)
this.pageTitle = page.getByTestId("store-page-title")
}
async goto() {
await this.navMenu.open()
await this.navMenu.storeLink.click()
await this.pageTitle.waitFor({ state: "visible" })
await this.productsListLoader.waitFor({ state: "hidden" })
}
}
================================================
FILE: storefront/e2e/index.ts
================================================
import { mergeTests } from "@playwright/test"
import { fixtures } from "./fixtures"
import { accountFixtures } from "./fixtures/account"
export const test = mergeTests(fixtures, accountFixtures)
export { expect } from "@playwright/test"
================================================
FILE: storefront/e2e/tests/authenticated/address.spec.ts
================================================
import { AddressesPage } from "../../fixtures/account/addresses-page"
import { test, expect } from "../../index"
import { getSelectedOptionText } from "../../utils/locators"
test.describe("Addresses tests", () => {
test("Creating a new address is displayed during checkout", async ({
accountAddressesPage: addressesPage,
cartPage,
checkoutPage,
productPage,
storePage,
}) => {
await test.step("Navigate to the new address modal", async () => {
await addressesPage.goto()
await addressesPage.newAddressButton.click()
await addressesPage.addAddressModal.container.waitFor({ state: "visible" })
})
await test.step("Inputs and saves the new address", async () => {
const modal = addressesPage.addAddressModal
await modal.firstNameInput.fill("First")
await modal.lastNameInput.fill("Last")
await modal.companyInput.fill("FirstCorp")
await modal.address1Input.fill("123 Fake Street")
await modal.address2Input.fill("Apt 1")
await modal.postalCodeInput.fill("11111")
await modal.cityInput.fill("City")
await modal.stateInput.fill("Colorado")
await modal.countrySelect.selectOption({
label: "United States",
})
await modal.phoneInput.fill("1112223333")
await modal.saveButton.click()
await modal.container.waitFor({ state: "hidden" })
})
await test.step("Navigate to a product page and add a product to the cart", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.highlight()
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
await productPage.selectOption("M")
await productPage.addProductButton.click()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
})
await test.step("Verify the address is correct in the checkout process", async () => {
await checkoutPage.selectSavedAddress("123 Fake Street")
await expect(checkoutPage.shippingFirstNameInput).toHaveValue("First")
await expect(checkoutPage.shippingLastNameInput).toHaveValue("Last")
await expect(checkoutPage.shippingCompanyInput).toHaveValue("FirstCorp")
await expect(checkoutPage.shippingAddressInput).toHaveValue(
"123 Fake Street"
)
await expect(checkoutPage.shippingPostalCodeInput).toHaveValue("11111")
await expect(checkoutPage.shippingCityInput).toHaveValue("City")
await expect(checkoutPage.shippingProvinceInput).toHaveValue("Colorado")
expect(
await getSelectedOptionText(
checkoutPage.page,
checkoutPage.shippingCountrySelect
)
).toContain("United States")
})
})
test("Performing all the CRUD actions for an address", async ({
accountAddressesPage: addressesPage,
}) => {
await test.step("Navigate to the new address modal", async () => {
await addressesPage.goto()
await addressesPage.newAddressButton.click()
await addressesPage.addAddressModal.container.waitFor({ state: "visible" })
})
await test.step("Input and save a new address", async () => {
const { addAddressModal } = addressesPage
await addAddressModal.firstNameInput.fill("First")
await addAddressModal.lastNameInput.fill("Last")
await addAddressModal.companyInput.fill("MyCorp")
await addAddressModal.address1Input.fill("123 Fake Street")
await addAddressModal.address2Input.fill("Apt 1")
await addAddressModal.postalCodeInput.fill("80010")
await addAddressModal.cityInput.fill("Denver")
await addAddressModal.stateInput.fill("Colorado")
await addAddressModal.countrySelect.selectOption({ label: "United States" })
await addAddressModal.phoneInput.fill("3031112222")
await addAddressModal.saveButton.click()
await addAddressModal.container.waitFor({ state: "hidden" })
})
let addressContainer: ReturnType
await test.step("Make sure the address container was appended to the page", async () => {
addressContainer = addressesPage.getAddressContainer("First Last")
await expect(addressContainer.name).toHaveText("First Last")
await expect(addressContainer.company).toHaveText("MyCorp")
await expect(addressContainer.address).toContainText("123 Fake Street")
await expect(addressContainer.address).toContainText("Apt 1")
await expect(addressContainer.postalCity).toContainText("80010, Denver")
await expect(addressContainer.provinceCountry).toContainText("Colorado, US")
})
await test.step("Refresh the page and assert address was saved", async () => {
await addressesPage.page.reload()
addressContainer = addressesPage.getAddressContainer("First Last")
await expect(addressContainer.name).toHaveText("First Last")
await expect(addressContainer.company).toHaveText("MyCorp")
await expect(addressContainer.address).toContainText("123 Fake Street")
await expect(addressContainer.address).toContainText("Apt 1")
await expect(addressContainer.postalCity).toContainText("80010, Denver")
await expect(addressContainer.provinceCountry).toContainText("Colorado, US")
})
await test.step("Edit the address", async () => {
await addressContainer.editButton.click()
await addressesPage.editAddressModal.container.waitFor({ state: "visible" })
await addressesPage.editAddressModal.firstNameInput.fill("Second")
await addressesPage.editAddressModal.lastNameInput.fill("Final")
await addressesPage.editAddressModal.companyInput.fill("MeCorp")
await addressesPage.editAddressModal.address1Input.fill("123 Spark Street")
await addressesPage.editAddressModal.address2Input.fill("Unit 3")
await addressesPage.editAddressModal.postalCodeInput.fill("80011")
await addressesPage.editAddressModal.cityInput.fill("Broomfield")
await addressesPage.editAddressModal.stateInput.fill("CO")
await addressesPage.editAddressModal.countrySelect.selectOption({
label: "Canada",
})
await addressesPage.editAddressModal.phoneInput.fill("3032223333")
await addressesPage.editAddressModal.saveButton.click()
await addressesPage.editAddressModal.container.waitFor({ state: "hidden" })
})
await test.step("Make sure edits were saved on the addressContainer", async () => {
addressContainer = addressesPage.getAddressContainer("Second Final")
await expect(addressContainer.name).toContainText("Second Final")
await expect(addressContainer.company).toContainText("MeCorp")
await expect(addressContainer.address).toContainText("123 Spark Street, Unit 3")
await expect(addressContainer.postalCity).toContainText("80011, Broomfield")
await expect(addressContainer.provinceCountry).toContainText("CO, CA")
})
await test.step("Refresh the page and assert edits were saved", async () => {
await addressesPage.page.reload()
await expect(addressContainer.name).toContainText("Second Final")
await expect(addressContainer.company).toContainText("MeCorp")
await expect(addressContainer.address).toContainText("123 Spark Street, Unit 3")
await expect(addressContainer.postalCity).toContainText("80011, Broomfield")
await expect(addressContainer.provinceCountry).toContainText("CO, CA")
})
await test.step("Delete the address", async () => {
await addressContainer.deleteButton.click()
await addressContainer.container.waitFor({ state: "hidden" })
await addressesPage.page.reload()
await expect(addressContainer.container).not.toBeVisible()
})
await test.step("Ensure address remains deleted after refresh", async () => {
await addressesPage.page.reload()
await expect(addressContainer.container).not.toBeVisible()
})
})
test.skip("Attempt to create duplicate addresses on the address page", async ({
accountAddressesPage: addressesPage
}) => {
await test.step("navigate to the new address modal", async () => {
await addressesPage.goto()
await addressesPage.newAddressButton.click()
await addressesPage.addAddressModal.container.waitFor({ state: "visible" })
})
await test.step("Input and save a new address", async () => {
await addressesPage.addAddressModal.firstNameInput.fill("First")
await addressesPage.addAddressModal.lastNameInput.fill("Last")
await addressesPage.addAddressModal.companyInput.fill("MyCorp")
await addressesPage.addAddressModal.address1Input.fill("123 Fake Street")
await addressesPage.addAddressModal.address2Input.fill("Apt 1")
await addressesPage.addAddressModal.postalCodeInput.fill("80010")
await addressesPage.addAddressModal.cityInput.fill("Denver")
await addressesPage.addAddressModal.stateInput.fill("Colorado")
await addressesPage.addAddressModal.countrySelect.selectOption({
label: "United States",
})
await addressesPage.addAddressModal.phoneInput.fill("3031112222")
await addressesPage.addAddressModal.saveButton.click()
await addressesPage.addAddressModal.container.waitFor({ state: "hidden" })
})
await test.step("Attempt to create the same address", async () => {
await addressesPage.newAddressButton.click()
await addressesPage.addAddressModal.container.waitFor({ state: "visible" })
await addressesPage.addAddressModal.firstNameInput.fill("First")
await addressesPage.addAddressModal.lastNameInput.fill("Last")
await addressesPage.addAddressModal.companyInput.fill("MyCorp")
await addressesPage.addAddressModal.address1Input.fill("123 Fake Street")
await addressesPage.addAddressModal.address2Input.fill("Apt 1")
await addressesPage.addAddressModal.postalCodeInput.fill("80010")
await addressesPage.addAddressModal.cityInput.fill("Denver")
await addressesPage.addAddressModal.stateInput.fill("Colorado")
await addressesPage.addAddressModal.countrySelect.selectOption({
label: "United States",
})
await addressesPage.addAddressModal.phoneInput.fill("3031112222")
await addressesPage.addAddressModal.saveButton.click()
})
await test.step("Validate error state", async () => {
})
})
test("Creating multiple tests works correctly", async ({
accountAddressesPage: addressesPage,
}) => {
test.slow()
await test.step("Navigate to the new address modal", async () => {
await addressesPage.goto()
})
let addressContainer: ReturnType
for (let i = 0; i < 10; i++) {
await test.step("Open up the new address modal", async () => {
await addressesPage.newAddressButton.click()
await addressesPage.addAddressModal.container.waitFor({ state: "visible" })
})
await test.step("Input and save a new address", async () => {
const { addAddressModal } = addressesPage
await addAddressModal.firstNameInput.fill(`First-${i}`)
await addAddressModal.lastNameInput.fill(`Last-${i}`)
await addAddressModal.companyInput.fill(`MyCorp-${i}`)
await addAddressModal.address1Input.fill(`123 Fake Street-${i}`)
await addAddressModal.address2Input.fill("Apt 1")
await addAddressModal.postalCodeInput.fill("80010")
await addAddressModal.cityInput.fill("Denver")
await addAddressModal.stateInput.fill("Colorado")
await addAddressModal.countrySelect.selectOption({ label: "United States" })
await addAddressModal.phoneInput.fill("3031112222")
await addAddressModal.saveButton.click()
await addAddressModal.container.waitFor({ state: "hidden" })
})
await test.step("Make sure the address container was appended to the page", async () => {
addressContainer = addressesPage.getAddressContainer(`First-${i} Last-${i}`)
await expect(addressContainer.name).toHaveText(`First-${i} Last-${i}`)
await expect(addressContainer.company).toHaveText(`MyCorp-${i}`)
await expect(addressContainer.address).toContainText(`123 Fake Street-${i}`)
await expect(addressContainer.address).toContainText("Apt 1")
await expect(addressContainer.postalCity).toContainText("80010, Denver")
await expect(addressContainer.provinceCountry).toContainText("Colorado, US")
})
}
})
})
================================================
FILE: storefront/e2e/tests/authenticated/orders.spec.ts
================================================
import { test, expect } from "../../index"
test.describe("Account orders page tests", async () => {
test.beforeEach(async ({ accountAddressesPage }) => {
await accountAddressesPage.goto()
await accountAddressesPage.newAddressButton.click()
await test.step("Add default address", async () => {
const modal = accountAddressesPage.addAddressModal
await modal.container.waitFor({ state: "visible" })
await modal.firstNameInput.fill("First")
await modal.lastNameInput.fill("Last")
await modal.companyInput.fill("FirstCorp")
await modal.address1Input.fill("123 Fake Street")
await modal.address2Input.fill("Apt 1")
await modal.postalCodeInput.fill("11111")
await modal.cityInput.fill("City")
await modal.stateInput.fill("Colorado")
await modal.countrySelect.selectOption({
label: "United States",
})
await modal.phoneInput.fill("1112223333")
await modal.saveButton.click()
await modal.container.waitFor({ state: "hidden" })
})
})
test("Verify account orders page displays empty container", async ({
accountOrdersPage,
}) => {
await accountOrdersPage.goto()
await expect(accountOrdersPage.noOrdersContainer).toBeVisible()
})
test("Order shows up after checkout flow", async ({
accountOrdersPage,
accountOrderPage,
cartPage,
checkoutPage,
orderPage: publicOrderPage,
productPage,
storePage,
}) => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.highlight()
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
})
await test.step("Enter in the first step of the checkout process", async () => {
await checkoutPage.selectSavedAddress("123 Fake Street")
await checkoutPage.shippingEmailInput.fill("test@example.com")
await checkoutPage.shippingPhoneInput.fill("3031112222")
await checkoutPage.submitAddressButton.click()
await checkoutPage.deliveryOptionsContainer.waitFor({ state: "visible" })
})
await test.step("Complete the rest of the payment process", async () => {
await checkoutPage.selectDeliveryOption("FakeEx Standard")
await checkoutPage.submitDeliveryOptionButton.click()
await checkoutPage.submitPaymentButton.click()
await checkoutPage.submitOrderButton.click()
await publicOrderPage.container.waitFor({ state: "visible" })
})
let orderId = ""
await test.step("Verify the order page information is correct", async () => {
orderId = (await publicOrderPage.orderId.textContent()) || ""
await test.step("Verify the products ordered are correct", async () => {
const product = await publicOrderPage.getProduct("Sweatshirt", "M")
await expect(product.name).toContainText("Sweatshirt")
await expect(product.variant).toContainText("M")
await expect(product.quantity).toContainText("1")
})
await test.step("Verify the shipping info is correct", async () => {
const address = publicOrderPage.shippingAddressSummary
await expect(address).toContainText("First")
await expect(address).toContainText("Last")
await expect(address).toContainText("123 Fake Street")
await expect(address).toContainText("11111")
await expect(address).toContainText("City")
await expect(address).toContainText("US")
const contact = publicOrderPage.shippingContactSummary
await expect(contact).toContainText("test@example.com")
await expect(contact).toContainText("3031112222")
const method = publicOrderPage.shippingMethodSummary
await expect(method).toContainText("FakeEx Standard")
})
})
await test.step("Verify the account orders page displays a result", async () => {
await accountOrdersPage.goto()
const order = await accountOrdersPage.getOrderById(orderId)
expect(order.items.length).toBe(1)
expect(order.items[0].title).toContainText("Sweatshirt")
expect(order.items[0].quantity).toHaveText("1")
await order.detailsLink.click()
await accountOrderPage.container.waitFor({ state: "visible" })
})
await test.step("Verify the order page displays the correct information", async () => {
await test.step("Verify the order id is correct", async () => {
await expect(accountOrderPage.orderId).toHaveText(orderId)
})
await test.step("Verify the products ordered are correct", async () => {
const product = await accountOrderPage.getProduct("Sweatshirt", "M")
await expect(product.name).toContainText("Sweatshirt")
await expect(product.variant).toContainText("M")
await expect(product.quantity).toContainText("1")
})
await test.step("Verify the shipping info is correct", async () => {
const address = accountOrderPage.shippingAddressSummary
await expect(address).toContainText("First")
await expect(address).toContainText("Last")
await expect(address).toContainText("123 Fake Street")
await expect(address).toContainText("11111")
await expect(address).toContainText("City")
await expect(address).toContainText("US")
const contact = accountOrderPage.shippingContactSummary
await contact.highlight()
await expect(contact.getByText("test@example.com")).toBeVisible()
await expect(contact.getByText("3031112222")).toBeVisible()
const method = accountOrderPage.shippingMethodSummary
await method.highlight()
await expect(method).toContainText("FakeEx Standard")
})
})
await test.step("Navigate back to the orders page, verifying back button works", async () => {
await accountOrderPage.backToOverviewButton.click()
await accountOrdersPage.container.waitFor({ state: "visible" })
})
})
test("Order preserves item count, and variants", async ({
accountOrdersPage,
accountOrderPage,
cartPage,
checkoutPage,
orderPage: publicOrderPage,
productPage,
storePage,
}) => {
await test.step("Add first batch or products to the cart", async () => {
await test.step("Navigate to the sweatshirt product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.highlight()
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.close()
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.close()
})
})
await test.step("Add second batch of products to the cart", async () => {
await test.step("Navigate to the sweatshirt product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatpants")
await product.locator.highlight()
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("S")
await productPage.clickAddProduct()
await productPage.cartDropdown.close()
await productPage.selectOption("M")
await productPage.clickAddProduct()
})
await test.step("Navigate to the checkout process", async () => {
await productPage.cartDropdown.goToCartButton.click()
await productPage.cartDropdown.close()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
})
})
let orderId = ""
await test.step("Checkout process", async () => {
await test.step("Enter in the first step of the checkout process", async () => {
await checkoutPage.selectSavedAddress("123 Fake Street")
await checkoutPage.shippingEmailInput.fill("test@example.com")
await checkoutPage.shippingPhoneInput.fill("3031112222")
await checkoutPage.submitAddressButton.click()
await checkoutPage.deliveryOptionsContainer.waitFor({
state: "visible",
})
})
await test.step("Complete the rest of the payment process", async () => {
await checkoutPage.selectDeliveryOption("FakeEx Standard")
await checkoutPage.submitDeliveryOptionButton.click()
await checkoutPage.submitPaymentButton.click()
await checkoutPage.submitOrderButton.click()
await publicOrderPage.container.waitFor({ state: "visible" })
orderId = (await publicOrderPage.orderId.textContent()) || ""
})
})
await test.step("Verify the order page information is correct", async () => {
await test.step("Navigate to the account orders page, verify information, and navigate to the order page", async () => {
await accountOrdersPage.goto()
const order = await accountOrdersPage.getOrderById(orderId)
expect(order.itemsLocator).toHaveCount(3)
expect(
order.itemsLocator.filter({ hasText: "Sweatpants" })
).toHaveCount(2)
expect(
order.itemsLocator.filter({ hasText: "Sweatshirt" })
).toHaveCount(1)
await order.detailsLink.click()
await accountOrderPage.container.waitFor({ state: "visible" })
})
await test.step("Verify information on the order page", async () => {
const sweatshirt = await accountOrderPage.getProduct("Sweatshirt", "M")
await expect(sweatshirt.name).toContainText("Sweatshirt")
await expect(sweatshirt.variant).toContainText("M")
await expect(sweatshirt.quantity).toContainText("2")
const smallSweatpants = await accountOrderPage.getProduct(
"Sweatpants",
"S"
)
await expect(smallSweatpants.name).toContainText("Sweatpants")
await expect(smallSweatpants.variant).toContainText("S")
await expect(smallSweatpants.quantity).toContainText("1")
const mediumSweatpants = await accountOrderPage.getProduct(
"Sweatpants",
"M"
)
await expect(mediumSweatpants.name).toContainText("Sweatpants")
await expect(mediumSweatpants.variant).toContainText("M")
await expect(mediumSweatpants.quantity).toContainText("1")
})
})
})
test("Multiple orders are stored correctly", async ({
accountOrdersPage,
cartPage,
checkoutPage,
orderPage: publicOrderPage,
productPage,
storePage,
}) => {
let firstOrderId = ""
let secondOrderId = ""
await test.step("Make the first order", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.highlight()
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
})
await test.step("Enter in the first step of the checkout process", async () => {
await checkoutPage.selectSavedAddress("123 Fake Street")
await checkoutPage.shippingEmailInput.fill("test@example.com")
await checkoutPage.shippingPhoneInput.fill("3031112222")
await checkoutPage.submitAddressButton.click()
await checkoutPage.deliveryOptionsContainer.waitFor({
state: "visible",
})
})
await test.step("Complete the rest of the payment process", async () => {
await checkoutPage.selectDeliveryOption("FakeEx Standard")
await checkoutPage.submitDeliveryOptionButton.click()
await checkoutPage.submitPaymentButton.click()
await checkoutPage.submitOrderButton.click()
await publicOrderPage.container.waitFor({ state: "visible" })
firstOrderId = (await publicOrderPage.orderId.textContent()) || ""
})
})
await test.step("Make the second order", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatpants")
await product.locator.highlight()
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("S")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
})
await test.step("Enter in the first step of the checkout process", async () => {
await checkoutPage.selectSavedAddress("123 Fake Street")
await checkoutPage.shippingEmailInput.fill("test@example.com")
await checkoutPage.shippingPhoneInput.fill("3031112222")
await checkoutPage.submitAddressButton.click()
await checkoutPage.deliveryOptionsContainer.waitFor({
state: "visible",
})
})
await test.step("Complete the rest of the payment process", async () => {
await checkoutPage.selectDeliveryOption("FakeEx Standard")
await checkoutPage.submitDeliveryOptionButton.click()
await checkoutPage.submitPaymentButton.click()
await checkoutPage.submitOrderButton.click()
await publicOrderPage.container.waitFor({ state: "visible" })
secondOrderId = (await publicOrderPage.orderId.textContent()) || ""
})
})
await test.step("Verify there are distinct orders on the orders page", async () => {
await accountOrdersPage.goto()
await test.step("Verify the first order info", async () => {
const order = await accountOrdersPage.getOrderById(firstOrderId)
await expect(order.itemsLocator).toHaveCount(1)
await expect(order.items[0].title).toContainText("Sweatshirt")
await expect(order.items[0].quantity).toHaveText("1")
})
await test.step("Verify the second order info", async () => {
const order = await accountOrdersPage.getOrderById(secondOrderId)
await expect(order.itemsLocator).toHaveCount(1)
await expect(order.items[0].title).toContainText("Sweatpants")
await expect(order.items[0].quantity).toHaveText("1")
})
})
})
})
================================================
FILE: storefront/e2e/tests/authenticated/profile.spec.ts
================================================
import { test, expect } from "../../index"
test.describe("Account profile tests", () => {
test("Profile completed update flow", async ({
accountOverviewPage: overviewPage,
accountProfilePage: profilePage,
}) => {
await overviewPage.goto()
await expect(overviewPage.profileCompletion).toHaveText("50%")
await test.step("navigate to the profile page", async () => {
await profilePage.profileLink.click()
await expect(profilePage.profileWrapper).toBeVisible()
})
await test.step("update the saved profile phone number", async () => {
await expect(profilePage.savedPhone).toHaveText("null")
await profilePage.phoneEditButton.click()
await profilePage.phoneInput.fill("8888888888")
await profilePage.phoneSaveButton.click()
await expect(profilePage.phoneSuccessMessage).toBeVisible()
await expect(profilePage.savedPhone).toHaveText("8888888888")
})
await test.step("verify the profile completion state and go back to the profile page", async () => {
await profilePage.overviewLink.click()
await expect(overviewPage.profileCompletion).toHaveText("75%")
await profilePage.profileLink.click()
await expect(profilePage.profileWrapper).toBeVisible()
})
await test.step("enter in the billing address", async () => {
await expect(profilePage.savedBillingAddress).toContainText(
"No billing address"
)
await profilePage.billingAddressEditButton.click()
await profilePage.billingFirstNameInput.fill("First")
await profilePage.billingLastNameInput.fill("Last")
await profilePage.billingAddress1Input.fill("123 Fake Street")
await profilePage.billingPostcalCodeInput.fill("11111")
await profilePage.billingCityInput.fill("Springdale")
await profilePage.billingProvinceInput.fill("IL")
await profilePage.billingCountryCodeSelect.selectOption({
label: "United States",
})
await profilePage.billingAddressSaveButton.click()
await expect(profilePage.billingAddressSuccessMessage).toBeVisible()
})
await test.step("profile completion state", async () => {
await profilePage.overviewLink.click()
await expect(overviewPage.profileCompletion).toHaveText("100%")
await profilePage.goto()
await expect(profilePage.savedBillingAddress).toContainText("First Last")
await expect(profilePage.savedBillingAddress).toContainText(
"123 Fake Street"
)
await expect(profilePage.savedBillingAddress).toContainText(
"11111, Springdale"
)
await expect(profilePage.savedBillingAddress).toContainText(
"United States"
)
})
})
test("Profile changes persist across page refreshes and logouts", async ({
page,
loginPage,
accountOverviewPage: overviewPage,
accountProfilePage: profilePage,
}) => {
await overviewPage.goto()
await expect(overviewPage.profileCompletion).toHaveText("50%")
await test.step("navigate to the profile page", async () => {
await profilePage.profileLink.click()
await expect(profilePage.profileWrapper).toBeVisible()
})
await test.step("update the first and last name", async () => {
await profilePage.nameEditButton.click()
await profilePage.firstNameInput.fill("FirstNew")
await profilePage.lastNameInput.fill("LastNew")
await profilePage.nameSaveButton.click()
await profilePage.nameSuccessMessage.waitFor({ state: "visible" })
})
await test.step("update the saved profile phone number", async () => {
await expect(profilePage.savedPhone).toHaveText("null")
await profilePage.phoneEditButton.click()
await profilePage.phoneInput.fill("8888888888")
await profilePage.phoneSaveButton.click()
await expect(profilePage.phoneSuccessMessage).toBeVisible()
await expect(profilePage.savedPhone).toHaveText("8888888888")
})
await test.step("enter in the billing address", async () => {
await expect(profilePage.savedBillingAddress).toContainText(
"No billing address"
)
await profilePage.billingAddressEditButton.click()
await profilePage.billingFirstNameInput.fill("First")
await profilePage.billingLastNameInput.fill("Last")
await profilePage.billingAddress1Input.fill("123 Fake Street")
await profilePage.billingPostcalCodeInput.fill("11111")
await profilePage.billingCityInput.fill("Springdale")
await profilePage.billingProvinceInput.fill("IL")
await profilePage.billingCountryCodeSelect.selectOption({
label: "United States",
})
await profilePage.billingAddressSaveButton.click()
await expect(profilePage.billingAddressSuccessMessage).toBeVisible()
})
await test.step("Refresh page and verify information saved is still there", async () => {
await page.reload()
await expect(profilePage.savedName).toContainText("FirstNew")
await expect(profilePage.savedName).toContainText("LastNew")
await expect(profilePage.savedPhone).toContainText("8888888888")
await expect(profilePage.savedBillingAddress).toContainText("First Last")
await expect(profilePage.savedBillingAddress).toContainText(
"123 Fake Street"
)
await expect(profilePage.savedBillingAddress).toContainText(
"11111, Springdale"
)
await expect(profilePage.savedBillingAddress).toContainText(
"United States"
)
})
await test.step("Log out and log back in", async () => {
await profilePage.logoutLink.click()
await expect(loginPage.container).toBeVisible()
await loginPage.emailInput.fill("test@example.com")
await loginPage.passwordInput.fill("password")
await loginPage.signInButton.click()
await overviewPage.overviewWrapper.waitFor({ state: "visible" })
await overviewPage.profileLink.click()
await profilePage.profileWrapper.waitFor({ state: "visible" })
})
await test.step("Verify the saved profile information is correct", async () => {
await expect(profilePage.savedName).toContainText("FirstNew")
await expect(profilePage.savedName).toContainText("LastNew")
await expect(profilePage.savedPhone).toContainText("8888888888")
await expect(profilePage.savedBillingAddress).toContainText("First Last")
await expect(profilePage.savedBillingAddress).toContainText(
"123 Fake Street"
)
await expect(profilePage.savedBillingAddress).toContainText(
"11111, Springdale"
)
await expect(profilePage.savedBillingAddress).toContainText(
"United States"
)
})
})
test("Verifies password changes work correctly", async ({
loginPage,
accountProfilePage: profilePage,
accountOverviewPage: overviewPage,
}) => {
await test.step("Navigate to the account Profile page", async () => {
await overviewPage.goto()
await profilePage.profileLink.click()
})
await test.step("Update the password", async () => {
await profilePage.passwordEditButton.click()
await profilePage.oldPasswordInput.fill("password")
await profilePage.newPasswordInput.fill("updated-password")
await profilePage.confirmPasswordInput.fill("updated-password")
await profilePage.passwordSaveButton.click()
await expect(profilePage.passwordSuccessMessage).toBeVisible()
})
await test.step("logout and log back in", async () => {
await profilePage.logoutLink.click()
await expect(loginPage.container).toBeVisible()
await loginPage.emailInput.fill("test@example.com")
await loginPage.passwordInput.fill("updated-password")
await loginPage.signInButton.click()
await expect(overviewPage.container).toBeVisible()
})
})
test("Check if changing email address updates user correctly", async ({
loginPage,
accountProfilePage: profilePage,
accountOverviewPage: accountPage,
}) => {
await test.step("Update the user email", async () => {
await accountPage.goto()
await accountPage.welcomeMessage.waitFor({ state: "visible" })
await accountPage.profileLink.click()
await profilePage.profileWrapper.waitFor({ state: "visible" })
await profilePage.emailEditButton.click()
await profilePage.emailInput.fill("test-111@example.com")
await profilePage.emailSaveButton.click()
await profilePage.emailSuccessMessage.waitFor({ state: "visible" })
})
await test.step("Try logging in again with the old email", async () => {
await profilePage.logoutLink.click()
await loginPage.container.waitFor({ state: "visible" })
await loginPage.emailInput.fill("test@example.com")
await loginPage.passwordInput.fill("password")
await loginPage.signInButton.click()
await loginPage.errorMessage.waitFor({ state: "visible" })
})
await test.step("Login with the new email", async () => {
await loginPage.emailInput.fill("test-111@example.com")
await loginPage.signInButton.click()
await accountPage.welcomeMessage.waitFor({ state: "visible" })
})
await test.step("Set the email back to test@example.com", async () => {
await accountPage.profileLink.click()
await profilePage.profileWrapper.waitFor({ state: "visible" })
await profilePage.emailEditButton.click()
await profilePage.emailInput.fill("test@example.com")
await profilePage.emailSaveButton.click()
await profilePage.emailSuccessMessage.waitFor({ state: "visible" })
})
await test.step("Try logging out and logging in with the first email", async () => {
await profilePage.logoutLink.click()
await loginPage.container.waitFor({ state: "visible" })
await loginPage.emailInput.fill("test@example.com")
await loginPage.passwordInput.fill("password")
await loginPage.signInButton.click()
await accountPage.welcomeMessage.waitFor({ state: "visible" })
})
})
})
================================================
FILE: storefront/e2e/tests/global/public-setup.ts
================================================
import { test as setup } from "@playwright/test"
import { seedData } from "../../data/seed"
setup("Seed data", async () => {
await seedData()
})
================================================
FILE: storefront/e2e/tests/global/setup.ts
================================================
import { test as setup } from "@playwright/test"
import { seedData } from "../../data/seed"
import { OverviewPage as AccountOverviewPage } from "../../fixtures/account/overview-page"
import { LoginPage } from "../../fixtures/account/login-page"
import { STORAGE_STATE } from "../../../playwright.config"
setup(
"Seed data and create session for authenticated user",
async ({ page }) => {
const seed = await seedData()
const user = seed.user
const loginPage = new LoginPage(page)
const accountPage = new AccountOverviewPage(page)
await loginPage.goto()
await loginPage.emailInput.fill(user?.email!)
await loginPage.passwordInput.fill(user?.password!)
await loginPage.signInButton.click()
await accountPage.welcomeMessage.waitFor({ state: "visible" })
await page.context().storageState({
path: STORAGE_STATE,
})
}
)
================================================
FILE: storefront/e2e/tests/global/teardown.ts
================================================
import { test as teardown } from "@playwright/test"
import { dropTemplate, resetDatabase } from "../../data/reset"
teardown("Reset the database and the drop the template database", async () => {
await resetDatabase()
await dropTemplate()
})
================================================
FILE: storefront/e2e/tests/public/cart.spec.ts
================================================
/*
Test List
- login from the sign in page redirects you page to the cart
*/
import { test, expect } from "../../index"
import { compareFloats, getFloatValue } from "../../utils"
test.describe("Cart tests", async () => {
test("Ensure adding multiple items from a product page adjusts the cart accordingly", async ({
page,
cartPage,
productPage,
storePage,
}) => {
// Assuming we have access to our page objects here
const cartDropdown = cartPage.cartDropdown
await test.step("Navigate to the product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the small size to the cart and verify the data", async () => {
await productPage.selectOption("S")
await productPage.addProductButton.click()
await expect(cartDropdown.navCartLink).toContainText("(1)")
const cartItem = await cartDropdown.getCartItem("Sweatshirt", "S")
await expect(cartItem.locator).toBeVisible()
await expect(cartItem.variant).toContainText("S")
await expect(cartItem.quantity).toContainText("1")
await cartDropdown.goToCartButton.click()
await cartDropdown.close()
await cartPage.container.waitFor({ state: "visible" })
const productInCart = await cartPage.getProduct("Sweatshirt", "S")
await expect(productInCart.productRow).toBeVisible()
await expect(productInCart.quantitySelect).toHaveValue("1")
await page.goBack()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the small size to the cart again and verify the data", async () => {
await productPage.selectOption("S")
await productPage.addProductButton.click()
await expect(cartDropdown.navCartLink).toContainText("(2)")
const cartItem = await cartDropdown.getCartItem("Sweatshirt", "S")
await expect(cartItem.locator).toBeVisible()
await expect(cartItem.variant).toContainText("S")
await expect(cartItem.quantity).toContainText("2")
await cartDropdown.goToCartButton.click()
await cartDropdown.close()
await cartPage.container.waitFor({ state: "visible" })
const productInCart = await cartPage.getProduct("Sweatshirt", "S")
await expect(productInCart.productRow).toBeVisible()
await expect(productInCart.quantitySelect).toHaveValue("2")
await page.goBack()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the medium size to the cart and verify the data", async () => {
await productPage.selectOption("M")
await productPage.addProductButton.click()
await expect(cartDropdown.navCartLink).toContainText("(3)")
const mediumCartItem = await cartDropdown.getCartItem("Sweatshirt", "M")
await expect(mediumCartItem.locator).toBeVisible()
await expect(mediumCartItem.variant).toContainText("M")
await expect(mediumCartItem.quantity).toContainText("1")
await cartDropdown.goToCartButton.click()
await cartDropdown.close()
await cartPage.container.waitFor({ state: "visible" })
const mediumProductInCart = await cartPage.getProduct("Sweatshirt", "M")
await expect(mediumProductInCart.productRow).toBeVisible()
await expect(mediumProductInCart.quantitySelect).toHaveValue("1")
const smallProductInCart = await cartPage.getProduct("Sweatshirt", "S")
await expect(smallProductInCart.productRow).toBeVisible()
await expect(smallProductInCart.quantitySelect).toHaveValue("2")
})
})
test("Ensure adding two products into the cart and verify the quantities", async ({
cartPage,
productPage,
storePage,
}) => {
const cartDropdown = cartPage.cartDropdown
await test.step("Navigate to the product page - go to the store page and click on the Sweatshirt product", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the small sweatshirt to the cart", async () => {
await productPage.selectOption("S")
await productPage.addProductButton.click()
await expect(cartDropdown.navCartLink).toContainText("(1)")
const sweatshirtItem = await cartDropdown.getCartItem("Sweatshirt", "S")
await expect(sweatshirtItem.locator).toBeVisible()
await expect(sweatshirtItem.variant).toHaveText("Variant: S")
await expect(sweatshirtItem.quantity).toContainText("1")
await cartDropdown.close()
})
await test.step("Navigate to another product - Sweatpants", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatpants")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the small sweatpants to the cart", async () => {
await productPage.selectOption("S")
await productPage.addProductButton.click()
await expect(cartDropdown.navCartLink).toContainText("(2)")
const sweatpantsItem = await cartDropdown.getCartItem("Sweatpants", "S")
await expect(sweatpantsItem.locator).toBeVisible()
await expect(sweatpantsItem.variant).toHaveText("Variant: S")
await expect(sweatpantsItem.quantity).toContainText("1")
const sweatshirtItem = await cartDropdown.getCartItem("Sweatshirt", "S")
await expect(sweatshirtItem.locator).toBeVisible()
await expect(sweatshirtItem.quantity).toContainText("1")
await cartDropdown.goToCartButton.click()
await cartDropdown.close()
await cartPage.container.waitFor({ state: "visible" })
})
await test.step("Verify the quantities in the cart", async () => {
const sweatpantsProduct = await cartPage.getProduct("Sweatpants", "S")
await expect(sweatpantsProduct.productRow).toBeVisible()
await expect(sweatpantsProduct.quantitySelect).toHaveValue("1")
const sweatshirtProduct = await cartPage.getProduct("Sweatshirt", "S")
await expect(sweatshirtProduct.productRow).toBeVisible()
await expect(sweatshirtProduct.quantitySelect).toHaveValue("1")
})
})
test("Verify the prices carries over to checkout", async ({
cartPage,
productPage,
storePage,
}) => {
await test.step("Navigate to the product page - go to the store page and click on the Hoodie product", async () => {
await storePage.goto()
const product = await storePage.getProduct("Hoodie")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
let hoodieSmallPrice = 0
let hoodieMediumPrice = 0
await test.step("Add the hoodie to the cart", async () => {
await productPage.selectOption("S")
hoodieSmallPrice = getFloatValue(
(await productPage.productPrice.getAttribute("data-value")) || "0"
)
await productPage.clickAddProduct()
await productPage.cartDropdown.close()
await productPage.selectOption("M")
hoodieMediumPrice = getFloatValue(
(await productPage.productPrice.getAttribute("data-value")) || "0"
)
await productPage.clickAddProduct()
await productPage.cartDropdown.close()
})
await test.step("Navigate to another product - Longsleeve", async () => {
await storePage.goto()
const product = await storePage.getProduct("Longsleeve")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
let longsleeveSmallPrice = 0
await test.step("Add the small longsleeve to the cart", async () => {
await productPage.selectOption("S")
longsleeveSmallPrice = getFloatValue(
(await productPage.productPrice.getAttribute("data-value")) || "0"
)
await productPage.clickAddProduct()
await productPage.cartDropdown.close()
await productPage.selectOption("S")
await productPage.clickAddProduct()
await productPage.selectOption("S")
await productPage.clickAddProduct()
await productPage.cartDropdown.goToCartButton.click()
await productPage.cartDropdown.close()
await cartPage.container.waitFor({ state: "visible" })
})
await test.step("Verify the price in the cart is the expected value", async () => {
const total = getFloatValue(
(await cartPage.cartSubtotal.getAttribute("data-value")) || "0"
)
const calculatedTotal =
3 * longsleeveSmallPrice + hoodieSmallPrice + hoodieMediumPrice
expect(compareFloats(total, calculatedTotal)).toBe(0)
})
})
})
================================================
FILE: storefront/e2e/tests/public/checkout.spec.ts
================================================
import { test, expect } from "../../index"
import { compareFloats, getFloatValue } from "../../utils"
test.describe("Checkout flow tests", async () => {
test("Default checkout flow", async ({
cartPage,
checkoutPage,
orderPage,
productPage,
storePage,
}) => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.highlight()
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
})
await test.step("Enter in the first step of the checkout process", async () => {
await test.step("Enter in the shipping address info", async () => {
await checkoutPage.shippingFirstNameInput.fill("First")
await checkoutPage.shippingLastNameInput.fill("Last")
await checkoutPage.shippingCompanyInput.fill("MyCorp")
await checkoutPage.shippingAddressInput.fill("123 Fake street")
await checkoutPage.shippingPostalCodeInput.fill("80010")
await checkoutPage.shippingCityInput.fill("Denver")
await checkoutPage.shippingProvinceInput.fill("Colorado")
await checkoutPage.shippingCountrySelect.selectOption("United States")
})
await test.step("Enter in the contact info and open the billing info form", async () => {
await checkoutPage.shippingEmailInput.fill("test@example.com")
await checkoutPage.shippingPhoneInput.fill("3031112222")
await checkoutPage.billingAddressCheckbox.uncheck()
})
await test.step("Enter in the billing address info", async () => {
await checkoutPage.billingFirstNameInput.fill("First")
await checkoutPage.billingLastNameInput.fill("Last")
await checkoutPage.billingCompanyInput.fill("MyCorp")
await checkoutPage.billingAddressInput.fill("123 Fake street")
await checkoutPage.billingPostalInput.fill("80010")
await checkoutPage.billingCityInput.fill("Denver")
await checkoutPage.billingProvinceInput.fill("Colorado")
await checkoutPage.billingCountrySelect.selectOption("United States")
await checkoutPage.submitAddressButton.click()
})
})
await test.step("Complete the rest of the payment process", async () => {
await checkoutPage.selectDeliveryOption("FakeEx Standard")
await checkoutPage.submitDeliveryOptionButton.click()
await checkoutPage.submitPaymentButton.click()
await checkoutPage.submitOrderButton.click()
await orderPage.container.waitFor({ state: "visible" })
})
await test.step("Verify the products ordered are correct", async () => {
const product = await orderPage.getProduct("Sweatshirt", "M")
await expect(product.name).toContainText("Sweatshirt")
await expect(product.variant).toContainText("M")
await expect(product.quantity).toContainText("1")
})
await test.step("Verify the shipping info is correct", async () => {
const address = orderPage.shippingAddressSummary
await expect(address).toContainText("First")
await expect(address).toContainText("Last")
await expect(address).toContainText("123 Fake street")
await expect(address).toContainText("80010")
await expect(address).toContainText("Denver")
await expect(address).toContainText("US")
const contact = orderPage.shippingContactSummary
await expect(contact).toContainText("test@example.com")
await expect(contact).toContainText("3031112222")
const method = orderPage.shippingMethodSummary
await expect(method).toContainText("FakeEx Standard")
})
})
test("Editing checkout steps works as expected", async ({
cartPage,
checkoutPage,
productPage,
storePage,
}) => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.highlight()
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
})
await test.step("Enter in the first step of the checkout process", async () => {
await test.step("Enter in the shipping address info", async () => {
await checkoutPage.shippingFirstNameInput.fill("First")
await checkoutPage.shippingLastNameInput.fill("Last")
await checkoutPage.shippingCompanyInput.fill("MyCorp")
await checkoutPage.shippingAddressInput.fill("123 Fake street")
await checkoutPage.shippingPostalCodeInput.fill("80010")
await checkoutPage.shippingCityInput.fill("Denver")
await checkoutPage.shippingProvinceInput.fill("Colorado")
await checkoutPage.shippingCountrySelect.selectOption("United States")
})
await test.step("Enter in the contact info and open the billing info form", async () => {
await checkoutPage.shippingEmailInput.fill("test@example.com")
await checkoutPage.shippingPhoneInput.fill("3031112222")
await checkoutPage.billingAddressCheckbox.uncheck()
})
await test.step("Enter in the billing address info", async () => {
await checkoutPage.billingFirstNameInput.fill("First")
await checkoutPage.billingLastNameInput.fill("Last")
await checkoutPage.billingCompanyInput.fill("MyCorp")
await checkoutPage.billingAddressInput.fill("123 Fake street")
await checkoutPage.billingPostalInput.fill("80010")
await checkoutPage.billingCityInput.fill("Denver")
await checkoutPage.billingProvinceInput.fill("Colorado")
await checkoutPage.billingCountrySelect.selectOption("United States")
await checkoutPage.submitAddressButton.click()
})
})
await test.step("Submit the delivery and payment options", async () => {
await checkoutPage.selectDeliveryOption("FakeEx Standard")
await checkoutPage.submitDeliveryOptionButton.click()
await checkoutPage.submitPaymentButton.click()
})
await test.step("Edit the shipping info", async () => {
await checkoutPage.editAddressButton.click()
await test.step("Edit the shipping address", async () => {
await checkoutPage.shippingFirstNameInput.fill("First1")
await checkoutPage.shippingLastNameInput.fill("Last1")
await checkoutPage.shippingCompanyInput.fill("MeCorp")
await checkoutPage.shippingAddressInput.fill("123 Fake Road")
await checkoutPage.shippingPostalCodeInput.fill("80011")
await checkoutPage.shippingCityInput.fill("Donver")
await checkoutPage.shippingProvinceInput.fill("CO")
await checkoutPage.shippingCountrySelect.selectOption("Canada")
})
await test.step("Edit the shipping contact info", async () => {
await checkoutPage.shippingEmailInput.fill("tester@example.com")
await checkoutPage.shippingPhoneInput.fill("3231112222")
})
await test.step("Edit the billing info", async () => {
await checkoutPage.billingFirstNameInput.fill("Farst")
await checkoutPage.billingLastNameInput.fill("List")
await checkoutPage.billingCompanyInput.fill("MistCorp")
await checkoutPage.billingAddressInput.fill("321 Fake street")
await checkoutPage.billingPostalInput.fill("80110")
await checkoutPage.billingCityInput.fill("Denvur")
await checkoutPage.billingProvinceInput.fill("AB")
await checkoutPage.billingCountrySelect.selectOption("Canada")
})
await checkoutPage.submitAddressButton.click()
})
await test.step("Make sure the edits are reflected in the container", async () => {
await test.step("Check shipping address summary", async () => {
const shippingColumn = checkoutPage.shippingAddressSummary
await expect(shippingColumn).toContainText("First1")
await expect(shippingColumn).toContainText("Last1")
await expect(shippingColumn).toContainText("123 Fake Road")
await expect(shippingColumn).toContainText("80011")
await expect(shippingColumn).toContainText("Donver")
await expect(shippingColumn).toContainText("CA")
})
await test.step("Check shipping contact summary", async () => {
const contactColumn = checkoutPage.shippingContactSummary
await expect(contactColumn).toContainText("tester@example.com")
await expect(contactColumn).toContainText("3231112222")
})
await test.step("Check billing summary", async () => {
const billingColumn = checkoutPage.billingAddressSummary
await expect(billingColumn).toContainText("Farst")
await expect(billingColumn).toContainText("List")
await expect(billingColumn).toContainText("321 Fake street")
await expect(billingColumn).toContainText("Denvur")
await expect(billingColumn).toContainText("CA")
})
})
})
test("Shipping info saved is filled back into the forms after clicking edit", async ({
cartPage,
checkoutPage,
productPage,
storePage,
}) => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.highlight()
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
})
await test.step("Enter in the first step of the checkout process", async () => {
await test.step("Enter in the shipping address info", async () => {
await checkoutPage.shippingFirstNameInput.fill("First")
await checkoutPage.shippingLastNameInput.fill("Last")
await checkoutPage.shippingCompanyInput.fill("MyCorp")
await checkoutPage.shippingAddressInput.fill("123 Fake street")
await checkoutPage.shippingPostalCodeInput.fill("80010")
await checkoutPage.shippingCityInput.fill("Denver")
await checkoutPage.shippingProvinceInput.fill("Colorado")
await checkoutPage.shippingCountrySelect.selectOption("United States")
})
await test.step("Enter in the contact info and open the billing info form", async () => {
await checkoutPage.shippingEmailInput.fill("test@example.com")
await checkoutPage.shippingPhoneInput.fill("3031112222")
await checkoutPage.billingAddressCheckbox.uncheck()
})
await test.step("Enter in the billing address info", async () => {
await checkoutPage.billingFirstNameInput.fill("First")
await checkoutPage.billingLastNameInput.fill("Last")
await checkoutPage.billingCompanyInput.fill("MyCorp")
await checkoutPage.billingAddressInput.fill("123 Fake street")
await checkoutPage.billingPostalInput.fill("80010")
await checkoutPage.billingCityInput.fill("Denver")
await checkoutPage.billingProvinceInput.fill("Colorado")
await checkoutPage.billingCountrySelect.selectOption("United States")
await checkoutPage.submitAddressButton.click()
})
})
await test.step("Click the edit address form and ensure the fields are filled correctly", async () => {
await checkoutPage.editAddressButton.click()
await test.step("Check the shipping address", async () => {
await expect(checkoutPage.shippingFirstNameInput).toHaveValue("First")
await expect(checkoutPage.shippingLastNameInput).toHaveValue("Last")
await expect(checkoutPage.shippingCompanyInput).toHaveValue("MyCorp")
await expect(checkoutPage.shippingAddressInput).toHaveValue(
"123 Fake street"
)
await expect(checkoutPage.shippingPostalCodeInput).toHaveValue("80010")
await expect(checkoutPage.shippingCityInput).toHaveValue("Denver")
await expect(checkoutPage.shippingProvinceInput).toHaveValue("Colorado")
await expect(checkoutPage.shippingCountrySelect).toHaveValue("us")
})
await test.step("Check the shipping contact", async () => {
await expect(checkoutPage.shippingEmailInput).toHaveValue(
"test@example.com"
)
await expect(checkoutPage.shippingPhoneInput).toHaveValue("3031112222")
})
await test.step("Check the billing address", async () => {
await expect(checkoutPage.billingFirstNameInput).toHaveValue("First")
await expect(checkoutPage.billingLastNameInput).toHaveValue("Last")
await expect(checkoutPage.billingCompanyInput).toHaveValue("MyCorp")
await expect(checkoutPage.billingAddressInput).toHaveValue(
"123 Fake street"
)
await expect(checkoutPage.billingPostalInput).toHaveValue("80010")
await expect(checkoutPage.billingCityInput).toHaveValue("Denver")
await expect(checkoutPage.billingProvinceInput).toHaveValue("Colorado")
await expect(checkoutPage.billingCountrySelect).toHaveValue("us")
})
})
await test.step("Set the billing info to the same as checked and perform checks", async () => {
await checkoutPage.billingAddressCheckbox.check()
await checkoutPage.submitAddressButton.click()
await checkoutPage.editAddressButton.click()
await expect(checkoutPage.billingAddressCheckbox).toBeChecked()
})
})
test("Shipping info in the checkout page is correctly reflected in the summary", async ({
cartPage,
checkoutPage,
productPage,
storePage,
}) => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.highlight()
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
})
await test.step("Enter in the first step of the checkout process", async () => {
await test.step("Enter in the shipping address info", async () => {
await checkoutPage.shippingFirstNameInput.fill("First")
await checkoutPage.shippingLastNameInput.fill("Last")
await checkoutPage.shippingCompanyInput.fill("MyCorp")
await checkoutPage.shippingAddressInput.fill("123 Fake street")
await checkoutPage.shippingPostalCodeInput.fill("80010")
await checkoutPage.shippingCityInput.fill("Denver")
await checkoutPage.shippingProvinceInput.fill("Colorado")
await checkoutPage.shippingCountrySelect.selectOption("United States")
})
await test.step("Enter in the contact info and open the billing info form", async () => {
await checkoutPage.shippingEmailInput.fill("test@example.com")
await checkoutPage.shippingPhoneInput.fill("3031112222")
await checkoutPage.billingAddressCheckbox.uncheck()
})
await test.step("Enter in the billing address info", async () => {
await checkoutPage.billingFirstNameInput.fill("First")
await checkoutPage.billingLastNameInput.fill("Last")
await checkoutPage.billingCompanyInput.fill("MyCorp")
await checkoutPage.billingAddressInput.fill("123 Fake street")
await checkoutPage.billingPostalInput.fill("80010")
await checkoutPage.billingCityInput.fill("Denver")
await checkoutPage.billingProvinceInput.fill("Colorado")
await checkoutPage.billingCountrySelect.selectOption("United States")
await checkoutPage.submitAddressButton.click()
})
})
await test.step("Ensure the shipping column reflects the entered data", async () => {
const shippingColumn = checkoutPage.shippingAddressSummary
await expect(shippingColumn).toContainText("First")
await expect(shippingColumn).toContainText("Last")
await expect(shippingColumn).toContainText("123 Fake street")
await expect(shippingColumn).toContainText("80010")
await expect(shippingColumn).toContainText("Denver")
await expect(shippingColumn).toContainText("US")
})
await test.step("Ensure the contact column reflects the entered data", async () => {
const contactColumn = checkoutPage.shippingContactSummary
await expect(contactColumn).toContainText("test@example.com")
await expect(contactColumn).toContainText("3031112222")
})
await test.step("Ensure the billing column reflects the entered data", async () => {
const billingColumn = checkoutPage.billingAddressSummary
await expect(billingColumn).toContainText("First")
await expect(billingColumn).toContainText("Last")
await expect(billingColumn).toContainText("123 Fake street")
await expect(billingColumn).toContainText("Denver")
await expect(billingColumn).toContainText("US")
})
await test.step("Edit the billing info so it is the same as the billing address", async () => {
await checkoutPage.editAddressButton.click()
await checkoutPage.billingAddressCheckbox.check()
await checkoutPage.submitAddressButton.click()
const billingColumn = checkoutPage.billingAddressSummary
await expect(billingColumn).toContainText("are the same.")
})
})
test("Entering checkout, leaving, then returning takes you back to the correct checkout spot", async ({
cartPage,
checkoutPage,
productPage,
storePage,
}) => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.highlight()
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
})
await test.step("Navigate away and back to the checkout page", async () => {
await checkoutPage.backToCartLink.click()
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
await expect(checkoutPage.submitAddressButton).toBeVisible()
})
await test.step("Enter in the first step of the checkout process", async () => {
await test.step("Enter in the shipping address info", async () => {
await checkoutPage.shippingFirstNameInput.fill("First")
await checkoutPage.shippingLastNameInput.fill("Last")
await checkoutPage.shippingCompanyInput.fill("MyCorp")
await checkoutPage.shippingAddressInput.fill("123 Fake street")
await checkoutPage.shippingPostalCodeInput.fill("80010")
await checkoutPage.shippingCityInput.fill("Denver")
await checkoutPage.shippingProvinceInput.fill("Colorado")
await checkoutPage.shippingCountrySelect.selectOption("United States")
})
await test.step("Enter in the contact info and open the billing info form", async () => {
await checkoutPage.shippingEmailInput.fill("test@example.com")
await checkoutPage.shippingPhoneInput.fill("3031112222")
await checkoutPage.billingAddressCheckbox.uncheck()
})
await test.step("Enter in the billing address info", async () => {
await checkoutPage.billingFirstNameInput.fill("First")
await checkoutPage.billingLastNameInput.fill("Last")
await checkoutPage.billingCompanyInput.fill("MyCorp")
await checkoutPage.billingAddressInput.fill("123 Fake street")
await checkoutPage.billingPostalInput.fill("80010")
await checkoutPage.billingCityInput.fill("Denver")
await checkoutPage.billingProvinceInput.fill("Colorado")
await checkoutPage.billingCountrySelect.selectOption("United States")
})
await checkoutPage.submitAddressButton.click()
await checkoutPage.deliveryOptionRadio
.first()
.waitFor({ state: "visible" })
})
await test.step("Navigate away and back to the checkout page", async () => {
await checkoutPage.backToCartLink.click()
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
await expect(checkoutPage.submitDeliveryOptionButton).toBeVisible()
})
await test.step("Submit the delivery choice and navigate back and forth", async () => {
await checkoutPage.selectDeliveryOption("FakeEx Standard")
await checkoutPage.submitDeliveryOptionButton.click()
await checkoutPage.submitPaymentButton.waitFor({ state: "visible" })
await checkoutPage.backToCartLink.click()
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
await expect(checkoutPage.submitPaymentButton).toBeVisible()
})
await test.step("Submit the payment info and navigate back and forth", async () => {
await checkoutPage.submitPaymentButton.click()
await checkoutPage.submitOrderButton.waitFor({ state: "visible" })
await checkoutPage.backToCartLink.click()
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
await expect(checkoutPage.submitPaymentButton).toBeVisible()
})
await test.step("Click edit on the shipping info and navigate back and forth", async () => {
await checkoutPage.editAddressButton.click()
await checkoutPage.backToCartLink.click()
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
await expect(checkoutPage.submitPaymentButton).toBeVisible()
})
await test.step("Click edit on the shipping choice and navigate back and forth", async () => {
await checkoutPage.editDeliveryButton.click()
await checkoutPage.backToCartLink.click()
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
await expect(checkoutPage.submitPaymentButton).toBeVisible()
})
})
test("Verify the prices carries over to checkout", async ({
cartPage,
checkoutPage,
productPage,
storePage,
}) => {
await test.step("Navigate to the product page - go to the store page and click on the Sweatshirt product", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
let sweatshirtSmallPrice = 0
let sweatshirtMediumPrice = 0
await test.step("Add the sweatshirts to the cart", async () => {
await productPage.selectOption("S")
sweatshirtSmallPrice = getFloatValue(
(await productPage.productPrice.getAttribute("data-value")) || "0"
)
await productPage.clickAddProduct()
await productPage.cartDropdown.close()
await productPage.selectOption("M")
sweatshirtMediumPrice = getFloatValue(
(await productPage.productPrice.getAttribute("data-value")) || "0"
)
await productPage.clickAddProduct()
await productPage.cartDropdown.close()
})
await test.step("Navigate to another product - Sweatpants", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatpants")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
let sweatpantsSmallPrice = 0
await test.step("Add the small sweatpants to the cart", async () => {
await productPage.selectOption("S")
sweatpantsSmallPrice = getFloatValue(
(await productPage.productPrice.getAttribute("data-value")) || "0"
)
await productPage.clickAddProduct()
await productPage.cartDropdown.close()
await productPage.selectOption("S")
await productPage.clickAddProduct()
await productPage.cartDropdown.goToCartButton.click()
await productPage.cartDropdown.close()
await cartPage.container.waitFor({ state: "visible" })
})
await test.step("Verify the price in the cart is the expected value", async () => {
const total = getFloatValue(
(await cartPage.cartSubtotal.getAttribute("data-value")) || "0"
)
const calculatedTotal =
2 * sweatpantsSmallPrice + sweatshirtSmallPrice + sweatshirtMediumPrice
expect(compareFloats(total, calculatedTotal)).toBe(0)
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
})
await test.step("Go to checkout and verify the price in the checkout is the expected value", async () => {
const total = getFloatValue(
(await checkoutPage.cartSubtotal.getAttribute("data-value")) || "0"
)
const calculatedTotal =
2 * sweatpantsSmallPrice + sweatshirtSmallPrice + sweatpantsSmallPrice
expect(compareFloats(total, calculatedTotal)).toBe(0)
})
})
})
================================================
FILE: storefront/e2e/tests/public/discount.spec.ts
================================================
import { seedDiscount, seedUser } from "../../data/seed"
import { test, expect } from "../../index"
test.describe("Discount tests", async () => {
let discount = {
id: "",
code: "",
rule_id: "",
amount: 0,
}
test.beforeEach(async () => {
discount = await seedDiscount()
})
test("Make sure discount works during transaction", async ({
cartPage,
checkoutPage,
orderPage,
productPage,
storePage,
}) => {
let cartSubtotal = 0
await test.step("Go through purchasing process, upto the cart page", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
cartSubtotal = Number(
(await cartPage.cartTotal.getAttribute("data-value")) || ""
)
})
await test.step("Navigate to the checkout page", async () => {
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
})
})
await test.step("Enter in the discount and assert value works", async () => {
await checkoutPage.discountButton.click()
await expect(checkoutPage.discountInput).toBeVisible()
await checkoutPage.discountInput.fill(discount.code)
await checkoutPage.discountApplyButton.click()
const paymentDiscount = await checkoutPage.getDiscount(discount.code)
await expect(paymentDiscount.locator).toBeVisible()
await expect(paymentDiscount.code).toHaveText(discount.code)
expect(paymentDiscount.amountValue).toBe(discount.amount.toString())
expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe(
(cartSubtotal - discount.amount).toString()
)
})
let shippingTotal = 0
await test.step("Go through checkout process", async () => {
await test.step("Enter in the first step of the checkout process", async () => {
await test.step("Enter in the shipping address info", async () => {
await checkoutPage.shippingFirstNameInput.fill("First")
await checkoutPage.shippingLastNameInput.fill("Last")
await checkoutPage.shippingCompanyInput.fill("MyCorp")
await checkoutPage.shippingAddressInput.fill("123 Fake street")
await checkoutPage.shippingPostalCodeInput.fill("80010")
await checkoutPage.shippingCityInput.fill("Denver")
await checkoutPage.shippingProvinceInput.fill("Colorado")
await checkoutPage.shippingCountrySelect.selectOption("United States")
})
await test.step("Enter in the contact info and open the billing info form", async () => {
await checkoutPage.shippingEmailInput.fill("test@example.com")
await checkoutPage.shippingPhoneInput.fill("3031112222")
await checkoutPage.submitAddressButton.click()
})
})
await test.step("Complete the rest of the payment process", async () => {
await checkoutPage.selectDeliveryOption("FakeEx Standard")
await checkoutPage.submitDeliveryOptionButton.click()
shippingTotal = Number(
(await checkoutPage.cartShipping.getAttribute("data-value")) || "0"
)
await checkoutPage.submitPaymentButton.click()
})
await test.step("Make sure the cart total is the expected value after selecting shipping", async () => {
expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe(
(cartSubtotal - discount.amount + shippingTotal).toString()
)
})
await test.step("Finish completing the order", async () => {
await checkoutPage.submitOrderButton.click()
await orderPage.container.waitFor({ state: "visible" })
})
})
const cartTotal = Number(cartSubtotal) + Number(shippingTotal)
await test.step("Assert the order page shows the total was 0", async () => {
expect(await orderPage.cartTotal.getAttribute("data-value")).toBe(
(cartTotal - discount.amount).toString()
)
expect(await orderPage.cartSubtotal.getAttribute("data-value")).toBe(
cartSubtotal.toString()
)
expect(await orderPage.cartDiscount.getAttribute("data-value")).toBe(
discount.amount.toString()
)
})
})
test("Make sure discount can be used when entered in from cart", async ({
cartPage,
checkoutPage,
orderPage,
productPage,
storePage,
}) => {
let cartSubtotal = 0
await test.step("Go through purchasing process, upto the cart page", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
cartSubtotal = Number(
(await cartPage.cartTotal.getAttribute("data-value")) || ""
)
})
})
await test.step("Enter in the discount and assert value works", async () => {
await cartPage.discountButton.click()
await expect(cartPage.discountInput).toBeVisible()
await cartPage.discountInput.fill(discount.code)
await cartPage.discountApplyButton.click()
const paymentDiscount = await cartPage.getDiscount(discount.code)
await expect(paymentDiscount.locator).toBeVisible()
await expect(paymentDiscount.code).toHaveText(discount.code)
expect(paymentDiscount.amountValue).toBe(discount.amount.toString())
expect(await cartPage.cartTotal.getAttribute("data-value")).toBe(
(cartSubtotal - discount.amount).toString()
)
})
await test.step("Go to checkout and assert the value is still discounted", async () => {
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe(
(cartSubtotal - discount.amount).toString()
)
})
let shippingTotal = 0
await test.step("Go through checkout process", async () => {
await test.step("Enter in the first step of the checkout process", async () => {
await test.step("Enter in the shipping address info", async () => {
await checkoutPage.shippingFirstNameInput.fill("First")
await checkoutPage.shippingLastNameInput.fill("Last")
await checkoutPage.shippingCompanyInput.fill("MyCorp")
await checkoutPage.shippingAddressInput.fill("123 Fake street")
await checkoutPage.shippingPostalCodeInput.fill("80010")
await checkoutPage.shippingCityInput.fill("Denver")
await checkoutPage.shippingProvinceInput.fill("Colorado")
await checkoutPage.shippingCountrySelect.selectOption("United States")
})
await test.step("Enter in the contact info and open the billing info form", async () => {
await checkoutPage.shippingEmailInput.fill("test@example.com")
await checkoutPage.shippingPhoneInput.fill("3031112222")
await checkoutPage.submitAddressButton.click()
})
})
await test.step("Complete the rest of the payment process", async () => {
await checkoutPage.selectDeliveryOption("FakeEx Standard")
await checkoutPage.submitDeliveryOptionButton.click()
shippingTotal = Number(
(await checkoutPage.cartShipping.getAttribute("data-value")) || "0"
)
await checkoutPage.submitPaymentButton.click()
await checkoutPage.submitOrderButton.click()
await orderPage.container.waitFor({ state: "visible" })
})
})
const cartTotal = Number(cartSubtotal) + Number(shippingTotal)
await test.step("Assert the order page shows the total was 0", async () => {
expect(await orderPage.cartTotal.getAttribute("data-value")).toBe(
(cartTotal - discount.amount).toString()
)
expect(await orderPage.cartSubtotal.getAttribute("data-value")).toBe(
cartSubtotal.toString()
)
expect(await orderPage.cartDiscount.getAttribute("data-value")).toBe(
discount.amount.toString()
)
})
})
test("Ensure adding and removing a discout does not impact checkout amount", async ({
cartPage,
checkoutPage,
orderPage,
productPage,
storePage,
}) => {
let cartSubtotal = 0
await test.step("Go through purchasing process, upto the cart page", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
cartSubtotal = Number(
(await cartPage.cartTotal.getAttribute("data-value")) || ""
)
})
})
await test.step("Enter in the discount and assert value works", async () => {
await cartPage.discountButton.click()
await expect(cartPage.discountInput).toBeVisible()
await cartPage.discountInput.fill(discount.code)
await cartPage.discountApplyButton.click()
const paymentDiscount = await cartPage.getDiscount(discount.code)
await expect(paymentDiscount.locator).toBeVisible()
await expect(paymentDiscount.code).toHaveText(discount.code)
expect(paymentDiscount.amountValue).toBe(discount.amount.toString())
expect(await cartPage.cartTotal.getAttribute("data-value")).toBe(
(cartSubtotal - discount.amount).toString()
)
})
await test.step("Go to checkout and assert the value is still discounted", async () => {
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe(
(cartSubtotal - discount.amount).toString()
)
const paymentDiscount = await checkoutPage.getDiscount(discount.code)
await paymentDiscount.removeButton.click()
await expect(paymentDiscount.locator).not.toBeVisible()
expect(await checkoutPage.cartTotal.getAttribute("data-value")).not.toBe(
(cartSubtotal - discount.amount).toString()
)
})
let shippingTotal = ""
await test.step("Go through checkout process", async () => {
await test.step("Enter in the first step of the checkout process", async () => {
await test.step("Enter in the shipping address info", async () => {
await checkoutPage.shippingFirstNameInput.fill("First")
await checkoutPage.shippingLastNameInput.fill("Last")
await checkoutPage.shippingCompanyInput.fill("MyCorp")
await checkoutPage.shippingAddressInput.fill("123 Fake street")
await checkoutPage.shippingPostalCodeInput.fill("80010")
await checkoutPage.shippingCityInput.fill("Denver")
await checkoutPage.shippingProvinceInput.fill("Colorado")
await checkoutPage.shippingCountrySelect.selectOption("United States")
})
await test.step("Enter in the contact info and open the billing info form", async () => {
await checkoutPage.shippingEmailInput.fill("test@example.com")
await checkoutPage.shippingPhoneInput.fill("3031112222")
await checkoutPage.submitAddressButton.click()
})
})
await test.step("Complete the rest of the payment process", async () => {
await checkoutPage.selectDeliveryOption("FakeEx Standard")
await checkoutPage.submitDeliveryOptionButton.click()
shippingTotal =
(await checkoutPage.cartShipping.getAttribute("data-value")) || ""
await checkoutPage.submitPaymentButton.click()
await checkoutPage.submitOrderButton.click()
await orderPage.container.waitFor({ state: "visible" })
})
})
const cartTotal = (Number(cartSubtotal) + Number(shippingTotal)).toString()
await test.step("Assert the order page shows the total was not discounted", async () => {
expect(await orderPage.cartTotal.getAttribute("data-value")).toBe(
cartTotal
)
expect(await orderPage.cartSubtotal.getAttribute("data-value")).toBe(
cartSubtotal.toString()
)
})
})
test("Make sure a fake discount displays an error message on the cart page", async ({
cartPage,
productPage,
storePage,
}) => {
await test.step("Go through purchasing process, upto the cart page", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
})
})
await test.step("Enter in the fake discount", async () => {
await cartPage.discountButton.click()
await expect(cartPage.discountInput).toBeVisible()
await cartPage.discountInput.fill("__FAKE_DISCOUNT_DNE_1111111")
await cartPage.discountApplyButton.click()
await expect(cartPage.discountErrorMessage).toBeVisible()
})
})
test("Make sure a fake discount displays an error message on the checkout page", async ({
cartPage,
checkoutPage,
productPage,
storePage,
}) => {
await test.step("Go through purchasing process, upto the cart page", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.highlight()
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
})
})
await test.step("Enter in the fake discount", async () => {
await checkoutPage.discountButton.click()
await expect(checkoutPage.discountInput).toBeVisible()
await checkoutPage.discountInput.fill("__FAKE_DISCOUNT_DNE_1111111")
await checkoutPage.discountApplyButton.click()
await expect(checkoutPage.discountErrorMessage).toBeVisible()
})
})
test("Adding a discount and then accessing the cart at a later point keeps the discount amount", async ({
cartPage,
checkoutPage,
productPage,
storePage,
}) => {
let cartSubtotal = 0
await test.step("Go through purchasing process, upto the cart page", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
cartSubtotal = Number(
(await cartPage.cartTotal.getAttribute("data-value")) || ""
)
})
})
await test.step("Enter in the giftcard and assert value works", async () => {
await cartPage.discountButton.click()
await cartPage.discountInput.fill(discount.code)
await cartPage.discountApplyButton.click()
const paymentDiscount = await cartPage.getDiscount(discount.code)
expect(paymentDiscount.amountValue).toBe(discount.amount.toString())
expect(await cartPage.cartTotal.getAttribute("data-value")).toBe(
(cartSubtotal - discount.amount).toString()
)
})
await test.step("Navigate away from the cart page and return to it", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatpants")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
await cartPage.goto()
await cartPage.cartDropdown.close()
})
await test.step("Verify the giftcard is still on the cart page", async () => {
const paymentDiscount = await cartPage.getDiscount(discount.code)
await expect(paymentDiscount.locator).toBeVisible()
await expect(paymentDiscount.code).toContainText(discount.code)
expect(await cartPage.cartTotal.getAttribute("data-value")).toBe(
(cartSubtotal - discount.amount).toString()
)
})
await test.step("Verify the giftcard is still on the checkout page", async () => {
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
const paymentDiscount = await checkoutPage.getDiscount(discount.code)
await expect(paymentDiscount.locator).toBeVisible()
await expect(paymentDiscount.code).toContainText(discount.code)
expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe(
(cartSubtotal - discount.amount).toString()
)
expect(paymentDiscount.amountValue).toBe(discount.amount.toString())
})
})
test("Adding a discount and then adding another item to the cart keeps the discount", async ({
cartPage,
checkoutPage,
productPage,
storePage,
}) => {
let cartSubtotal = 0
await test.step("Go through purchasing process, upto the cart page", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
cartSubtotal = Number(
(await cartPage.cartTotal.getAttribute("data-value")) || ""
)
})
})
await test.step("Enter in the giftcard and assert value works", async () => {
await cartPage.discountButton.click()
await cartPage.discountInput.fill(discount.code)
await cartPage.discountApplyButton.click()
const paymentDiscount = await cartPage.getDiscount(discount.code)
expect(paymentDiscount.amountValue).toBe(discount.amount.toString())
expect(await cartPage.cartTotal.getAttribute("data-value")).toBe(
(cartSubtotal - discount.amount).toString()
)
})
await test.step("Navigate away from the cart page and return to it", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatpants")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
await productPage.selectOption("XL")
await productPage.clickAddProduct()
await productPage.cartDropdown.close()
await cartPage.goto()
cartSubtotal = Number(
(await cartPage.cartSubtotal.getAttribute("data-value")) || ""
)
})
await test.step("Verify the giftcard is still on the cart page", async () => {
const paymentDiscount = await cartPage.getDiscount(discount.code)
await expect(paymentDiscount.locator).toBeVisible()
await expect(paymentDiscount.code).toContainText(discount.code)
expect(await cartPage.cartTotal.getAttribute("data-value")).toBe(
(cartSubtotal - discount.amount).toString()
)
})
await test.step("Verify the giftcard is still on the checkout page", async () => {
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
const paymentDiscount = await checkoutPage.getDiscount(discount.code)
await expect(paymentDiscount.locator).toBeVisible()
await expect(paymentDiscount.code).toContainText(discount.code)
expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe(
(cartSubtotal - discount.amount).toString()
)
expect(paymentDiscount.amountValue).toBe(discount.amount.toString())
})
})
})
================================================
FILE: storefront/e2e/tests/public/giftcard.spec.ts
================================================
import { first } from "lodash"
import { seedGiftcard, seedUser } from "../../data/seed"
import { test, expect } from "../../index"
test.describe("Gift card tests", async () => {
let giftcard = {
id: "",
code: "",
value: 0,
amount: "0",
balance: "",
}
test.beforeEach(async () => {
giftcard = await seedGiftcard()
})
test("Make sure giftcard can be used to pay for transaction", async ({
cartPage,
checkoutPage,
orderPage,
productPage,
storePage,
}) => {
let cartSubtotal = ""
await test.step("Go through purchasing process, upto the cart page", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
cartSubtotal =
(await cartPage.cartTotal.getAttribute("data-value")) || ""
})
await test.step("Navigate to the checkout page", async () => {
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
})
})
await test.step("Enter in the giftcard and assert value works", async () => {
await checkoutPage.discountButton.click()
await expect(checkoutPage.discountInput).toBeVisible()
await checkoutPage.discountInput.fill(giftcard.code)
await checkoutPage.discountApplyButton.click()
const paymentGiftcard = await checkoutPage.getGiftCard(giftcard.code)
await expect(paymentGiftcard.locator).toBeVisible()
await expect(paymentGiftcard.code).toHaveText(giftcard.code)
expect(paymentGiftcard.amountValue).toBe(giftcard.amount)
expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe("0")
})
let shippingTotal = ""
await test.step("Go through checkout process", async () => {
await test.step("Enter in the first step of the checkout process", async () => {
await test.step("Enter in the shipping address info", async () => {
await checkoutPage.shippingFirstNameInput.fill("First")
await checkoutPage.shippingLastNameInput.fill("Last")
await checkoutPage.shippingCompanyInput.fill("MyCorp")
await checkoutPage.shippingAddressInput.fill("123 Fake street")
await checkoutPage.shippingPostalCodeInput.fill("80010")
await checkoutPage.shippingCityInput.fill("Denver")
await checkoutPage.shippingProvinceInput.fill("Colorado")
await checkoutPage.shippingCountrySelect.selectOption("United States")
})
await test.step("Enter in the contact info and open the billing info form", async () => {
await checkoutPage.shippingEmailInput.fill("test@example.com")
await checkoutPage.shippingPhoneInput.fill("3031112222")
await checkoutPage.submitAddressButton.click()
})
})
await test.step("Complete the rest of the payment process", async () => {
await checkoutPage.selectDeliveryOption("FakeEx Standard")
await checkoutPage.submitDeliveryOptionButton.click()
shippingTotal =
(await checkoutPage.cartShipping.getAttribute("data-value")) || ""
await checkoutPage.submitPaymentButton.click()
})
await test.step("Make sure the giftcard still has the total as zero after selecting shipping", async () => {
expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe(
"0"
)
})
await test.step("Finish completing the order", async () => {
await checkoutPage.submitOrderButton.click()
await orderPage.container.waitFor({ state: "visible" })
})
})
const cartTotal = (Number(cartSubtotal) + Number(shippingTotal)).toString()
await test.step("Assert the order page shows the total was 0", async () => {
expect(await orderPage.cartTotal.getAttribute("data-value")).toBe("0")
expect(await orderPage.cartSubtotal.getAttribute("data-value")).toBe(
cartSubtotal
)
expect(
await orderPage.cartGiftCardAmount.getAttribute("data-value")
).toBe(cartTotal)
})
})
test("Make sure giftcard can be used when entered in from cart", async ({
cartPage,
checkoutPage,
orderPage,
productPage,
storePage,
}) => {
let cartSubtotal = ""
await test.step("Go through purchasing process, upto the cart page", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
cartSubtotal =
(await cartPage.cartTotal.getAttribute("data-value")) || ""
})
})
await test.step("Enter in the giftcard and assert value works", async () => {
await cartPage.discountButton.click()
await expect(cartPage.discountInput).toBeVisible()
await cartPage.discountInput.fill(giftcard.code)
await cartPage.discountApplyButton.click()
const paymentGiftcard = await cartPage.getGiftCard(giftcard.code)
await expect(paymentGiftcard.locator).toBeVisible()
await expect(paymentGiftcard.code).toHaveText(giftcard.code)
expect(paymentGiftcard.amountValue).toBe(giftcard.amount)
expect(await cartPage.cartTotal.getAttribute("data-value")).toBe("0")
})
await test.step("Go to checkout and assert the value is still 0", async () => {
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe("0")
})
let shippingTotal = ""
await test.step("Go through checkout process", async () => {
await test.step("Enter in the first step of the checkout process", async () => {
await test.step("Enter in the shipping address info", async () => {
await checkoutPage.shippingFirstNameInput.fill("First")
await checkoutPage.shippingLastNameInput.fill("Last")
await checkoutPage.shippingCompanyInput.fill("MyCorp")
await checkoutPage.shippingAddressInput.fill("123 Fake street")
await checkoutPage.shippingPostalCodeInput.fill("80010")
await checkoutPage.shippingCityInput.fill("Denver")
await checkoutPage.shippingProvinceInput.fill("Colorado")
await checkoutPage.shippingCountrySelect.selectOption("United States")
})
await test.step("Enter in the contact info and open the billing info form", async () => {
await checkoutPage.shippingEmailInput.fill("test@example.com")
await checkoutPage.shippingPhoneInput.fill("3031112222")
await checkoutPage.submitAddressButton.click()
})
})
await test.step("Complete the rest of the payment process", async () => {
await checkoutPage.selectDeliveryOption("FakeEx Standard")
await checkoutPage.submitDeliveryOptionButton.click()
shippingTotal =
(await checkoutPage.cartShipping.getAttribute("data-value")) || ""
await checkoutPage.submitPaymentButton.click()
await checkoutPage.submitOrderButton.click()
await orderPage.container.waitFor({ state: "visible" })
})
})
const cartTotal = (Number(cartSubtotal) + Number(shippingTotal)).toString()
await test.step("Assert the order page shows the total was 0", async () => {
expect(await orderPage.cartTotal.getAttribute("data-value")).toBe("0")
expect(await orderPage.cartSubtotal.getAttribute("data-value")).toBe(
cartSubtotal
)
expect(
await orderPage.cartGiftCardAmount.getAttribute("data-value")
).toBe(cartTotal)
})
})
test("Ensure adding and removing a giftcard does not impact checkout amount", async ({
cartPage,
checkoutPage,
orderPage,
productPage,
storePage,
}) => {
let cartSubtotal = ""
await test.step("Go through purchasing process, upto the cart page", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
cartSubtotal =
(await cartPage.cartTotal.getAttribute("data-value")) || ""
})
})
await test.step("Enter in the giftcard and assert value works", async () => {
await cartPage.discountButton.click()
await expect(cartPage.discountInput).toBeVisible()
await cartPage.discountInput.fill(giftcard.code)
await cartPage.discountApplyButton.click()
const paymentGiftcard = await cartPage.getGiftCard(giftcard.code)
await expect(paymentGiftcard.locator).toBeVisible()
await expect(paymentGiftcard.code).toHaveText(giftcard.code)
expect(paymentGiftcard.amountValue).toBe(giftcard.amount)
expect(await cartPage.cartTotal.getAttribute("data-value")).toBe("0")
})
await test.step("Go to checkout and assert the value is still 0", async () => {
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe("0")
const paymentGiftcard = await checkoutPage.getGiftCard(giftcard.code)
await paymentGiftcard.removeButton.click()
await expect(paymentGiftcard.locator).not.toBeVisible()
expect(await checkoutPage.cartTotal.getAttribute("data-value")).not.toBe(
"0"
)
})
let shippingTotal = ""
await test.step("Go through checkout process", async () => {
await test.step("Enter in the first step of the checkout process", async () => {
await test.step("Enter in the shipping address info", async () => {
await checkoutPage.shippingFirstNameInput.fill("First")
await checkoutPage.shippingLastNameInput.fill("Last")
await checkoutPage.shippingCompanyInput.fill("MyCorp")
await checkoutPage.shippingAddressInput.fill("123 Fake street")
await checkoutPage.shippingPostalCodeInput.fill("80010")
await checkoutPage.shippingCityInput.fill("Denver")
await checkoutPage.shippingProvinceInput.fill("Colorado")
await checkoutPage.shippingCountrySelect.selectOption("United States")
})
await test.step("Enter in the contact info and open the billing info form", async () => {
await checkoutPage.shippingEmailInput.fill("test@example.com")
await checkoutPage.shippingPhoneInput.fill("3031112222")
await checkoutPage.submitAddressButton.click()
})
})
await test.step("Complete the rest of the payment process", async () => {
await checkoutPage.selectDeliveryOption("FakeEx Standard")
await checkoutPage.submitDeliveryOptionButton.click()
shippingTotal =
(await checkoutPage.cartShipping.getAttribute("data-value")) || ""
await checkoutPage.submitPaymentButton.click()
await checkoutPage.submitOrderButton.click()
await orderPage.container.waitFor({ state: "visible" })
})
})
const cartTotal = (Number(cartSubtotal) + Number(shippingTotal)).toString()
await test.step("Assert the order page shows the total was 0", async () => {
expect(await orderPage.cartTotal.getAttribute("data-value")).toBe(
cartTotal
)
expect(await orderPage.cartSubtotal.getAttribute("data-value")).toBe(
cartSubtotal
)
})
})
test("Make sure a fake gift card displays an error message on the cart page", async ({
cartPage,
productPage,
storePage,
}) => {
await test.step("Go through purchasing process, upto the cart page", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
})
})
await test.step("Enter in the fake giftcard", async () => {
await cartPage.discountButton.click()
await expect(cartPage.discountInput).toBeVisible()
await cartPage.discountInput.fill("__FAKE_GIFT_CARD_DNE_1111111")
await cartPage.discountApplyButton.click()
await expect(cartPage.discountErrorMessage).toBeVisible()
})
})
test("Make sure a fake gift card displays an error message on the checkout page", async ({
cartPage,
checkoutPage,
productPage,
storePage,
}) => {
await test.step("Go through purchasing process, upto the cart page", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.highlight()
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
})
})
await test.step("Enter in the fake giftcard", async () => {
await checkoutPage.discountButton.click()
await expect(checkoutPage.discountInput).toBeVisible()
await checkoutPage.discountInput.fill("__FAKE_GIFT_CARD_DNE_1111111")
await checkoutPage.discountApplyButton.click()
await expect(checkoutPage.discountErrorMessage).toBeVisible()
})
})
test("Adding a giftcard and then accessing the cart at a later point keeps the giftcard amount", async ({
cartPage,
checkoutPage,
productPage,
storePage,
}) => {
await test.step("Go through purchasing process, upto the cart page", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
})
})
await test.step("Enter in the giftcard and assert value works", async () => {
await cartPage.discountButton.click()
await cartPage.discountInput.fill(giftcard.code)
await cartPage.discountApplyButton.click()
const paymentGiftcard = await cartPage.getGiftCard(giftcard.code)
expect(paymentGiftcard.amountValue).toBe(giftcard.amount)
expect(await cartPage.cartTotal.getAttribute("data-value")).toBe("0")
})
await test.step("Navigate away from the cart page and return to it", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatpants")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
await cartPage.goto()
})
await test.step("Verify the giftcard is still on the cart page", async () => {
const paymentGiftcard = await cartPage.getGiftCard(giftcard.code)
await expect(paymentGiftcard.locator).toBeVisible()
await expect(paymentGiftcard.code).toContainText(giftcard.code)
expect(await cartPage.cartTotal.getAttribute("data-value")).toBe("0")
})
await test.step("Verify the giftcard is still on the checkout page", async () => {
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
const paymentGiftcard = await checkoutPage.getGiftCard(giftcard.code)
await expect(paymentGiftcard.locator).toBeVisible()
await expect(paymentGiftcard.code).toContainText(giftcard.code)
expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe("0")
expect(paymentGiftcard.amountValue).toBe(giftcard.amount)
})
})
test("Adding a giftcard and then adding another item to the cart keeps the giftcard", async ({
cartPage,
checkoutPage,
productPage,
storePage,
}) => {
await test.step("Go through purchasing process, upto the cart page", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
})
})
await test.step("Enter in the giftcard and assert value works", async () => {
await cartPage.discountButton.click()
await cartPage.discountInput.fill(giftcard.code)
await cartPage.discountApplyButton.click()
const paymentGiftcard = await cartPage.getGiftCard(giftcard.code)
expect(paymentGiftcard.amountValue).toBe(giftcard.amount)
expect(await cartPage.cartTotal.getAttribute("data-value")).toBe("0")
})
await test.step("Navigate away from the cart page and return to it", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatpants")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
await productPage.selectOption("XL")
await productPage.clickAddProduct()
await productPage.cartDropdown.close()
await cartPage.goto()
})
await test.step("Verify the giftcard is still on the cart page", async () => {
const paymentGiftcard = await cartPage.getGiftCard(giftcard.code)
await expect(paymentGiftcard.locator).toBeVisible()
await expect(paymentGiftcard.code).toContainText(giftcard.code)
expect(await cartPage.cartTotal.getAttribute("data-value")).toBe("0")
})
await test.step("Verify the giftcard is still on the checkout page", async () => {
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
const paymentGiftcard = await checkoutPage.getGiftCard(giftcard.code)
await expect(paymentGiftcard.locator).toBeVisible()
await expect(paymentGiftcard.code).toContainText(giftcard.code)
expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe("0")
expect(paymentGiftcard.amountValue).toBe(giftcard.amount)
})
})
test("Applying a giftcard, deleting cookies, and then reapplying the giftcard works", async ({
cartPage,
productPage,
storePage,
}) => {
await test.step("Go through purchasing process, upto the cart page", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
})
})
await test.step("Enter in the giftcard and assert value works", async () => {
await cartPage.discountButton.click()
await cartPage.discountInput.fill(giftcard.code)
await cartPage.discountApplyButton.click()
const paymentGiftcard = await cartPage.getGiftCard(giftcard.code)
expect(paymentGiftcard.amountValue).toBe(giftcard.amount)
expect(await cartPage.cartTotal.getAttribute("data-value")).toBe("0")
})
await test.step("Navigate away from the cart page and delete cookies", async () => {
const context = storePage.page.context()
await context.clearCookies()
await storePage.page.reload()
await storePage.goto()
})
await test.step("Recreate the cart", async () => {
await test.step("Navigate to a product page", async () => {
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
})
})
await test.step("Re-enter in the giftcard and assert value works", async () => {
await cartPage.discountButton.click()
await cartPage.discountInput.fill(giftcard.code)
await cartPage.discountApplyButton.click()
const paymentGiftcard = await cartPage.getGiftCard(giftcard.code)
expect(paymentGiftcard.amountValue).toBe(giftcard.amount)
expect(await cartPage.cartTotal.getAttribute("data-value")).toBe("0")
})
})
test("Gift card balance works as expected across transactions", async ({
cartPage,
checkoutPage,
orderPage,
productPage,
storePage,
}) => {
let firstTransactionTotal = 0
await test.step("Complete first transaction using the giftcard", async () => {
let cartSubtotal = ""
await test.step("Go through purchasing process, upto the cart page", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
cartSubtotal =
(await cartPage.cartTotal.getAttribute("data-value")) || ""
})
await test.step("Navigate to the checkout page", async () => {
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
})
})
await test.step("Enter in the giftcard and assert value works", async () => {
await checkoutPage.discountButton.click()
await expect(checkoutPage.discountInput).toBeVisible()
await checkoutPage.discountInput.fill(giftcard.code)
await checkoutPage.discountApplyButton.click()
const paymentGiftcard = await checkoutPage.getGiftCard(giftcard.code)
await expect(paymentGiftcard.locator).toBeVisible()
await expect(paymentGiftcard.code).toHaveText(giftcard.code)
expect(paymentGiftcard.amountValue).toBe(giftcard.amount)
expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe(
"0"
)
})
let shippingTotal = ""
await test.step("Go through checkout process", async () => {
await test.step("Enter in the first step of the checkout process", async () => {
await test.step("Enter in the shipping address info", async () => {
await checkoutPage.shippingFirstNameInput.fill("First")
await checkoutPage.shippingLastNameInput.fill("Last")
await checkoutPage.shippingCompanyInput.fill("MyCorp")
await checkoutPage.shippingAddressInput.fill("123 Fake street")
await checkoutPage.shippingPostalCodeInput.fill("80010")
await checkoutPage.shippingCityInput.fill("Denver")
await checkoutPage.shippingProvinceInput.fill("Colorado")
await checkoutPage.shippingCountrySelect.selectOption(
"United States"
)
})
await test.step("Enter in the contact info and open the billing info form", async () => {
await checkoutPage.shippingEmailInput.fill("test@example.com")
await checkoutPage.shippingPhoneInput.fill("3031112222")
await checkoutPage.submitAddressButton.click()
})
})
await test.step("Complete the rest of the payment process", async () => {
await checkoutPage.selectDeliveryOption("FakeEx Standard")
await checkoutPage.submitDeliveryOptionButton.click()
shippingTotal =
(await checkoutPage.cartShipping.getAttribute("data-value")) || ""
await checkoutPage.submitPaymentButton.click()
})
await test.step("Make sure the giftcard still has the total as zero after selecting shipping", async () => {
expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe(
"0"
)
})
await test.step("Finish completing the order", async () => {
await checkoutPage.submitOrderButton.click()
await orderPage.container.waitFor({ state: "visible" })
})
})
const cartTotal = Number(cartSubtotal) + Number(shippingTotal)
firstTransactionTotal = cartTotal
await test.step("Assert the order page shows the total was 0", async () => {
expect(await orderPage.cartTotal.getAttribute("data-value")).toBe("0")
expect(await orderPage.cartSubtotal.getAttribute("data-value")).toBe(
cartSubtotal
)
expect(
await orderPage.cartGiftCardAmount.getAttribute("data-value")
).toBe(cartTotal.toString())
})
})
await test.step("Setup the second transaction with the same giftcard", async () => {
let cartSubtotal = ""
await test.step("Go through purchasing process, upto the cart page", async () => {
await test.step("Navigate to a product page", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
})
await test.step("Add the product to the cart and goto checkout", async () => {
await productPage.selectOption("M")
await productPage.clickAddProduct()
await productPage.cartDropdown.navCartLink.click()
await productPage.cartDropdown.goToCartButton.click()
await cartPage.container.waitFor({ state: "visible" })
await cartPage.cartDropdown.close()
cartSubtotal =
(await cartPage.cartTotal.getAttribute("data-value")) || ""
})
await test.step("Navigate to the checkout page", async () => {
await cartPage.checkoutButton.click()
await checkoutPage.container.waitFor({ state: "visible" })
})
})
await test.step("Enter in the giftcard and assert value works", async () => {
await checkoutPage.discountButton.click()
await expect(checkoutPage.discountInput).toBeVisible()
await checkoutPage.discountInput.fill(giftcard.code)
await checkoutPage.discountApplyButton.click()
const paymentGiftcard = await checkoutPage.getGiftCard(giftcard.code)
await expect(paymentGiftcard.locator).toBeVisible()
await expect(paymentGiftcard.code).toHaveText(giftcard.code)
expect(paymentGiftcard.amountValue).toBe(
(giftcard.value - firstTransactionTotal).toString()
)
expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe(
"0"
)
})
let shippingTotal = ""
await test.step("Go through checkout process", async () => {
await test.step("Enter in the first step of the checkout process", async () => {
await test.step("Enter in the shipping address info", async () => {
await checkoutPage.shippingFirstNameInput.fill("First")
await checkoutPage.shippingLastNameInput.fill("Last")
await checkoutPage.shippingCompanyInput.fill("MyCorp")
await checkoutPage.shippingAddressInput.fill("123 Fake street")
await checkoutPage.shippingPostalCodeInput.fill("80010")
await checkoutPage.shippingCityInput.fill("Denver")
await checkoutPage.shippingProvinceInput.fill("Colorado")
await checkoutPage.shippingCountrySelect.selectOption(
"United States"
)
})
await test.step("Enter in the contact info and open the billing info form", async () => {
await checkoutPage.shippingEmailInput.fill("test@example.com")
await checkoutPage.shippingPhoneInput.fill("3031112222")
await checkoutPage.submitAddressButton.click()
})
})
await test.step("Complete the rest of the payment process", async () => {
await checkoutPage.selectDeliveryOption("FakeEx Standard")
await checkoutPage.submitDeliveryOptionButton.click()
shippingTotal =
(await checkoutPage.cartShipping.getAttribute("data-value")) || ""
await checkoutPage.submitPaymentButton.click()
})
await test.step("Make sure the giftcard still has the total as zero after selecting shipping", async () => {
expect(await checkoutPage.cartTotal.getAttribute("data-value")).toBe(
"0"
)
})
await test.step("Finish completing the order", async () => {
await checkoutPage.submitOrderButton.click()
await orderPage.container.waitFor({ state: "visible" })
})
})
const cartTotal = (
Number(cartSubtotal) + Number(shippingTotal)
).toString()
await test.step("Assert the order page shows the total was 0", async () => {
expect(await orderPage.cartTotal.getAttribute("data-value")).toBe("0")
expect(await orderPage.cartSubtotal.getAttribute("data-value")).toBe(
cartSubtotal
)
expect(
await orderPage.cartGiftCardAmount.getAttribute("data-value")
).toBe(cartTotal)
})
})
})
})
================================================
FILE: storefront/e2e/tests/public/login.spec.ts
================================================
import { test, expect } from "../../index"
test.describe("Login Page functionality", async () => {
test("access login page from nav menu and submit (partially) empty form", async ({
loginPage,
}) => {
await loginPage.accountLink.click()
await loginPage.container.waitFor({ state: "visible" })
await loginPage.signInButton.click()
await expect(loginPage.emailInput).toBeFocused()
await loginPage.emailInput.fill("test-dne@example.com")
await loginPage.signInButton.click()
await expect(loginPage.passwordInput).toBeFocused()
})
test("enter incorrect creds and verify error message", async ({
loginPage,
}) => {
await loginPage.accountLink.click()
await loginPage.container.waitFor({ state: "visible" })
await loginPage.emailInput.fill("test-dne@example.com")
await loginPage.passwordInput.fill("password")
await loginPage.signInButton.click()
await expect(loginPage.errorMessage).toBeVisible()
})
test("enter different incorrect creds and verify error message", async ({
loginPage,
}) => {
await loginPage.accountLink.click()
await loginPage.container.waitFor({ state: "visible" })
await loginPage.emailInput.fill("test@example.com")
await loginPage.passwordInput.fill("passwrong")
await loginPage.signInButton.click()
await expect(loginPage.errorMessage).toBeVisible()
})
test("successful login redirects to account page", async ({
accountOverviewPage,
loginPage,
}) => {
await loginPage.accountLink.click()
await loginPage.container.waitFor({ state: "visible" })
await loginPage.emailInput.fill("test@example.com")
await loginPage.passwordInput.fill("password")
await loginPage.signInButton.click()
await expect(accountOverviewPage.welcomeMessage).toBeVisible()
})
test("logging out works correctly", async ({
page,
accountOverviewPage,
loginPage,
}) => {
await loginPage.accountLink.click()
await loginPage.container.waitFor({ state: "visible" })
await loginPage.emailInput.fill("test@example.com")
await loginPage.passwordInput.fill("password")
await loginPage.signInButton.click()
await expect(accountOverviewPage.welcomeMessage).toBeVisible()
await accountOverviewPage.logoutLink.highlight()
await accountOverviewPage.logoutLink.click()
await loginPage.container.waitFor({ state: "visible" })
await loginPage.accountLink.click()
await loginPage.container.waitFor({ state: "visible" })
})
})
================================================
FILE: storefront/e2e/tests/public/register.spec.ts
================================================
import { test, expect } from "../../index"
test.describe("User registration functionality", async () => {
test("registration with existing user shows error message", async ({
loginPage,
registerPage,
}) => {
await loginPage.accountLink.click()
await registerPage.container.isVisible()
await loginPage.registerButton.click()
await registerPage.firstNameInput.fill("first")
await registerPage.lastNameInput.fill("last")
await registerPage.emailInput.fill("test@example.com")
await registerPage.passwordInput.fill("password")
await registerPage.registerButton.click()
await expect(registerPage.registerError).toBeVisible()
})
test("registration with empty form data highlights corresponding input", async ({
accountOverviewPage,
loginPage,
registerPage,
}) => {
await loginPage.accountLink.click()
await registerPage.container.isVisible()
await loginPage.registerButton.click()
await registerPage.registerButton.click()
await expect(registerPage.firstNameInput).toBeFocused()
await registerPage.firstNameInput.fill("first")
await registerPage.registerButton.click()
await expect(registerPage.lastNameInput).toBeFocused()
await registerPage.lastNameInput.fill("last")
await registerPage.registerButton.click()
await expect(registerPage.emailInput).toBeFocused()
await registerPage.emailInput.fill("test-reg-new@example.com")
await registerPage.registerButton.click()
await expect(registerPage.passwordInput).toBeFocused()
await registerPage.passwordInput.fill("password")
await registerPage.registerButton.click()
await expect(accountOverviewPage.welcomeMessage).toBeVisible()
})
test("successful registration and navigation to account overview", async ({
loginPage,
registerPage,
accountOverviewPage,
}) => {
await loginPage.accountLink.click()
await registerPage.container.isVisible()
await loginPage.registerButton.click()
await registerPage.firstNameInput.fill("first")
await registerPage.lastNameInput.fill("last")
await registerPage.emailInput.fill("test-reg@example.com")
await registerPage.passwordInput.fill("password")
await registerPage.registerButton.click()
await expect(accountOverviewPage.welcomeMessage).toBeVisible()
})
})
================================================
FILE: storefront/e2e/tests/public/search.spec.ts
================================================
import { test, expect } from "../../index"
test.describe("Search tests", async () => {
test("Searching for a specific product returns the correct product page", async ({
productPage,
}) => {
const searchModal = productPage.searchModal
await searchModal.open()
await searchModal.searchInput.fill("Sweatshirt")
await searchModal.searchResult
.filter({ hasText: "Sweatshirt" })
.first()
.click()
await productPage.container.waitFor({ state: "visible" })
await expect(productPage.productTitle).toContainText("Sweatshirt")
})
test("An erroneous search returns an empty result", async ({
productPage,
}) => {
const searchModal = productPage.searchModal
await searchModal.open()
await searchModal.searchInput.fill("Does Not Sweatshirt")
await expect(searchModal.noSearchResultsContainer).toBeVisible()
})
test("User can search after an empty search result", async ({
productPage,
}) => {
const searchModal = productPage.searchModal
await searchModal.open()
await searchModal.searchInput.fill("Does Not Sweatshirt")
await expect(searchModal.noSearchResultsContainer).toBeVisible()
await searchModal.searchInput.fill("Sweat")
await expect(searchModal.searchResults).toBeVisible()
await expect(searchModal.searchResult.first()).toBeVisible()
})
test("Closing the search page returns user back to their current page", async ({
storePage,
productPage,
loginPage,
}) => {
const searchModal = storePage.searchModal
await test.step("Navigate to the store page and open and close search modal", async () => {
await storePage.goto()
await searchModal.open()
await searchModal.close()
await expect(storePage.container).toBeVisible()
})
await test.step("Navigate to the product page and open and close search modal", async () => {
await storePage.goto()
const product = await storePage.getProduct("Sweatshirt")
await product.locator.click()
await productPage.container.waitFor({ state: "visible" })
await searchModal.open()
await searchModal.close()
await expect(productPage.container).toBeVisible()
})
await test.step("Navigate to the login page and open and close search modal", async () => {
await loginPage.goto()
await searchModal.open()
await searchModal.close()
await expect(loginPage.container).toBeVisible()
})
})
})
================================================
FILE: storefront/e2e/utils/index.ts
================================================
export function getFloatValue(s: string) {
return parseFloat(parseFloat(s).toFixed(2))
}
export function compareFloats(f1: number, f2: number) {
const diff = f1 - f2
if (Math.abs(diff) < 0.01) {
return 0
} else if (diff < 0) {
return -1
} else {
return 1
}
}
================================================
FILE: storefront/e2e/utils/locators.ts
================================================
import { Page, Locator} from '@playwright/test'
export async function getSelectedOptionText(page: Page, select: Locator) {
const handle = await select.elementHandle()
return await page.evaluate(
(opts) => {
if (!opts || !opts[0]) { return "" }
const select = opts[0] as HTMLSelectElement
return select.options[select.selectedIndex].textContent
},
[handle]
)
}
================================================
FILE: storefront/eslint.config.cjs
================================================
const { FlatCompat } = require("@eslint/eslintrc")
const compat = new FlatCompat({
baseDirectory: __dirname,
})
module.exports = [
...compat.config({
extends: ["next/core-web-vitals", "next/typescript", "prettier"],
ignorePatterns: [
// Dependencies
"node_modules/",
// Build and output directories
".next/",
"out/",
// Coverage
"coverage/",
// Static and public assets
"public/",
// Test directories
"e2e/",
"integration-tests/",
// Type definitions
"**/*.d.ts",
// Environment and config files
".env",
".env.local",
".env.*.local",
// Linting cache
".eslintcache",
// Package lock files (yarn only)
"yarn.lock",
],
rules: {
// General best practices
"no-console": [
"warn",
{
allow: ["warn", "error", "info"],
},
],
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-var": "error",
"prefer-const": "error",
"prefer-arrow-callback": "warn",
"object-shorthand": "warn",
eqeqeq: ["error", "always"],
// React best practices
"react/no-unescaped-entities": "warn",
"react/self-closing-comp": "error",
"react/prefer-es6-class": "error",
"react/no-array-index-key": "warn",
"react/no-danger": "warn",
// React hooks
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
// Next.js
"@next/next/no-img-element": "warn",
"@next/next/no-html-link-for-pages": "error",
},
}),
// Override for config files that legitimately use require()
{
files: ["**/*.config.js", "**/*.config.cjs", "check-env-variables.js"],
rules: {
"@typescript-eslint/no-require-imports": "off",
},
},
]
================================================
FILE: storefront/next-env.d.ts
================================================
///
///
///
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
================================================
FILE: storefront/next-sitemap.js
================================================
const excludedPaths = ["/checkout", "/account/*"]
module.exports = {
siteUrl: process.env.NEXT_PUBLIC_VERCEL_URL,
generateRobotsTxt: true,
exclude: excludedPaths + ["/[sitemap]"],
robotsTxtOptions: {
policies: [
{
userAgent: "*",
allow: "/",
},
{
userAgent: "*",
disallow: excludedPaths,
},
],
},
}
================================================
FILE: storefront/next.config.js
================================================
const checkEnvVariables = require("./check-env-variables")
checkEnvVariables()
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
reactStrictMode: true,
experimental: {
staticGenerationRetryCount: 3,
staticGenerationMaxConcurrency: 1,
},
images: {
remotePatterns: [
{
protocol: "http",
hostname: "localhost",
},
{
protocol: "https",
hostname: "fashion-starter-demo.s3.eu-central-1.amazonaws.com",
},
],
},
}
module.exports = nextConfig
================================================
FILE: storefront/package.json
================================================
{
"name": "fashion-starter",
"version": "2.0.0",
"private": true,
"author": "Ante Primorac ",
"description": "Next.js Fashion E-Commerce Starter to be used with Medusa 2.0",
"keywords": [
"medusa-storefront"
],
"scripts": {
"dev": "next dev -p 8000 --turbo",
"build": "next build",
"start": "next start -p 8000",
"lint": "eslint . --ext .js,.jsx,.ts,.tsx",
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix",
"analyze": "ANALYZE=true next build",
"test-e2e": "playwright test e2e"
},
"resolutions": {
"webpack": "^5"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@medusajs/icons": "^2.13.1",
"@medusajs/js-sdk": "2.13.1",
"@medusajs/types": "2.13.1",
"@paypal/paypal-js": "^8.4.2",
"@paypal/react-paypal-js": "^8.9.2",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.7.0",
"@tanstack/react-query": "^5.70.2",
"@vercel/speed-insights": "^1.2.0",
"axios": "^1.13.5",
"embla-carousel-react": "^8.6.0",
"lodash": "^4.17.23",
"meilisearch": "^0.55.0",
"next": "15.5.10",
"pg": "^8.13.3",
"qs": "^6.14.2",
"react": "^19.0.0",
"react-aria-components": "^1.15.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.71.1",
"react-stately": "^3.44.0",
"server-only": "^0.0.1",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-radix": "^4.0.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@types/lodash": "^4.17.16",
"@types/node": "^20",
"@types/pg": "^8.11.11",
"@types/react": "^19",
"@types/react-dom": "^19",
"ansi-colors": "^4.1.3",
"autoprefixer": "^10.4.21",
"eslint": "^9",
"eslint-config-next": "15.2.1",
"eslint-config-prettier": "^10.1.8",
"postcss": "^8.5.3",
"prettier": "^3.8.1",
"tailwindcss": "^3.4.17",
"typescript": "^5",
"webpack": "^5"
},
"packageManager": "yarn@1.22.19"
}
================================================
FILE: storefront/playwright.config.ts
================================================
import { defineConfig, devices } from "@playwright/test"
import path from "path"
import "dotenv/config.js"
export const STORAGE_STATE = path.join(__dirname, "playwright/.auth/user.json")
export default defineConfig({
testDir: "./e2e",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.NEXT_PUBLIC_BASE_URL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "retain-on-failure",
},
/* Configure projects for major browsers */
projects: [
{
name: "setup",
testMatch: /global\/setup\.ts/,
teardown: "cleanup test database",
},
{
name: "public setup",
testMatch: /global\/public-setup\.ts/,
teardown: "cleanup test database",
},
{
name: "cleanup test database",
testMatch: /global\/teardown\.ts/,
},
{
name: "chromium auth",
dependencies: ["setup"],
testIgnore: "public/*.spec.ts",
use: { ...devices["Desktop Chrome"], storageState: STORAGE_STATE },
},
{
name: "chromium public",
dependencies: ["public setup"],
testMatch: "public/*.spec.ts",
use: { ...devices["Desktop Chrome"] },
},
/*
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
*/
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'yarn start',
url: process.env.NEXT_PUBLIC_BASE_URL,
// reuseExistingServer: !process.env.CI,
},
})
================================================
FILE: storefront/postcss.config.js
================================================
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
================================================
FILE: storefront/src/app/[countryCode]/(checkout)/checkout/loading.tsx
================================================
import { Icon } from "@/components/Icon"
export default function Loading() {
return (
)
}
================================================
FILE: storefront/src/app/[countryCode]/(checkout)/checkout/page.tsx
================================================
import React from "react"
import { Metadata } from "next"
import { notFound } from "next/navigation"
import { getCartId } from "@lib/data/cookies"
import { CheckoutForm } from "@modules/checkout/components/checkout-form"
export const metadata: Metadata = {
title: "Checkout",
}
export default async function Checkout({
params,
searchParams,
}: {
params: Promise<{ countryCode: string }>
searchParams: Promise<{ step?: string }>
}) {
const cart = await getCartId()
if (!cart) {
return notFound()
}
const { countryCode } = await params
const { step } = await searchParams
return
}
================================================
FILE: storefront/src/app/[countryCode]/(checkout)/layout.tsx
================================================
import * as React from "react"
import { Layout, LayoutColumn } from "@/components/Layout"
import { LocalizedLink } from "@/components/LocalizedLink"
import dynamic from "next/dynamic"
const CheckoutSummaryWrapper = dynamic(
() => import("@modules/checkout/components/checkout-summary-wrapper"),
{ loading: () => <>> }
)
const MobileCheckoutSummaryWrapper= dynamic(
() => import("@modules/checkout/components/mobile-checkout-summary-wrapper"),
{ loading: () => <>> }
)
export default function CheckoutLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
SofaSocietyCo.
{children}
>
)
}
================================================
FILE: storefront/src/app/[countryCode]/(checkout)/not-found.tsx
================================================
import { Metadata } from "next"
import NotFoundPage from "app/not-found"
export const metadata: Metadata = {
title: "404",
description: "Something went wrong",
}
export default async function NotFound() {
return
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/about/page.tsx
================================================
import { Metadata } from "next"
import Image from "next/image"
import { StoreRegion } from "@medusajs/types"
import { listRegions } from "@lib/data/regions"
import { Layout, LayoutColumn } from "@/components/Layout"
export const metadata: Metadata = {
title: "About",
description: "Learn more about Sofa Society",
}
export async function generateStaticParams() {
const countryCodes = await listRegions().then((regions: StoreRegion[]) =>
regions.flatMap((r) =>
r.countries
? r.countries
.map((c) => c.iso_2)
.filter(
(value): value is string =>
typeof value === "string" && Boolean(value)
)
: []
)
)
const staticParams = countryCodes.map((countryCode) => ({
countryCode,
}))
return staticParams
}
export default function AboutPage() {
return (
<>
At Sofa Society, we believe that a sofa is the heart of every
home.
Welcome to Sofa Society, where we believe that comfort and style
should be effortlessly intertwined. Our mission is to help you
create beautiful, functional spaces that bring warmth and
relaxation into your home.
Every piece in our collection is designed with care, blending
timeless craftsmanship with modern aesthetics to offer you the
perfect balance between form and function.
We are here to make your living space a true reflection of your
personal style.
At the heart of our brand is a deep commitment to quality. We
understand that a sofa isn't just another piece of
furniture; it's where you unwind, gather with loved ones,
and make memories. That's why we source only the finest
materials and fabrics, ensuring that every sofa we offer is
built to last.
From luxurious leathers and soft linens to high-performance
textiles, each fabric is carefully selected for its durability
and beauty. Our attention to detail extends to every stitch and
seam, guaranteeing that your sofa will not only look stunning
but will also withstand the test of time.
Our design philosophy revolves around creating pieces that are
both beautiful and practical. Inspired by Scandinavian
simplicity, modern luxury, and timeless classics, our
collections are curated to suit a wide variety of tastes and
lifestyles. We understand that every home is different, so we
offer a diverse range of styles, colors, and textures to help
you find the perfect fit. Whether you prefer sleek modern lines
or soft, inviting silhouettes, we have something to suit every
space and personality.
We believe that great design should be environmentally
conscious, which is why we strive to minimise our environmental
footprint through responsible sourcing and production practices.
Our commitment to sustainability ensures that our products are
not only beautiful but also kind to the planet.
Our customers are at the center of everything we do!
Our team is here to help guide you through the process, offering
personalised support to ensure that you find exactly what
you're looking for.
We're not just selling sofas - we're helping you
create spaces where you can relax, recharge, and make lasting
memories. Thank you for choosing Sofa Society to be a part of
your home!
>
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/account/layout.tsx
================================================
import * as React from "react"
import { SignOutButton } from "@modules/account/components/SignOutButton"
import { SidebarNav } from "@modules/account/components/SidebarNav"
export default function AccountLayout(props: { children: React.ReactNode }) {
return (
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/account/loading.tsx
================================================
import SkeletonAccountPage from "@modules/skeletons/templates/skeleton-account-page"
export default function Loading() {
return
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/account/my-orders/[orderId]/page.tsx
================================================
import * as React from "react"
import { Metadata } from "next"
import Image from "next/image"
import { HttpTypes } from "@medusajs/types"
import { convertToLocale } from "@lib/util/money"
import { retrieveOrder } from "@lib/data/orders"
import { OrderTotals } from "@modules/order/components/OrderTotals"
import { UiTag } from "@/components/ui/Tag"
import { UiTagList, UiTagListDivider } from "@/components/ui/TagList"
import { Icon } from "@/components/Icon"
import { LocalizedLink } from "@/components/LocalizedLink"
import { getCustomer } from "@lib/data/customer"
import { redirect } from "next/navigation"
export const metadata: Metadata = {
title: "Account - Order",
description: "Check your order history",
}
const OrderStatus: React.FC<{ order: HttpTypes.StoreOrder }> = ({ order }) => {
if (order.fulfillment_status === "canceled") {
return (
Canceled
)
}
if (order.fulfillment_status === "delivered") {
return (
Packing
Delivering
Delivered
)
}
if (
order.fulfillment_status === "shipped" ||
order.fulfillment_status === "partially_delivered"
) {
return (
Packing
Delivering
Delivered
)
}
return (
Packing
Delivering
Delivered
)
}
export default async function AccountOrderPage({
params,
}: {
params: Promise<{ orderId: string }>
}) {
const customer = await getCustomer().catch(() => null)
if (!customer) {
redirect(`/`)
}
const { orderId } = await params
const order = await retrieveOrder(orderId)
return (
<>
Order: {order.display_id}
{new Date(order.created_at).toLocaleDateString()}
{[
order.shipping_address?.first_name,
order.shipping_address?.last_name,
]
.filter(Boolean)
.join(" ")}
{Boolean(order.shipping_address?.company) && (
{order.shipping_address?.company}
)}
{[
order.shipping_address?.address_1,
order.shipping_address?.address_2,
[
order.shipping_address?.postal_code,
order.shipping_address?.city,
]
.filter(Boolean)
.join(" "),
order.shipping_address?.country?.display_name,
]
.filter(Boolean)
.join(", ")}
{Boolean(order.shipping_address?.phone) && (
{order.shipping_address?.phone}
)}
{[
order.billing_address?.first_name,
order.billing_address?.last_name,
]
.filter(Boolean)
.join(" ")}
{Boolean(order.billing_address?.company) && (
{order.billing_address?.company}
)}
{[
order.billing_address?.address_1,
order.billing_address?.address_2,
[
order.billing_address?.postal_code,
order.billing_address?.city,
]
.filter(Boolean)
.join(" "),
order.billing_address?.country?.display_name,
]
.filter(Boolean)
.join(", ")}
{Boolean(order.billing_address?.phone) && (
{order.billing_address?.phone}
)}
{order.items?.map((item) => (
{item.thumbnail && (
)}
{item.product_title}
{item.variant?.options?.map((option) => (
{option.option?.title}:
{option.value}
))}
Quantity:
{item.quantity}
{convertToLocale({
currency_code: order.currency_code,
amount: item.total,
})}
))}
>
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/account/my-orders/page.tsx
================================================
import * as React from "react"
import { Metadata } from "next"
import Image from "next/image"
import { HttpTypes } from "@medusajs/types"
import { twMerge } from "tailwind-merge"
import { listOrders } from "@lib/data/orders"
import { Pagination } from "@modules/store/components/pagination"
import { ButtonLink } from "@/components/Button"
import { UiTag } from "@/components/ui/Tag"
import { LocalizedLink } from "@/components/LocalizedLink"
import { getCustomer } from "@lib/data/customer"
import { redirect } from "next/navigation"
export const metadata: Metadata = {
title: "Account - Orders",
description: "Check your order history",
}
const OrderStatus: React.FC<{
order: HttpTypes.StoreOrder
className?: string
}> = ({ order, className }) => {
if (order.fulfillment_status === "canceled") {
return (
Canceled
)
}
if (order.fulfillment_status === "delivered") {
return (
Delivered
)
}
if (
order.fulfillment_status === "shipped" ||
order.fulfillment_status === "partially_delivered"
) {
return (
Delivering
)
}
return (
Packing
)
}
type PageProps = {
searchParams: Promise<{
page?: string
}>
}
const ORDERS_PER_PAGE = 6
export default async function AccountMyOrdersPage({ searchParams }: PageProps) {
const { page } = await searchParams
const customer = await getCustomer().catch(() => null)
if (!customer) {
redirect(`/`)
}
const pageNumber = page ? parseInt(page, 10) : 1
const { orders, count } = await listOrders(
ORDERS_PER_PAGE,
(pageNumber - 1) * ORDERS_PER_PAGE
)
const totalPages = Math.ceil(count / ORDERS_PER_PAGE)
return (
<>
My orders
{orders.length > 0 ? (
{orders.map((order) => (
Order: {" "}
{order.display_id}
Order date:{" "}
{new Date(order.created_at).toLocaleDateString()}
{order.items
?.filter((item) => item.thumbnail)
.map((item) => (
))}
Check details
Check details
))}
{totalPages > 1 && (
)}
) : (
You haven't ordered anything yet
)}
>
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/account/page.tsx
================================================
import { Metadata } from "next"
import { redirect } from "next/navigation"
import { getCustomer } from "@lib/data/customer"
import { getRegion, listRegions } from "@lib/data/regions"
import { UpsertAddressForm } from "@modules/account/components/UpsertAddressForm"
import { PersonalInfoForm } from "@modules/account/components/PersonalInfoForm"
import { SignOutButton } from "@modules/account/components/SignOutButton"
import { Icon } from "@/components/Icon"
import { Button } from "@/components/Button"
import { UiModal, UiModalOverlay } from "@/components/ui/Modal"
import { UiDialog, UiDialogTrigger } from "@/components/Dialog"
import { RequestPasswordResetButton } from "@modules/account/components/RequestPasswordResetButton"
import { AddressSingle } from "@modules/account/components/AddressSingle"
import { AddressMultiple } from "@modules/account/components/AddressMultiple"
import { DefaultShippingAddressSelect } from "@modules/account/components/DefaultShippingAddressSelect"
import { DefaultBillingAddressSelect } from "@modules/account/components/DefaultBillingAddressSelect"
import { UiRadioGroup } from "@/components/ui/Radio"
export const metadata: Metadata = {
title: "Account - Personal & security",
description: "Manage your personal information and security settings",
}
export default async function AccountPersonalAndSecurityPage({
params,
}: {
params: Promise<{ countryCode: string }>
}) {
const { countryCode } = await params
const customer = await getCustomer().catch(() => null)
if (!customer) {
redirect(`/${countryCode}/auth/login`)
}
const [region, regions] = await Promise.all([
getRegion(countryCode),
listRegions(),
])
const countries = regions.flatMap((region) => region.countries ?? [])
return (
<>
Personal & security
Personal information
Name
{[customer.first_name, customer.last_name]
.filter(Boolean)
.join(" ")}
Number
{customer.phone || "-"}
Change
Contact
If you want to change your email please contact us via customer support.
{customer.addresses.length > 1 ? "Addresses" : "Address"}
{customer.addresses.length === 0 && (
You don't have any addresses saved yet.
)}
{customer.addresses.length === 1 &&
customer.addresses.map((address) => (
))}
{customer.addresses.length > 1 && (
<>
{customer.addresses
.sort(
(a, b) =>
new Date(b.created_at).getTime() -
new Date(a.created_at).getTime()
)
.map((address) => (
))}
>
)}
{customer.addresses.length > 0 ? (
Add another address
) : (
Add address
)}
Change password
To change your password, we'll send you an email. Just click on the
reset button below.
>
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/auth/forgot-password/page.tsx
================================================
import { Metadata } from "next"
import Image from "next/image"
import { ForgotPasswordForm } from "@modules/auth/components/ForgotPasswordForm"
export const metadata: Metadata = {
title: "Forgot password",
description: "Reset your password",
}
export default function ForgotPasswordPage() {
return (
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/auth/forgot-password/reset/page.tsx
================================================
import { Metadata } from "next"
import { notFound } from "next/navigation"
import { ChangePasswordForm } from "@modules/auth/components/ResetPasswordForm"
import { Layout, LayoutColumn } from "@/components/Layout"
export const metadata: Metadata = {
title: "Reset password",
description: "Reset your password",
}
export default async function ResetPasswordPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
const { email, token } = await searchParams
if (
typeof email !== "string" ||
typeof token !== "string" ||
!email ||
!token
) {
notFound()
}
return (
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/auth/login/loading.tsx
================================================
import Image from "next/image"
import { LocalizedLink } from "@/components/LocalizedLink"
import { Button } from "@/components/Button"
import { Input } from "@/components/Forms"
export default async function LoginLoadingPage() {
return (
Welcome back to Sofa Society!
Don't have an account yet? You can{" "}
register here
.
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/auth/login/page.tsx
================================================
import { Metadata } from "next"
import Image from "next/image"
import { redirect } from "next/navigation"
import { getCustomer } from "@lib/data/customer"
import { LoginForm } from "@modules/auth/components/LoginForm"
import { LocalizedLink } from "@/components/LocalizedLink"
export const metadata: Metadata = {
title: "Login",
description: "Login to your account",
}
export default async function LoginPage({
params,
}: {
params: Promise<{ countryCode: string }>
}) {
const { countryCode } = await params
const customer = await getCustomer().catch(() => null)
if (customer) {
redirect(`/${countryCode}/account`)
}
return (
Welcome back to Sofa Society!
Don't have an account yet? You can{" "}
register here
.
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/auth/register/loading.tsx
================================================
import Image from "next/image"
import { LocalizedLink } from "@/components/LocalizedLink"
import { Input } from "@/components/Forms"
import { SubmitButton } from "@modules/common/components/submit-button"
export default function RegisterLoadingPage() {
return (
Hey, welcome to Sofa Society!
Already have an account? No worries, just{" "}
log in
.
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/auth/register/page.tsx
================================================
import { Metadata } from "next"
import Image from "next/image"
import { redirect } from "next/navigation"
import { getCustomer } from "@lib/data/customer"
import { SignUpForm } from "@modules/auth/components/SignUpForm"
import { LocalizedLink } from "@/components/LocalizedLink"
export const metadata: Metadata = {
title: "Register",
description: "Create an account",
}
export default async function RegisterPage({
params,
}: {
params: Promise<{ countryCode: string }>
}) {
const customer = await getCustomer().catch(() => null)
if (customer) {
redirect(`/${(await params).countryCode}/account`)
}
return (
Hey, welcome to Sofa Society!
Already have an account? No worries, just{" "}
log in
.
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/auth/reset-password/page.tsx
================================================
import { Metadata } from "next"
import { notFound } from "next/navigation"
import { ChangePasswordForm } from "@modules/auth/components/ResetPasswordForm"
import { Layout, LayoutColumn } from "@/components/Layout"
export const metadata: Metadata = {
title: "Reset password",
description: "Reset your password",
}
export default async function ResetPasswordPage({
searchParams,
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
const { email, token } = await searchParams
if (
typeof email !== "string" ||
typeof token !== "string" ||
!email ||
!token
) {
notFound()
}
return (
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/cart/loading.tsx
================================================
import SkeletonCartPage from "@modules/skeletons/templates/skeleton-cart-page"
export default function Loading() {
return
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/cart/not-found.tsx
================================================
import { Metadata } from "next"
import NotFoundPage from "app/not-found"
export const metadata: Metadata = {
title: "404",
description: "Something went wrong",
}
export default function NotFound() {
return
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/cart/page.tsx
================================================
import { Metadata } from "next"
import CartTemplate from "@modules/cart/templates"
export const metadata: Metadata = {
title: "Cart",
description: "View your cart",
}
export default function Cart() {
return
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/collections/[handle]/page.tsx
================================================
import { Metadata } from "next"
import { notFound } from "next/navigation"
import {
getCollectionByHandle,
getCollectionsList,
} from "@lib/data/collections"
import { listRegions } from "@lib/data/regions"
import { StoreCollection, StoreRegion } from "@medusajs/types"
import CollectionTemplate from "@modules/collections/templates"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
import { collectionMetadataCustomFieldsSchema } from "@lib/util/collections"
type Props = {
params: Promise<{ handle: string; countryCode: string }>
searchParams: Promise<{
category?: string | string[]
type?: string | string[]
page?: string
sortBy?: SortOptions
}>
}
export async function generateStaticParams() {
const { collections } = await getCollectionsList()
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({ params }: Props): Promise {
const { handle } = await params
const collection = await getCollectionByHandle(handle, [
"id",
"title",
"metadata",
])
if (!collection) {
notFound()
}
const collectionDetails = collectionMetadataCustomFieldsSchema.safeParse(
collection.metadata ?? {}
)
const metadata = {
title: `${collection.title} | Medusa Store`,
description:
collectionDetails.success && collectionDetails.data.description
? collectionDetails.data.description
: `${collection.title} collection`,
} as Metadata
return metadata
}
export default async function CollectionPage({ params, searchParams }: Props) {
const { handle, countryCode } = await params
const { sortBy, page, category, type } = await searchParams
const collection = await getCollectionByHandle(handle, [
"id",
"title",
"metadata",
])
if (!collection) {
notFound()
}
return (
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/cookie-policy/page.tsx
================================================
import { Metadata } from "next"
import { StoreRegion } from "@medusajs/types"
import { Layout, LayoutColumn } from "@/components/Layout"
import { listRegions } from "@lib/data/regions"
export const metadata: Metadata = {
title: "Cookie Policy",
}
export async function generateStaticParams() {
const countryCodes = await listRegions().then((regions: StoreRegion[]) =>
regions.flatMap((r) =>
r.countries
? r.countries
.map((c) => c.iso_2)
.filter(
(value): value is string =>
typeof value === "string" && Boolean(value)
)
: []
)
)
const staticParams = countryCodes.map((countryCode) => ({
countryCode,
}))
return staticParams
}
export default function CookiePolicyPage() {
return (
Cookie Policy for Sofa Society
This Cookie Policy explains how Sofa Society uses cookies and similar
technologies on our website. By using our website, you consent to the
use of cookies as described in this policy.
1. What Are Cookies:
Cookies are small text files that are placed on your computer or
device when you visit a website. They are widely used to make websites
work more efficiently and provide a better browsing experience.
Cookies also enable website owners to collect certain information
about visitors.
2. Types of Cookies We Use:
We use the following types of cookies on our website:
Essential Cookies: These cookies are necessary for the operation of
our website and enable you to navigate and use its features. They
are typically set in response to your actions, such as setting your
privacy preferences, logging in, or filling out forms.
Analytics and Performance Cookies: These cookies help us understand
how visitors interact with our website by collecting information
such as the number of visitors, pages visited, and sources of
traffic. This data helps us improve our website's performance
and usability.
Functionality Cookies: These cookies allow our website to remember
choices you make (such as language preferences) and provide enhanced
features. They may also be used to provide personalized content
based on your browsing history.
Advertising and Targeting Cookies: These cookies are used to deliver
advertisements that are relevant to your interests. They may also be
used to limit the number of times you see an advertisement and
measure the effectiveness of advertising campaigns.
3. Third-Party Cookies:
We may allow third-party service providers, such as analytics and
advertising companies, to place cookies on our website. These third
parties may collect information about your online activities over time
and across different websites.
4. Cookie Management:
You can manage and control cookies through your browser settings. Most
web browsers allow you to block or delete cookies. However, please
note that blocking or deleting certain cookies may impact the
functionality and user experience of our website.
For more information on how to manage cookies, you can visit the help
or settings section of your browser.
5. Updates to the Cookie Policy:
We may update this Cookie Policy from time to time to reflect changes
in our use of cookies or for other operational, legal, or regulatory
reasons. We will notify you of any material changes by posting a
prominent notice on our website.
6. Contact Us:
If you have any questions, concerns, or requests regarding this
Privacy Policy or how we handle your personal information, please
contact us at:
Email: privacy@sofasociety.com
Address: Skärgårdsvägen 12, 124 55 Stockholm
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/inspiration/page.tsx
================================================
import { Metadata } from "next"
import Image from "next/image"
import { StoreRegion } from "@medusajs/types"
import { listRegions } from "@lib/data/regions"
import { Layout, LayoutColumn } from "@/components/Layout"
import { LocalizedLink } from "@/components/LocalizedLink"
import { CollectionsSection } from "@/components/CollectionsSection"
export const metadata: Metadata = {
title: "Inspiration",
description: "Get inspired by our latest collections",
}
export async function generateStaticParams() {
const countryCodes = await listRegions().then((regions: StoreRegion[]) =>
regions.flatMap((r) =>
r.countries
? r.countries
.map((c) => c.iso_2)
.filter(
(value): value is string =>
typeof value === "string" && Boolean(value)
)
: []
)
)
const staticParams = countryCodes.map((countryCode) => ({
countryCode,
}))
return staticParams
}
export default function InspirationPage() {
return (
<>
The Astrid Curve sofa is a masterpiece of minimalism and luxury.
Our design philosophy revolves around creating pieces that are
both beautiful and practical. Inspired by Scandinavian
simplicity, modern luxury, and timeless classics.
Astrid Curve
Scandinavian Simplicity
Haven Sofas have minimalistic designs, neutral colors, and
high-quality textures.
Perfect for those who seek comfort with a clean and understated
aesthetic. This collection brings the essence of Scandinavian
elegance to your living room.
Nordic Haven
Scandinavian Simplicity
Nordic Breeze
Scandinavian Simplicity
Oslo Drift is infused with playful textures and vibrant patterns
with eclectic vibes.
Whether you're looking for bold statement pieces or subtle
elegance, this collection elevates your home with a touch of
glamour, sophistication, and unmatched coziness.
Oslo Drift
Scandinavian Simplicity
>
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/layout.tsx
================================================
import { Metadata } from "next"
import { getBaseURL } from "@lib/util/env"
import { Header } from "@/components/Header"
import { Footer } from "@/components/Footer"
export const metadata: Metadata = {
metadataBase: new URL(getBaseURL()),
}
export default async function PageLayout(props: { children: React.ReactNode }) {
return (
<>
{props.children}
>
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/not-found.tsx
================================================
import { Metadata } from "next"
import NotFoundPage from "app/not-found"
export const metadata: Metadata = {
title: "404",
description: "Something went wrong",
}
export default function NotFound() {
return
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/order/confirmed/[id]/loading.tsx
================================================
import SkeletonOrderConfirmed from "@modules/skeletons/templates/skeleton-order-confirmed"
export default function Loading() {
return
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/order/confirmed/[id]/page.tsx
================================================
import { Metadata } from "next"
import { notFound } from "next/navigation"
import OrderCompletedTemplate from "@modules/order/templates/order-completed-template"
import { retrieveOrder } from "@lib/data/orders"
type Props = {
params: Promise<{ id: string }>
}
export const metadata: Metadata = {
title: "Order Confirmed",
description: "You purchase was successful",
}
export default async function OrderConfirmedPage({ params }: Props) {
const { id } = await params
const order = await retrieveOrder(id)
if (!order) {
return notFound()
}
return
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/page.tsx
================================================
import { Metadata } from "next"
import Image from "next/image"
import { getRegion } from "@lib/data/regions"
import { getProductTypesList } from "@lib/data/product-types"
import { Layout, LayoutColumn } from "@/components/Layout"
import { LocalizedLink } from "@/components/LocalizedLink"
import { CollectionsSection } from "@/components/CollectionsSection"
export const metadata: Metadata = {
title: "Medusa Next.js Starter Template",
description:
"A performant frontend ecommerce starter template with Next.js 14 and Medusa.",
}
const ProductTypesSection: React.FC = async () => {
const productTypes = await getProductTypesList(0, 20, [
"id",
"value",
"metadata",
])
if (!productTypes) {
return null
}
return (
Our products
{productTypes.productTypes.map((productType, index) => (
{typeof productType.metadata?.image === "object" &&
productType.metadata.image &&
"url" in productType.metadata.image &&
typeof productType.metadata.image.url === "string" && (
)}
{productType.value}
))}
)
}
export default async function Home({
params,
}: {
params: Promise<{ countryCode: string }>
}) {
const { countryCode } = await params
const region = await getRegion(countryCode)
if (!region) {
return null
}
return (
<>
Elevate Your Living Space with Unmatched Comfort & Style
Discover Your Perfect Sofa Today
Explore Now
About Sofa Society
At Sofa Society, we believe that a sofa is the heart of every
home.
We are dedicated to delivering high-quality, thoughtfully
designed sofas that merge comfort and style effortlessly.
Our mission is to transform your living space into a sanctuary
of relaxation and beauty, with products built to last.
Read more about Sofa Society
>
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/privacy-policy/page.tsx
================================================
import { Metadata } from "next"
import { StoreRegion } from "@medusajs/types"
import { listRegions } from "@lib/data/regions"
import { Layout, LayoutColumn } from "@/components/Layout"
export const metadata: Metadata = {
title: "Privacy Policy",
description: "Learn how we protect your privacy",
}
export async function generateStaticParams() {
const countryCodes = await listRegions().then((regions: StoreRegion[]) =>
regions.flatMap((r) =>
r.countries
? r.countries
.map((c) => c.iso_2)
.filter(
(value): value is string =>
typeof value === "string" && Boolean(value)
)
: []
)
)
const staticParams = countryCodes.map((countryCode) => ({
countryCode,
}))
return staticParams
}
export default function PrivacyPolicyPage() {
return (
Privacy Policy for Sofa Society
At Sofa Society, we value your privacy and are committed to protecting
your personal information. This Privacy Policy outlines how we
collect, use, disclose, and safeguard your data when you interact with
our website, services, and products. By using our platform, you
consent to the practices described in this policy.
1. Information We Collect:
We may collect personal information you provide directly to us, such
as:
Name, email address, and contact details when you sign up for an
account.
Billing and shipping addresses when you make a purchase.
Payment information (credit/debit card details) for completing
transactions securely.
Personal preferences and fashion interests you share with us.
Additionally, we may automatically collect certain information when
you access or use our website, including:
IP address, browser type, operating system, and device information.
Usage data, such as pages visited, time spent on our platform, and
referring website.
2. How We Use Your Information:
We may use your personal information for various purposes, including
but not limited to:
Providing and managing your account, purchases, and orders.
Customizing your shopping experience and suggesting relevant
products.
Sending you updates, newsletters, and marketing communications (you
can opt-out anytime).
Analyzing user behavior to improve our website and services.
Complying with legal obligations and enforcing our Terms of Service.
3. Cookies and Similar Technologies:
We use cookies and similar technologies to collect information about
your browsing activity on our website. These technologies help us
analyze usage patterns and enhance user experience. You can manage
your cookie preferences through your browser settings.
4. Data Sharing and Disclosure:
We may share your personal information with third parties under
certain circumstances, including:
Service providers who assist us in operating our business and
delivering services.
Legal authorities or government agencies as required by law.
We do not sell or rent your personal information to third parties for
their marketing purposes.
5. Data Security:
We implement reasonable security measures to protect your personal
information from unauthorized access, alteration, or disclosure.
However, no method of transmission over the internet or electronic
storage is completely secure.
6. Your Choices:
You have the right to:
Review and update your personal information in your account
settings.
Opt-out of receiving marketing communications.
Delete your account (subject to applicable laws and regulations).
7. Children's Privacy:
Our services are not intended for individuals under the age of 16. If
we become aware that we have collected personal information from
children without parental consent, we will take prompt action to
delete such data.
8. Changes to this Privacy Policy:
We may update this Privacy Policy from time to time to reflect changes
in our practices or for other operational, legal, or regulatory
reasons. We will notify you of any material changes via email or by
prominently posting a notice on our website.
9. Contact Us:
If you have any questions, concerns, or requests regarding this
Privacy Policy or how we handle your personal information, please
contact us at:
Email: privacy@sofasociety.com
Address: Skärgårdsvägen 12, 124 55 Stockholm
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/products/[handle]/page.tsx
================================================
import { Metadata } from "next"
import { notFound } from "next/navigation"
import { sdk } from "@lib/config"
import { getRegion, listRegions } from "@lib/data/regions"
import {
getProductByHandle,
getProductFashionDataByHandle,
} from "@lib/data/products"
import ProductTemplate from "@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()
.filter(Boolean) as string[]
)
if (!countryCodes) {
return []
}
const { products } = await sdk.store.product.list(
{ fields: "handle" },
{ next: { tags: ["products"] } }
)
const staticParams = countryCodes
?.map((countryCode) =>
products.map((product) => ({
countryCode,
handle: product.handle,
}))
)
.flat()
.filter((product) => product.handle)
return staticParams
} catch (error) {
console.error(
`Failed to generate static paths for product pages: ${
error instanceof Error ? error.message : "Unknown error"
}.`
)
return []
}
}
export async function generateMetadata({ params }: Props): Promise {
const { handle, countryCode } = await params
const region = await getRegion(countryCode)
if (!region) {
notFound()
}
const product = await getProductByHandle(handle, region.id)
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({ params }: Props) {
const { handle, countryCode } = await params
const region = await getRegion(countryCode)
if (!region) {
notFound()
}
const [pricedProduct, fashionData] = await Promise.all([
getProductByHandle(handle, region.id),
getProductFashionDataByHandle(handle),
])
if (!pricedProduct) {
notFound()
}
return (
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/search/page.tsx
================================================
import { Metadata } from "next"
import { Layout, LayoutColumn } from "@/components/Layout"
import { Suspense } from "react"
import SkeletonProductGrid from "@modules/skeletons/templates/skeleton-product-grid"
import PaginatedProducts from "@modules/store/templates/paginated-products"
import { CollectionsSlider } from "@modules/store/components/collections-slider"
import { MeiliSearchProductHit, searchClient } from "@lib/search-client"
import { getRegion } from "@lib/data/regions"
type Props = {
params: Promise<{ countryCode: string }>
searchParams: Promise<{ query: string; page: string }>
}
export const metadata: Metadata = {
title: "Search",
description: "Search for products",
}
export default async function SearchPage({ params, searchParams }: Props) {
const { countryCode } = await params
const { query, page } = await searchParams
const pageNumber = page ? parseInt(page, 10) : 1
const results = await searchClient
.index("products")
.search(query)
const region = await getRegion(countryCode)
return (
Search results for '{query}'
}>
{region && (
h.id)}
typeId={undefined}
/>
)}
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/store/page.tsx
================================================
import { Metadata } from "next"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
import StoreTemplate from "@modules/store/templates"
export const metadata: Metadata = {
title: "Store",
description: "Explore all of our products.",
}
type Params = {
searchParams: Promise<{
sortBy?: SortOptions
collection?: string | string[]
category?: string | string[]
type?: string | string[]
page?: string
}>
params: Promise<{
countryCode: string
}>
}
export default async function StorePage({ searchParams, params }: Params) {
const { countryCode } = await params
const { sortBy, page, collection, category, type } = await searchParams
return (
)
}
================================================
FILE: storefront/src/app/[countryCode]/(main)/terms-of-use/page.tsx
================================================
import { Metadata } from "next"
import { StoreRegion } from "@medusajs/types"
import { listRegions } from "@lib/data/regions"
import { Layout, LayoutColumn } from "@/components/Layout"
export const metadata: Metadata = {
title: "Terms of Use",
description: "Learn about our terms of use",
}
export async function generateStaticParams() {
const countryCodes = await listRegions().then((regions: StoreRegion[]) =>
regions.flatMap((r) =>
r.countries
? r.countries
.map((c) => c.iso_2)
.filter(
(value): value is string =>
typeof value === "string" && Boolean(value)
)
: []
)
)
const staticParams = countryCodes.map((countryCode) => ({
countryCode,
}))
return staticParams
}
export default function TermsOfUsePage() {
return (
Terms of Use for Sofa Society
Welcome to Sofa Society. These Terms of Use govern your access to and
use of our website, products, and services. By accessing or using our
platform, you agree to be bound by these terms and conditions. If you
do not agree with any part of these terms, please do not use our
website.
1. Terms of Use:
All content and materials on our website, including text, graphics,
logos, images, videos, and trademarks, are the property of Sofa
Society or its licensors and are protected by intellectual property
laws. You may not use, reproduce, modify, or distribute any of our
content without our prior written permission.
2. Use of the Website:
Eligibility: You must be at least 16 years old to use our website.
If you are under the age of 18, you should review these terms with a
parent or guardian to ensure their understanding and agreement.
User Account: Some features of our website may require you to create
an account. You are responsible for maintaining the confidentiality
of your account credentials and are solely responsible for any
activity that occurs under your account.
Prohibited Activities: You agree not to engage in any of the
following activities:
Violating any applicable laws or regulations.
Impersonating any person or entity or falsely representing your
affiliation with any person or entity.
Interfering with or disrupting the functionality of our website
or servers.
Uploading or transmitting any viruses, malware, or other
malicious code.
Collecting or harvesting any information from our website
without our consent.
3. Third-Party Links and Content:
Our website may contain links to third-party websites or display
content from third parties. We do not endorse or control these
third-party websites or content, and your use of them is at your own
risk. We are not responsible for the accuracy, reliability, or
legality of any third-party websites or content.
4. Disclaimer of Warranties:
Our website is provided on an "as is" and "as
available" basis. We do not make any warranties, express or
implied, regarding the operation, availability, or accuracy of our
website or the content therein. Your use of our website is at your
sole risk.
5. Limitation of Liability:
To the maximum extent permitted by law, Sofa Society and its
affiliates, officers, directors, employees, and agents shall not be
liable for any direct, indirect, incidental, consequential, or special
damages arising out of or in connection with your use of our website,
even if advised of the possibility of such damages.
6. Indemnification:
You agree to indemnify, defend, and hold harmless Sofa Society and its
affiliates, officers, directors, employees, and agents from and
against any claims, liabilities, damages, losses, and expenses,
including reasonable attorney's fees, arising out of or in
connection with your use of our website or violation of these Terms of
Use.
7. Modifications to the Terms:
You agree to indemnify, defend, and hold harmless Sofa Society and its
affiliates, officers, directors, employees, and agents from and
against any claims, liabilities, damages, losses, and expenses,
including reasonable attorney's fees, arising out of or in
connection with your use of our website or violation of these Terms of
Use.
8. Governing Law and Jurisdiction:
These Terms of Use shall be governed by and construed in accordance
with the laws. Any disputes arising out of or in connection with these
terms shall be subject to the exclusive jurisdiction of the courts.
)
}
================================================
FILE: storefront/src/app/layout.tsx
================================================
import { Metadata } from "next"
import { SpeedInsights } from "@vercel/speed-insights/next"
import { Mona_Sans } from "next/font/google"
import { getBaseURL } from "@lib/util/env"
import "../styles/globals.css"
import React from "react"
import { WebMCPProvider } from "@lib/webmcp/WebMCPProvider"
export const metadata: Metadata = {
metadataBase: new URL(getBaseURL()),
}
const monaSans = Mona_Sans({
preload: true,
subsets: ["latin"],
style: ["normal", "italic"],
display: "swap",
weight: "variable",
variable: "--font-mona-sans",
})
export default function RootLayout(props: { children: React.ReactNode }) {
return (
{props.children}
)
}
================================================
FILE: storefront/src/app/not-found.tsx
================================================
import { Metadata } from "next"
import { Layout, LayoutColumn } from "@/components/Layout"
import { LocalizedButtonLink } from "@/components/LocalizedLink"
import { Footer } from "@/components/Footer"
import { Header } from "@/components/Header"
export const metadata: Metadata = {
title: "404",
description: "Something went wrong",
}
export default function NotFoundPage() {
return (
<>
404
Page not found
The page you are looking for doesn't exist or an error
occurred. Go back, or head over to our home page.
Back to home
>
)
}
================================================
FILE: storefront/src/app/robots.ts
================================================
import { MetadataRoute } from "next"
export default function robots(): MetadataRoute.Robots {
if (process.env.DISALLOW_ROBOTS) {
return {
rules: {
userAgent: "*",
disallow: "/",
},
}
}
return {
rules: {
userAgent: "*",
allow: "/",
disallow: "/private/",
},
}
}
================================================
FILE: storefront/src/components/Button.tsx
================================================
"use client"
import * as React from "react"
import { twJoin, twMerge } from "tailwind-merge"
import * as ReactAria from "react-aria-components"
import Link, { LinkProps } from "next/link"
import { Icon, IconNames } from "@/components/Icon"
export type ButtonOwnProps = {
isFullWidth?: boolean
iconName?: IconNames
iconPosition?: "start" | "end"
isVisuallyDisabled?: boolean
isLoading?: boolean
loadingText?: string
size?: "sm" | "md"
spinnerPosition?: "start" | "end"
variant?: "ghost" | "outline" | "solid" | "link" | "unstyled"
}
export const getButtonClassNames = ({
isFullWidth,
iconName,
iconPosition,
isVisuallyDisabled,
isLoading,
loadingText,
size,
spinnerPosition,
variant = "solid",
}: ButtonOwnProps): string => {
const variantClasses = {
ghost: "text-black h-auto disabled:text-grayscale-200",
unstyled: "text-black h-auto disabled:text-grayscale-200",
outline:
"text-black hover:text-grayscale-500 hover:border-grayscale-500 border border-black disabled:text-grayscale-200 disabled:border-grayscale-200",
solid:
"bg-black hover:bg-grayscale-500 text-white disabled:bg-grayscale-200",
link: "text-black h-auto border-b border-current px-0 rounded-none disabled:text-grayscale-200 hover:border-transparent",
}
const visuallyDisabledClasses = isVisuallyDisabled
? {
ghost: "pointer-events-none text-grayscale-200",
link: "pointer-events-none text-grayscale-200",
unstyled: "pointer-events-none text-grayscale-200",
outline: "pointer-events-none border-grayscale-200 text-grayscale-200",
solid: "pointer-events-none bg-grayscale-200",
}[variant]
: ""
const flexDirection =
iconPosition === "end" || spinnerPosition === "end"
? "flex-row-reverse"
: ""
const hasGap = (isLoading && loadingText) || iconName
const sizeClasses =
size === "sm" ? "px-4 h-8 text-xs" : size === "md" ? "px-6 h-12" : ""
return twJoin(
"inline-flex items-center focus-visible:outline-none rounded-xs justify-center transition-colors disabled:pointer-events-none",
isFullWidth && "w-full",
flexDirection,
hasGap && "gap-2",
sizeClasses,
variantClasses[variant],
visuallyDisabledClasses
)
}
export type ButtonProps = React.ComponentPropsWithoutRef<"button"> &
ButtonOwnProps &
ReactAria.ButtonProps
export const Button: React.FC = ({
isFullWidth,
isVisuallyDisabled,
iconName,
iconPosition = "start",
isLoading,
loadingText,
size = "md",
spinnerPosition = "start",
variant = "solid",
type = "button",
className,
children,
...rest
}) => (
{Boolean(isLoading) && }
{iconName && !Boolean(isLoading) && }
{Boolean(isLoading)
? Boolean(loadingText)
? loadingText
: null
: children}
)
export const ButtonAnchor: React.FC<
React.ComponentPropsWithoutRef<"a"> & ButtonOwnProps
> = ({
isFullWidth,
isVisuallyDisabled,
iconName,
iconPosition = "start",
isLoading,
loadingText,
size = "md",
spinnerPosition = "start",
variant = "solid",
className,
children,
...rest
}) => (
{Boolean(isLoading) && }
{iconName && !Boolean(isLoading) && }
{Boolean(isLoading)
? Boolean(loadingText)
? loadingText
: null
: children}
)
export const ButtonLink: React.FC<
Omit &
ButtonOwnProps & {
className?: string
children?: React.ReactNode
}
> = ({
isFullWidth,
isVisuallyDisabled,
iconName,
iconPosition = "start",
isLoading,
loadingText,
size = "md",
spinnerPosition = "start",
variant = "solid",
className,
children,
...rest
}) => (
{Boolean(isLoading) && }
{iconName && !Boolean(isLoading) && }
{Boolean(isLoading)
? Boolean(loadingText)
? loadingText
: null
: children}
)
================================================
FILE: storefront/src/components/Carousel.tsx
================================================
"use client"
import * as React from "react"
import { twJoin, twMerge } from "tailwind-merge"
import { EmblaCarouselType } from "embla-carousel"
import useEmblaCarousel from "embla-carousel-react"
import { Icon } from "@/components/Icon"
import { IconCircle } from "@/components/IconCircle"
import { Layout, LayoutColumn } from "@/components/Layout"
export type CarouselProps = {
heading?: React.ReactNode
button?: React.ReactNode
arrows?: boolean
} & React.ComponentPropsWithRef<"div">
export const Carousel: React.FC = ({
heading,
button,
arrows = true,
children,
className,
}) => {
const [emblaRef, emblaApi] = useEmblaCarousel({
containScroll: "trimSnaps",
skipSnaps: true,
active: true,
})
const [prevBtnDisabled, setPrevBtnDisabled] = React.useState(true)
const [nextBtnDisabled, setNextBtnDisabled] = React.useState(true)
const scrollPrev = React.useCallback(
() => emblaApi && emblaApi.scrollPrev(),
[emblaApi]
)
const scrollNext = React.useCallback(
() => emblaApi && emblaApi.scrollNext(),
[emblaApi]
)
const onSelect = React.useCallback((emblaApi: EmblaCarouselType) => {
setPrevBtnDisabled(!emblaApi.canScrollPrev())
setNextBtnDisabled(!emblaApi.canScrollNext())
}, [])
React.useEffect(() => {
if (!emblaApi) return
onSelect(emblaApi)
emblaApi.on("reInit", onSelect)
emblaApi.on("select", onSelect)
}, [emblaApi, onSelect])
return (
{heading}
{(arrows || button) && (
)}
)
}
================================================
FILE: storefront/src/components/CartDrawer.tsx
================================================
"use client"
import * as React from "react"
import { HttpTypes } from "@medusajs/types"
import Item from "@modules/cart/components/item"
import CartTotals from "@modules/cart/components/cart-totals"
import { LocalizedButtonLink, LocalizedLink } from "@/components/LocalizedLink"
import { Drawer } from "@/components/Drawer"
import { Button } from "@/components/Button"
import DiscountCode from "@modules/cart/components/discount-code"
import { Icon } from "@/components/Icon"
import { getCheckoutStep } from "@modules/cart/utils/getCheckoutStep"
import { useCart, useCartQuantity } from "hooks/cart"
import { withReactQueryProvider } from "@lib/util/react-query"
export const CartDrawer = withReactQueryProvider(() => {
const [isCartDrawerOpen, setIsCartDrawerOpen] = React.useState(false)
const { data: cart, isPending } = useCart({ enabled: isCartDrawerOpen })
const step = getCheckoutStep(cart as HttpTypes.StoreCart)
const { data: quantity, isPending: pendingQuantity } = useCartQuantity()
return (
<>
setIsCartDrawerOpen(true)}
variant="ghost"
className="p-1 group-data-[light=true]:md:text-white group-data-[sticky=true]:md:text-black"
aria-label="Open cart"
>
{pendingQuantity ? (
) : (
0 ? quantity : undefined}
/>
)}
{({ close }) => (
<>
{cart?.items?.length ? (
<>
{cart?.items
.sort((a, b) => {
return (a.created_at ?? "") > (b.created_at ?? "")
? -1
: 1
})
.map((item) => {
return (
)
})}
Proceed to checkout
>
) : isPending ? (
) : (
<>
You don't have anything in your cart. Let's change
that, use the link below to start browsing our products.
{
setIsCartDrawerOpen(false)
}}
>
Explore products
>
)}
>
)}
>
)
})
================================================
FILE: storefront/src/components/CartIcon.tsx
================================================
import { Suspense } from "react"
import { getCartQuantity } from "@lib/data/cart"
import { Icon, IconProps } from "@/components/Icon"
const CartIconWithQuantity: React.FC<
Omit
> = async (props) => {
const quantity = await getCartQuantity()
return (
0 ? quantity : undefined} {...props} />
)
}
export const CartIcon: React.FC> = (
props
) => {
return (
}>
)
}
================================================
FILE: storefront/src/components/CollectionsSection.tsx
================================================
import Image from "next/image"
import { getCollectionsList } from "@lib/data/collections"
import { Carousel } from "@/components/Carousel"
import { LocalizedButtonLink, LocalizedLink } from "@/components/LocalizedLink"
export const CollectionsSection: React.FC<{ className?: string }> = async ({
className,
}) => {
const collections = await getCollectionsList(0, 20, [
"id",
"title",
"handle",
"metadata",
])
if (!collections) {
return null
}
return (
Collections}
button={
<>
View All
View All
>
}
className={className}
>
{collections.collections.map((collection) => (
{typeof collection.metadata?.image === "object" &&
collection.metadata.image &&
"url" in collection.metadata.image &&
typeof collection.metadata.image.url === "string" && (
)}
{collection.title}
{typeof collection.metadata?.description === "string" &&
collection.metadata?.description.length > 0 && (
{collection.metadata.description}
)}
))}
)
}
================================================
FILE: storefront/src/components/Dialog.tsx
================================================
"use client"
import * as React from "react"
import * as ReactAria from "react-aria-components"
import { twMerge } from "tailwind-merge"
import { Button, ButtonProps } from "@/components/Button"
export const UiDialogTrigger: React.FC = ({
children,
...rest
}) => {children}
export const UiDialog: React.FC = ({
children,
className,
...rest
}) => (
{children}
)
export const UiCloseButton: React.FC = (props) => {
const { close } = React.useContext(ReactAria.OverlayTriggerStateContext)!
return
}
export const UiConfirmButton: React.FC<
ButtonProps & { onConfirm: () => Promise }
> = (props) => {
const { close } = React.useContext(ReactAria.OverlayTriggerStateContext)!
const onPress = React.useCallback(
async (e: ReactAria.PressEvent) => {
await props.onConfirm()
close()
props.onPress?.(e)
},
[props, close]
)
return
}
================================================
FILE: storefront/src/components/Drawer.tsx
================================================
import * as React from "react"
import { twMerge } from "tailwind-merge"
import * as ReactAria from "react-aria-components"
import { UiModal, UiModalOverlay, UiModalOwnProps } from "@/components/ui/Modal"
import { UiDialog } from "@/components/Dialog"
export interface DrawerProps
extends Omit,
UiModalOwnProps,
Pick {
colorScheme?: "light" | "dark"
className?: string
}
export const Drawer: React.FC = ({
colorScheme = "dark",
animateFrom,
className,
children,
...rest
}) => {
return (
{children}
)
}
================================================
FILE: storefront/src/components/Footer.tsx
================================================
"use client"
import { useParams, usePathname } from "next/navigation"
import { twMerge } from "tailwind-merge"
import { Layout, LayoutColumn } from "@/components/Layout"
import { NewsletterForm } from "@/components/NewsletterForm"
import { LocalizedLink } from "@/components/LocalizedLink"
export const Footer: React.FC = () => {
const pathName = usePathname()
const { countryCode } = useParams()
const currentPath = pathName.split(`/${countryCode}`)[1]
const isAuthPage = currentPath === "/register" || currentPath === "/login"
return (
Sofa Society Co.
© {new Date().getFullYear()}, Sofa Society
FAQ
Help
Delivery
Returns
Privacy Policy
Cookie Policy
Terms of Use
)
}
================================================
FILE: storefront/src/components/Forms.tsx
================================================
"use client"
import * as React from "react"
import { twJoin, twMerge } from "tailwind-merge"
import * as ReactAria from "react-aria-components"
import { Icon } from "@/components/Icon"
import {
FormProvider,
useForm,
UseFormProps,
DefaultValues,
UseFormReturn,
useController,
ControllerRenderProps,
} from "react-hook-form"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import CountrySelect from "@modules/checkout/components/country-select"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type FormProps> = UseFormProps<
z.infer
> & {
schema: T
onSubmit: (
values: z.infer,
form: UseFormReturn>
) => void | Promise
defaultValues?: DefaultValues>
children?:
| React.ReactNode
| ((form: UseFormReturn>) => React.ReactNode)
formProps?: Omit, "onSubmit">
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Form = >({
schema,
onSubmit,
children,
formProps,
...props
}: FormProps) => {
const form = useForm({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolver: zodResolver(schema as any),
...props,
})
const submitHandler = React.useCallback(
(values: z.infer) => {
return onSubmit(values, form)
},
[onSubmit, form]
)
const onFormSubmit: React.FormEventHandler =
React.useCallback(
(event) => {
event.preventDefault()
event.stopPropagation()
form.handleSubmit(submitHandler, (err) => console.error(err))(event)
},
[form, submitHandler]
)
return (
)
}
export const getInputClassNames = ({
uiSize = "lg",
isVisuallyDisabled,
isSuccess,
}: InputOwnProps): string => {
const sizeClasses = {
sm: "h-9 text-xs focus:pt-3.5 [&:not(:placeholder-shown)]:pt-3.5 [&:autofill]:pt-3.5",
md: "h-12 focus:pt-3 [&:not(:placeholder-shown)]:pt-3 [&:autofill]:pt-3",
lg: "h-14 focus:pt-4 [&:not(:placeholder-shown)]:pt-4 [&:autofill]:pt-4",
}
const visuallyDisabledClasses = isVisuallyDisabled
? "pointer-events-none bg-grayscale-50"
: ""
const successClasses = isSuccess ? "border-green-500 pr-7" : ""
return twJoin(
"peer block w-full rounded-xs transition-all outline-none px-4 placeholder:invisible border border-grayscale-200 hover:border-grayscale-500 focus:border-grayscale-500 bg-transparent disabled:pointer-events-none disabled:bg-grayscale-50 [&:autofill]:bg-clip-text aria-[invalid=true]:border-red-primary aria-[invalid=true]:focus:border-red-900 aria-[invalid=true]:hover:border-red-900",
sizeClasses[uiSize],
visuallyDisabledClasses,
successClasses
)
}
export const getPlaceholderClassNames = ({
uiSize = "lg",
}: Pick): string => {
const sizeClasses = {
lg: "peer-focus:top-2.5 peer-[:not(:placeholder-shown)]:top-2.5 peer-[:autofill]:top-2.5 peer-focus:text-xs peer-[:not(:placeholder-shown)]:text-xs peer-[:autofill]:text-xs",
md: "peer-focus:top-1 peer-[:not(:placeholder-shown)]:top-1 peer-[:autofill]:top-1 peer-focus:text-xs peer-[:not(:placeholder-shown)]:text-xs peer-[:autofill]:text-xs",
sm: "peer-focus:top-1 peer-[:not(:placeholder-shown)]:top-1 peer-[:autofill]:top-1 text-xs peer-focus:text-2xs peer-[:not(:placeholder-shown)]:text-2xs peer-[:autofill]:text-2xs",
}
return twJoin(
"absolute -translate-y-1/2 peer-placeholder-shown:top-1/2 left-4 peer-[:not(:placeholder-shown)]:translate-y-0 peer-[:autofill]:translate-y-0 peer-focus:translate-y-0 text-grayscale-400 pointer-events-none transition-all",
sizeClasses[uiSize]
)
}
/**
* Label
*/
type InputLabelOwnProps = {
isRequired?: boolean
}
export const InputLabel: React.FC<
React.ComponentPropsWithRef<"label"> & InputLabelOwnProps
> = ({ isRequired, children, className, ...rest }) => (
{children}
{isRequired && * }
)
/**
* SubLabel
*/
type InputSubLabelOwnProps = {
type: "success" | "error"
}
export const InputSubLabel: React.FC<
React.ComponentPropsWithRef<"p"> & InputSubLabelOwnProps
> = ({ type, children, className, ...rest }) => (
{children}
)
/**
* Input
*/
export type InputOwnProps = {
uiSize?: "sm" | "md" | "lg"
isVisuallyDisabled?: boolean
isSuccess?: boolean
errorMessage?: string
wrapperClassName?: string
}
export const Input = React.forwardRef<
HTMLInputElement,
React.ComponentProps<"input"> & InputOwnProps
>(
(
{
uiSize = "lg",
isVisuallyDisabled,
isSuccess,
errorMessage,
wrapperClassName,
placeholder,
className,
...rest
},
ref
) => (
{placeholder && (
{placeholder}
)}
{isSuccess && (
)}
{errorMessage && (
{errorMessage}
)}
)
)
Input.displayName = "Input"
export interface InputFieldProps {
className?: string
name: string
placeholder?: string
type?: React.ComponentProps["type"]
inputProps?: Omit<
React.ComponentProps,
"name" | "id" | "type" | keyof ControllerRenderProps
>
}
export const InputField: React.FC = ({
className,
name,
type,
inputProps,
placeholder,
}) => {
const { field, fieldState } = useController<{ __name__: string }, "__name__">(
{ name: name as "__name__" }
)
return (
{fieldState.error && (
{fieldState.error.message}
)}
)
}
export interface CountrySelectFieldProps {
className?: string
name: string
label?: string
selectProps?: Omit<
React.ComponentProps,
"name" | "id" | keyof ControllerRenderProps
>
isRequired?: boolean
children?: React.ReactNode
}
export const CountrySelectField: React.FC = ({
className,
name,
selectProps,
children,
}) => {
const { field, fieldState } = useController<{ __name__: string }, "__name__">(
{ name: name as "__name__" }
)
return (
{children}
{fieldState.error && (
{fieldState.error.message}
)}
)
}
================================================
FILE: storefront/src/components/Header.tsx
================================================
import * as React from "react"
import { listRegions } from "@lib/data/regions"
import { SearchField } from "@/components/SearchField"
import { Layout, LayoutColumn } from "@/components/Layout"
import { LocalizedLink } from "@/components/LocalizedLink"
import { HeaderDrawer } from "@/components/HeaderDrawer"
import { RegionSwitcher } from "@/components/RegionSwitcher"
import { HeaderWrapper } from "@/components/HeaderWrapper"
import dynamic from "next/dynamic"
const LoginLink = dynamic(
() => import("@modules/header/components/LoginLink"),
{ loading: () => <>> }
)
const CartDrawer = dynamic(
() => import("@/components/CartDrawer").then((mod) => mod.CartDrawer),
{ loading: () => <>> }
)
export const Header: React.FC = async () => {
const regions = await listRegions()
const countryOptions = regions
.map((r) => {
return (r.countries ?? []).map((c) => ({
country: c.iso_2,
region: r.id,
label: c.display_name,
}))
})
.flat()
.sort((a, b) => (a?.label ?? "").localeCompare(b?.label ?? ""))
return (
<>
SofaSocietyCo.
About
Inspiration
Shop
>
)
}
================================================
FILE: storefront/src/components/HeaderDrawer.tsx
================================================
"use client"
import * as React from "react"
import { Button } from "@/components/Button"
import { Icon } from "@/components/Icon"
import { Drawer } from "@/components/Drawer"
import { LocalizedLink } from "@/components/LocalizedLink"
import { RegionSwitcher } from "@/components/RegionSwitcher"
import { SearchField } from "@/components/SearchField"
import { useSearchParams } from "next/navigation"
export const HeaderDrawer: React.FC<{
countryOptions: {
country: string | undefined
region: string
label: string | undefined
}[]
}> = ({ countryOptions }) => {
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const searchParams = useSearchParams()
const searchQuery = searchParams.get("query")
React.useEffect(() => {
if (searchQuery) setIsMenuOpen(false)
}, [searchQuery])
return (
<>
setIsMenuOpen(true)}
aria-label="Open menu"
>
{({ close }) => (
<>
setIsMenuOpen(false)}
>
About
setIsMenuOpen(false)}
>
Inspiration
setIsMenuOpen(false)}
>
Shop
>
)}
>
)
}
================================================
FILE: storefront/src/components/HeaderWrapper.tsx
================================================
"use client"
import * as React from "react"
import { usePathname } from "next/navigation"
import { useCountryCode } from "hooks/country-code"
export const HeaderWrapper: React.FC<{ children?: React.ReactNode }> = ({
children,
}) => {
const pathName = usePathname()
const countryCode = useCountryCode()
const currentPath = countryCode
? pathName.split(`/${countryCode}`)[1]
: pathName
const isPageWithHeroImage =
!currentPath ||
currentPath === "/" ||
currentPath === "/about" ||
currentPath === "/inspiration" ||
currentPath.startsWith("/collections")
const isAlwaysSticky =
currentPath.startsWith("/auth") || currentPath.startsWith("/account")
React.useEffect(() => {
if (isAlwaysSticky) {
return
}
const headerElement = document.querySelector("#site-header")
if (!headerElement) {
return
}
const nextElement = headerElement.nextElementSibling
let triggerPosition = 0
const updateTriggerPosition = () => {
if (isPageWithHeroImage) {
triggerPosition = nextElement
? Math.max(nextElement.clientHeight - headerElement.clientHeight, 1)
: 200
} else {
triggerPosition = nextElement
? Math.max(
Number.parseInt(
window.getComputedStyle(nextElement).paddingTop,
10
) - headerElement.clientHeight,
1
)
: 1
}
}
const handleScroll = () => {
const position = window.scrollY
headerElement.setAttribute(
"data-sticky",
position > triggerPosition ? "true" : "false"
)
}
updateTriggerPosition()
handleScroll()
window.addEventListener("resize", updateTriggerPosition, {
passive: true,
})
window.addEventListener("orientationchange", updateTriggerPosition, {
passive: true,
})
window.addEventListener("scroll", handleScroll, {
passive: true,
})
return () => {
window.removeEventListener("resize", updateTriggerPosition)
window.removeEventListener("orientationchange", updateTriggerPosition)
window.removeEventListener("scroll", handleScroll)
}
}, [pathName, isPageWithHeroImage, isAlwaysSticky])
return (
)
}
================================================
FILE: storefront/src/components/Icon.tsx
================================================
import * as React from "react"
import { twJoin, twMerge } from "tailwind-merge"
import { ArrowLeft } from "@/components/icons/ArrowLeft"
import { ArrowRight } from "@/components/icons/ArrowRight"
import { ArrowUpRight } from "@/components/icons/ArrowUpRight"
import { Calendar } from "@/components/icons/Calendar"
import { Case } from "@/components/icons/Case"
import { Check } from "@/components/icons/Check"
import { ChevronDown } from "@/components/icons/ChevronDown"
import { ChevronLeft } from "@/components/icons/ChevronLeft"
import { ChevronRight } from "@/components/icons/ChevronRight"
import { ChevronUp } from "@/components/icons/ChevronUp"
import { Close } from "@/components/icons/Close"
import { CreditCard } from "@/components/icons/CreditCard"
import { Heart } from "@/components/icons/Heart"
import { Info } from "@/components/icons/Info"
import { Loader } from "@/components/icons/Loader"
import { MapPin } from "@/components/icons/MapPin"
import { Menu } from "@/components/icons/Menu"
import { Minus } from "@/components/icons/Minus"
import { Package } from "@/components/icons/Package"
import { Plus } from "@/components/icons/Plus"
import { Receipt } from "@/components/icons/Receipt"
import { Search } from "@/components/icons/Search"
import { Sliders } from "@/components/icons/Sliders"
import { Trash } from "@/components/icons/Trash"
import { Truck } from "@/components/icons/Truck"
import { Undo } from "@/components/icons/Undo"
import { User } from "@/components/icons/User"
export type IconNames =
| "arrow-left"
| "arrow-right"
| "arrow-up-right"
| "calendar"
| "case"
| "check"
| "chevron-down"
| "chevron-left"
| "chevron-right"
| "chevron-up"
| "close"
| "credit-card"
| "heart"
| "info"
| "loader"
| "map-pin"
| "menu"
| "minus"
| "package"
| "plus"
| "receipt"
| "search"
| "sliders"
| "trash"
| "truck"
| "undo"
| "user"
const baseClasses = "w-4 h-auto shrink-0"
export type IconProps = React.ComponentPropsWithoutRef<"svg"> & {
name: IconNames
status?: number
wrapperClassName?: string
}
export const Icon: React.FC = ({
name,
status = 0,
wrapperClassName,
className,
...rest
}) => (
{Boolean(status) && (
99 && "!text-[0.5rem]"
)}
>
{status > 99 ? "+99" : status}
)}
{name === "arrow-left" && (
)}
{name === "arrow-right" && (
)}
{name === "arrow-up-right" && (
)}
{name === "calendar" && (
)}
{name === "case" && (
)}
{name === "check" && (
)}
{name === "chevron-down" && (
)}
{name === "chevron-left" && (
)}
{name === "chevron-right" && (
)}
{name === "chevron-up" && (
)}
{name === "close" && (
)}
{name === "credit-card" && (
)}
{name === "heart" && (
)}
{name === "info" && (
)}
{name === "loader" && (
)}
{name === "map-pin" && (
)}
{name === "menu" && (
)}
{name === "minus" && (
)}
{name === "package" && (
)}
{name === "plus" && (
)}
{name === "receipt" && (
)}
{name === "search" && (
)}
{name === "sliders" && (
)}
{name === "trash" && (
)}
{name === "truck" && (
)}
{name === "undo" && (
)}
{name === "user" && (
)}
)
================================================
FILE: storefront/src/components/IconCircle.tsx
================================================
import * as React from "react"
import { twMerge } from "tailwind-merge"
export const IconCircle: React.FC> = ({
className,
...rest
}) => (
)
================================================
FILE: storefront/src/components/InputNumberField.tsx
================================================
"use client"
import * as React from "react"
import * as ReactAria from "react-aria-components"
import { twJoin, twMerge } from "tailwind-merge"
import { Icon } from "@/components/Icon"
type InputNumberFieldProps = Omit<
ReactAria.InputProps,
"type" | "size" | "value" | "defaultValue" | "onChange"
> & {
size?: "sm" | "base"
value?: number
onChange?: (value: number) => void
onCommit?: (value: number) => void
minValue?: number
maxValue?: number
step?: number
isDisabled?: boolean
}
const clampValue = (value: number, min?: number, max?: number) => {
if (typeof min === "number" && value < min) return min
if (typeof max === "number" && value > max) return max
return value
}
const coerceInputValue = (value: string) => {
if (value.trim() === "") return null
const parsed = Number(value)
return Number.isNaN(parsed) ? null : parsed
}
export const InputNumberField: React.FC = ({
size = "base",
className,
value,
onChange,
onCommit,
minValue,
maxValue,
step = 1,
isDisabled,
onBlur,
onFocus,
...rest
}) => {
const resolvedValue = typeof value === "number" ? value : undefined
const [inputValue, setInputValue] = React.useState(
resolvedValue === undefined ? "" : `${resolvedValue}`
)
const isFocusedRef = React.useRef(false)
React.useEffect(() => {
if (!isFocusedRef.current) {
setInputValue(resolvedValue === undefined ? "" : `${resolvedValue}`)
}
}, [resolvedValue])
const getBaseValue = React.useCallback(() => {
const parsed = coerceInputValue(inputValue)
if (parsed !== null) return parsed
if (typeof resolvedValue === "number") return resolvedValue
if (typeof minValue === "number") return minValue
return 0
}, [inputValue, minValue, resolvedValue])
const baseValue = getBaseValue()
const canDecrement =
typeof minValue === "number" ? baseValue > minValue : true
const canIncrement =
typeof maxValue === "number" ? baseValue < maxValue : true
const handleInputChange = React.useCallback(
(event: React.ChangeEvent) => {
const nextValue = event.target.value
setInputValue(nextValue)
if (!onChange) return
const parsed = coerceInputValue(nextValue)
if (parsed === null) return
onChange(clampValue(parsed, minValue, maxValue))
},
[maxValue, minValue, onChange]
)
const normalizeOnBlur = React.useCallback(() => {
if (!onChange) {
setInputValue(resolvedValue === undefined ? "" : `${resolvedValue}`)
return null
}
const parsed = coerceInputValue(inputValue)
if (parsed === null) {
const fallback = clampValue(baseValue, minValue, maxValue)
onChange(fallback)
setInputValue(`${fallback}`)
return fallback
}
const clamped = clampValue(parsed, minValue, maxValue)
if (clamped !== parsed) {
onChange(clamped)
setInputValue(`${clamped}`)
}
return clamped
}, [baseValue, inputValue, maxValue, minValue, onChange, resolvedValue])
const handleDecrement = React.useCallback(() => {
if (!onChange || isDisabled || !canDecrement) return
const nextValue = clampValue(baseValue - step, minValue, maxValue)
onChange(nextValue)
setInputValue(`${nextValue}`)
onCommit?.(nextValue)
}, [
canDecrement,
baseValue,
isDisabled,
maxValue,
minValue,
onChange,
onCommit,
step,
])
const handleIncrement = React.useCallback(() => {
if (!onChange || isDisabled || !canIncrement) return
const nextValue = clampValue(baseValue + step, minValue, maxValue)
onChange(nextValue)
setInputValue(`${nextValue}`)
onCommit?.(nextValue)
}, [
canIncrement,
baseValue,
isDisabled,
maxValue,
minValue,
onChange,
onCommit,
step,
])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "ArrowDown") {
event.preventDefault()
handleDecrement()
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
handleIncrement()
return
}
if (event.key === "Enter") {
event.preventDefault()
const committedValue = normalizeOnBlur()
if (committedValue !== null) {
onCommit?.(committedValue)
}
}
},
[handleDecrement, handleIncrement, normalizeOnBlur, onCommit]
)
return (
{
isFocusedRef.current = true
onFocus?.(event)
}}
onBlur={(event) => {
isFocusedRef.current = false
const committedValue = normalizeOnBlur()
if (committedValue !== null) {
onCommit?.(committedValue)
}
onBlur?.(event)
}}
disabled={isDisabled}
className={twJoin(
"disabled:text-grayscale-200 disabled:bg-transparent text-center focus-within:outline-none w-7 leading-none appearance-none [-moz-appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
size === "sm" ? "text-xs" : "text-sm"
)}
/>
)
}
================================================
FILE: storefront/src/components/Layout.tsx
================================================
import * as React from "react"
import { twJoin, twMerge } from "tailwind-merge"
export const Layout = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithRef<"div">
>(({ className, ...rest }, ref) => (
))
Layout.displayName = "Layout"
// const fullConfig = resolveConfig(tailwindConfig);
// const breakpointsNamesArray = Object.keys(fullConfig.theme.screens);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const breakpointsNamesArray = ["base", "xs", "sm", "md", "lg", "xl"] as const
type BreakpointsNames = (typeof breakpointsNamesArray)[number]
type ColumnsNumbers = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13
type LayoutOwnProps = {
start?: { [key in BreakpointsNames]?: ColumnsNumbers } | ColumnsNumbers
end?: { [key in BreakpointsNames]?: ColumnsNumbers } | ColumnsNumbers
}
export const getLayoutColumnClasses = ({
start = 1,
end = 13,
}: Pick): string => {
const startClasses =
typeof start === "number"
? [`col-start-${start}`]
: Object.entries(start).map(([breakpoint, columns]) => {
if (breakpoint === "base") {
return `col-start-${columns}`
}
return `${breakpoint}:col-start-${columns}`
})
const endClasses =
typeof end === "number"
? [`col-end-${end}`]
: Object.entries(end).map(([breakpoint, columns]) => {
if (breakpoint === "base") {
return `col-end-${columns}`
}
return `${breakpoint}:col-end-${columns}`
})
return twJoin(...startClasses, ...endClasses)
}
export const LayoutColumn: React.FC<
React.ComponentPropsWithRef<"div"> & LayoutOwnProps
> = ({ start = 1, end = 13, className, ...rest }) => {
return (
)
}
================================================
FILE: storefront/src/components/Link.tsx
================================================
import * as React from "react"
import NextLink, { LinkProps as NextLinkProps } from "next/link"
import { twJoin, twMerge } from "tailwind-merge"
export type LinkOwnProps = {
variant?: "underline" | "hover:underline" | "unstyled"
}
export const getLinkClassNames = ({ variant }: LinkOwnProps): string =>
twJoin(
variant !== "unstyled" && "transition-colors",
(variant === "underline" || variant === "hover:underline") &&
"border-b border-current pb-0.5 md:pb-1",
variant === "hover:underline" &&
"border-transparent hover:border-current transition-colors",
variant === "underline" && "hover:border-transparent"
)
export const Link = ({
variant = "unstyled",
className,
children,
...rest
}: React.ComponentPropsWithoutRef<"a"> &
NextLinkProps &
LinkOwnProps) => (
{children}
)
export const Anchor: React.FC<
React.ComponentPropsWithoutRef<"a"> & LinkOwnProps
> = ({ variant = "unstyled", className, children, ...rest }) => (
{children}
)
================================================
FILE: storefront/src/components/LocalizedLink.tsx
================================================
"use client"
import * as React from "react"
import { LinkProps } from "next/link"
import { useCountryCode } from "hooks/country-code"
import { Link, LinkOwnProps } from "@/components/Link"
import { ButtonLink, ButtonOwnProps } from "@/components/Button"
export const LocalizedLink = ({
children,
href,
...props
}: React.ComponentPropsWithoutRef<"a"> &
LinkProps &
LinkOwnProps) => {
const countryCode = useCountryCode()
return (
{children}
)
}
export const LocalizedButtonLink = ({
children,
href,
...props
}: ButtonOwnProps &
Omit, "passHref"> & {
className?: string
children?: React.ReactNode
}) => {
const countryCode = useCountryCode()
return (
{children}
)
}
================================================
FILE: storefront/src/components/NewsletterForm.tsx
================================================
"use client"
import * as React from "react"
import { Button } from "@/components/Button"
import { Form, InputField } from "@/components/Forms"
import { LocalizedLink } from "@/components/LocalizedLink"
import { z } from "zod"
const newsletterFormSchema = z.object({
email: z.string().min(3).email(),
})
export const NewsletterForm: React.FC<{ className?: string }> = ({
className,
}) => {
const [isSubmitted, setIsSubmitted] = React.useState(false)
return (
Join our newsletter
{isSubmitted ? (
Thank you for subscribing to our newsletter!
) : (
<>
We will also send you our discount coupons!
By subscribing you agree to with our{" "}
Privacy Policy
{" "}
and provide consent to receive updates from our company.
>
)}
)
}
================================================
FILE: storefront/src/components/NumberField.tsx
================================================
"use client"
import * as ReactAria from "react-aria-components"
import { twJoin, twMerge } from "tailwind-merge"
import { Icon } from "@/components/Icon"
export const NumberField: React.FC<
ReactAria.NumberFieldProps & {
size?: "sm" | "base"
}
> = ({ size = "base", className, ...rest }) => (
)
================================================
FILE: storefront/src/components/ProductPageGallery.tsx
================================================
// TODO: Review this component.
"use client"
import * as React from "react"
import { twJoin, twMerge } from "tailwind-merge"
import { EmblaCarouselType } from "embla-carousel"
import useEmblaCarousel from "embla-carousel-react"
import { Icon } from "@/components/Icon"
import { IconCircle } from "@/components/IconCircle"
export const ProductPageGallery: React.FC<
React.ComponentPropsWithRef<"div">
> = ({ children, className }) => {
const [emblaRef, emblaApi] = useEmblaCarousel({
containScroll: "trimSnaps",
skipSnaps: true,
})
const [prevBtnDisabled, setPrevBtnDisabled] = React.useState(true)
const [nextBtnDisabled, setNextBtnDisabled] = React.useState(true)
const [selectedIndex, setSelectedIndex] = React.useState(0)
const [scrollSnaps, setScrollSnaps] = React.useState([])
const scrollPrev = React.useCallback(
() => emblaApi && emblaApi.scrollPrev(),
[emblaApi]
)
const scrollNext = React.useCallback(
() => emblaApi && emblaApi.scrollNext(),
[emblaApi]
)
const onSelect = React.useCallback((emblaApi: EmblaCarouselType) => {
setPrevBtnDisabled(!emblaApi.canScrollPrev())
setNextBtnDisabled(!emblaApi.canScrollNext())
setSelectedIndex(emblaApi.selectedScrollSnap())
}, [])
const onInit = React.useCallback((emblaApi: EmblaCarouselType) => {
setScrollSnaps(emblaApi.scrollSnapList())
}, [])
const onDotButtonClick = React.useCallback(
(index: number) => {
if (!emblaApi) return
emblaApi.scrollTo(index)
},
[emblaApi]
)
React.useEffect(() => {
if (!emblaApi) return
onInit(emblaApi)
onSelect(emblaApi)
emblaApi.on("reInit", onInit).on("reInit", onSelect).on("select", onSelect)
}, [emblaApi, onInit, onSelect])
return (
{React.Children.map(children, (child) => {
return (
{child}
)
})}
{scrollSnaps.map((_, index) => (
onDotButtonClick(index)}
className="px-1.5"
>
{index + 1}
))}
)
}
================================================
FILE: storefront/src/components/RegionSwitcher.tsx
================================================
"use client"
import * as React from "react"
import { usePathname } from "next/navigation"
import * as ReactAria from "react-aria-components"
import {
UiSelectButton,
UiSelectIcon,
UiSelectListBox,
UiSelectListBoxItem,
UiSelectValue,
} from "@/components/ui/Select"
import { useCountryCode } from "hooks/country-code"
import { useUpdateRegion } from "hooks/cart"
import { withReactQueryProvider } from "@lib/util/react-query"
export const RegionSwitcher = withReactQueryProvider<{
countryOptions: {
country: string | undefined
region: string
label: string | undefined
}[]
className?: string
selectButtonClassName?: string
selectIconClassName?: string
}>(
({
countryOptions,
className,
selectButtonClassName,
selectIconClassName,
}) => {
const pathName = usePathname()
const countryCode = useCountryCode(countryOptions)
let currentPath = pathName
const updateRegion = useUpdateRegion()
if (countryCode) {
currentPath = pathName.split(`/${countryCode}`)[1]
}
return (
{
updateRegion.mutate({ countryCode: `${key}`, currentPath })
}}
className={className}
aria-label="Select country"
>
{(item) =>
typeof item.selectedItem === "object" &&
item.selectedItem !== null &&
"country" in item.selectedItem &&
typeof item.selectedItem.country === "string"
? item.selectedItem.country.toUpperCase()
: item.defaultChildren
}
{countryOptions.map((country) => (
{country.label}
))}
)
}
)
================================================
FILE: storefront/src/components/SearchField.tsx
================================================
"use client"
import * as React from "react"
import * as ReactAria from "react-aria-components"
import { twJoin } from "tailwind-merge"
import { useAsyncList } from "react-stately"
import { Hit } from "meilisearch"
import { useRouter, useSearchParams } from "next/navigation"
import { useCountryCode } from "hooks/country-code"
import { MeiliSearchProductHit, searchClient } from "@lib/search-client"
import { getProductPrice } from "@lib/util/get-product-price"
import { getProductsById } from "@lib/data/products"
import Thumbnail from "@modules/products/components/thumbnail"
import { Button } from "@/components/Button"
import { Input } from "@/components/Forms"
import { Icon } from "@/components/Icon"
interface ListItem extends Hit {
price: {
calculated_price_number: number
calculated_price: string
original_price_number: number | null
original_price: string
currency_code: string | null
price_type: string | null | undefined
percentage_diff: string
} | null
}
export const SearchField: React.FC<{
countryOptions: {
country: string | undefined
region: string
label: string | undefined
}[]
isInputAlwaysShown?: boolean
}> = ({ countryOptions, isInputAlwaysShown }) => {
const router = useRouter()
const [isInputShown, setIsInputShown] = React.useState(false)
const countryCode = useCountryCode()
const region = countryOptions.find((co) => co.country === countryCode)?.region
const searchParams = useSearchParams()
const searchQuery = searchParams.get("query")
const list = useAsyncList({
getKey(item) {
return item.handle
},
load: async ({ filterText, signal }) => {
const results = await searchClient
.index("products")
.search(filterText, undefined, {
signal,
})
const medusaProducts = await getProductsById({
ids: results.hits.map((h) => h.id),
regionId: region!,
})
return {
items: results.hits.map((hit) => {
const product = medusaProducts.find((p) => p.id === hit.id)
return {
...hit,
price: getProductPrice({
product: product!,
}).cheapestPrice,
}
}),
filterText,
}
},
initialFilterText: searchQuery ?? "",
})
const buttonPressHandle = React.useCallback(() => {
if (!isInputShown) {
setIsInputShown(true)
} else if (list.filterText) {
router.push(`/${countryCode}/search?query=${list.filterText}`)
if (!isInputAlwaysShown) setIsInputShown(false)
} else {
if (!isInputAlwaysShown) setIsInputShown(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isInputShown, list.filterText, router, countryCode])
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
if (!isInputAlwaysShown) setIsInputShown(false)
} else if (e.key === "Enter" && list.filterText) {
router.push(`/${countryCode}/search?query=${list.filterText}`)
if (!isInputAlwaysShown) setIsInputShown(false)
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[list.filterText, router, countryCode]
)
React.useEffect(() => {
if (searchQuery && !list.filterText) {
list.setFilterText(searchQuery)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery])
React.useEffect(() => {
if (isInputAlwaysShown) setIsInputShown(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
{(item: ListItem) => (
{item.title}
{item.variants[0]}
{item.price?.calculated_price}
)}
)
}
================================================
FILE: storefront/src/components/icons/ArrowLeft.tsx
================================================
import * as React from "react"
export const ArrowLeft: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/ArrowRight.tsx
================================================
import * as React from "react"
export const ArrowRight: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/ArrowUpRight.tsx
================================================
import * as React from "react"
export const ArrowUpRight: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/Calendar.tsx
================================================
import * as React from "react"
export const Calendar: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/Case.tsx
================================================
import * as React from "react"
export const Case: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/Check.tsx
================================================
import * as React from "react"
export const Check: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/ChevronDown.tsx
================================================
import * as React from "react"
export const ChevronDown: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/ChevronLeft.tsx
================================================
import * as React from "react"
export const ChevronLeft: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/ChevronRight.tsx
================================================
import * as React from "react"
export const ChevronRight: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/ChevronUp.tsx
================================================
import * as React from "react"
export const ChevronUp: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/Close.tsx
================================================
import * as React from "react"
export const Close: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/CreditCard.tsx
================================================
import * as React from "react"
export const CreditCard: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/Heart.tsx
================================================
import * as React from "react"
export const Heart: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/Info.tsx
================================================
import * as React from "react"
export const Info: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/Loader.tsx
================================================
import * as React from "react"
export const Loader: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/MapPin.tsx
================================================
import * as React from "react"
export const MapPin: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/Menu.tsx
================================================
import * as React from "react"
export const Menu: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/Minus.tsx
================================================
import * as React from "react"
export const Minus: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/Package.tsx
================================================
import * as React from "react"
export const Package: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/Plus.tsx
================================================
import * as React from "react"
export const Plus: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/Receipt.tsx
================================================
import * as React from "react"
export const Receipt: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/Search.tsx
================================================
import * as React from "react"
export const Search: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/Sliders.tsx
================================================
import * as React from "react"
export const Sliders: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/Trash.tsx
================================================
import * as React from "react"
export const Trash: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/Truck.tsx
================================================
import * as React from "react"
export const Truck: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/Undo.tsx
================================================
import * as React from "react"
export const Undo: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/icons/User.tsx
================================================
import * as React from "react"
export const User: React.FC> = (
props
) => (
)
================================================
FILE: storefront/src/components/ui/Checkbox.tsx
================================================
"use client"
import * as ReactAria from "react-aria-components"
import { twMerge } from "tailwind-merge"
import { Icon, IconNames, IconProps } from "@/components/Icon"
export const UiCheckbox: React.FC = ({
className,
...props
}) => (
)
export const UiCheckboxBox: React.FC> = ({
className,
...props
}) => (
)
export const UiCheckboxIcon: React.FC<
Omit & { name?: IconNames }
> = ({ name = "check", className, ...props }) => (
)
export const UiCheckboxLabel: React.FC<
React.ComponentPropsWithoutRef<"span">
> = ({ className, ...props }) =>
================================================
FILE: storefront/src/components/ui/Modal.tsx
================================================
"use client"
import * as ReactAria from "react-aria-components"
import { twJoin, twMerge } from "tailwind-merge"
export const UiModalOverlay: React.FC = ({
isDismissable = true,
className,
...props
}) => (
)
export type UiModalOwnProps = {
animateFrom?: "center" | "right" | "bottom" | "left"
}
export const getModalClassNames = ({
animateFrom = "center",
}: UiModalOwnProps): string => {
const animateFromClasses = {
center: "data-[entering]:zoom-in-95 data-[exiting]:zoom-out-95",
right:
"data-[entering]:slide-in-from-right-10 data-[exiting]:slide-out-to-right-10 right-0 left-auto absolute",
bottom:
"data-[entering]:slide-in-from-bottom-10 data-[exiting]:slide-out-to-bottom-10 bottom-0 absolute",
left: "data-[entering]:slide-in-from-left-10 data-[exiting]:slide-out-to-left-10 left-0 right-auto absolute",
}
return twJoin(
"bg-white max-sm:px-4 p-6 rounded-xs max-h-full overflow-y-scroll max-w-154 w-full shadow-modal data-[entering]:animate-in data-[entering]:ease-out data-[entering]:duration-200 data-[exiting]:animate-out data-[exiting]:ease-in data-[exiting]:duration-100",
animateFromClasses[animateFrom]
)
}
export const UiModal: React.FC<
UiModalOwnProps & ReactAria.ModalOverlayProps
> = ({ animateFrom = "center", className, ...props }) => (
)
================================================
FILE: storefront/src/components/ui/Radio.tsx
================================================
"use client"
import * as ReactAria from "react-aria-components"
import { twMerge } from "tailwind-merge"
type UiRadioOwnProps = {
variant?: "ghost" | "outline"
}
export const UiRadioGroup: React.FC = ({
...props
}) =>
export const UiRadio: React.FC = ({
variant = "ghost",
className,
...props
}) => (
)
export const UiRadioBox: React.FC> = ({
className,
...props
}) => (
)
export const UiRadioLabel: React.FC> = ({
className,
...props
}) =>
================================================
FILE: storefront/src/components/ui/Select.tsx
================================================
"use client"
import * as ReactAria from "react-aria-components"
import { twMerge } from "tailwind-merge"
import { Icon, IconNames, IconProps } from "@/components/Icon"
type UiSelectButtonOwnProps = {
variant?: "outline" | "ghost"
}
export const UiSelectButton: React.FC<
ReactAria.ButtonProps & UiSelectButtonOwnProps
> = ({ variant = "outline", className, ...props }) => (
)
export const UiSelectIcon: React.FC<
Omit & { name?: IconNames }
> = ({ name = "chevron-down", className, ...props }) => (
)
export const UiSelectValue = ({
className,
...props
}: ReactAria.SelectValueProps) => (
)
export const UiSelectListBox = ({
className,
...props
}: ReactAria.ListBoxProps) => (
)
export const UiSelectListBoxItem: React.FC = ({
className,
...props
}) => (
)
export const UiSelectDialog: React.FC = ({
className,
...props
}) => (
)
================================================
FILE: storefront/src/components/ui/Skeleton.tsx
================================================
import { twMerge } from "tailwind-merge"
type SkeletonProps = {
colorScheme?: "white" | "grayscale"
} & React.ComponentPropsWithoutRef<"div">
export const Skeleton: React.FC = ({
colorScheme = "grayscale",
className,
...rest
}) => (
)
================================================
FILE: storefront/src/components/ui/Slider.tsx
================================================
import * as ReactAria from "react-aria-components"
import { twMerge } from "tailwind-merge"
export const UiSliderTrack: React.FC = ({
className,
...props
}) => (
)
export const UiSliderThumb: React.FC = ({
className,
...props
}) => (
)
export const UiSliderOutput: React.FC = ({
className,
...props
}) => (
)
export const UiSliderOutputValue: React.FC<
React.ComponentPropsWithoutRef<"span">
> = ({ className, ...props }) => (
)
================================================
FILE: storefront/src/components/ui/Tag.tsx
================================================
import * as React from "react"
import { twMerge } from "tailwind-merge"
import { Icon, IconNames } from "@/components/Icon"
type UiTagOwnProps = {
isActive?: boolean
iconName?: IconNames
iconPosition?: "start" | "end"
}
export const UiTag: React.FC<
React.ComponentPropsWithRef<"div"> & UiTagOwnProps
> = ({
isActive = false,
iconName,
iconPosition,
className,
children,
...rest
}) => (
{iconName && }
{children}
)
================================================
FILE: storefront/src/components/ui/TagList.tsx
================================================
import { twMerge } from "tailwind-merge"
export const UiTagList: React.FC> = ({
className,
...rest
}) => (
)
export const UiTagListDivider: React.FC<
React.ComponentPropsWithoutRef<"span">
> = ({ className, ...rest }) => (
)
================================================
FILE: storefront/src/hooks/cart.ts
================================================
import {
addToCart,
applyPromotions,
deleteLineItem,
getCartQuantity,
getPaymentMethod,
initiatePaymentSession,
placeOrder,
retrieveCart,
setAddresses,
setEmail,
setPaymentMethod,
setShippingMethod,
updateLineItem,
updateRegion,
} from "@lib/data/cart"
import { listCartShippingMethods } from "@lib/data/fulfillment"
import { listCartPaymentMethods } from "@lib/data/payment"
import { HttpTypes } from "@medusajs/types"
import {
useMutation,
UseMutationOptions,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
import { useCallback, useEffect, useRef, useState } from "react"
import { z } from "zod"
export const useCart = ({ enabled }: { enabled: boolean }) => {
return useQuery({
queryKey: ["cart"],
queryFn: async () => {
const res = await retrieveCart()
return res
},
enabled,
})
}
export const useCartQuantity = () => {
return useQuery({
queryKey: ["cart", "cart-quantity"],
queryFn: async () => {
const res = await getCartQuantity()
return res
},
})
}
export const useCartShippingMethods = (cartId: string) => {
return useQuery({
queryKey: [cartId],
queryFn: async () => {
const res = await listCartShippingMethods(cartId)
return res
},
})
}
export const useCartPaymentMethods = (regionId: string) => {
return useQuery({
queryKey: [regionId],
queryFn: async () => {
const res = await listCartPaymentMethods(regionId)
return res
},
})
}
type UpdateLineItemContext = {
previousCart: HttpTypes.StoreCart | null | undefined
}
const coerceMutationContext = (
context: UpdateLineItemContext | void | unknown
): UpdateLineItemContext => {
if (context && typeof context === "object") {
return context as UpdateLineItemContext
}
return { previousCart: undefined }
}
export const useUpdateLineItem = (
options?: UseMutationOptions<
void,
Error,
{ lineId: string; quantity: number },
UpdateLineItemContext
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ["cart-update-line-item"],
mutationFn: async (payload: { lineId: string; quantity: number }) => {
const response = await updateLineItem({
lineId: payload.lineId,
quantity: payload.quantity,
})
return response
},
...options,
onMutate: async ({ lineId, quantity, ...rest }, ...restArgs) => {
await queryClient.cancelQueries({ queryKey: ["cart"] })
const userContext = await options?.onMutate?.(
{ lineId, quantity, ...rest },
...restArgs
)
const previousCart = queryClient.getQueryData(
["cart"]
)
queryClient.setQueryData(
["cart"],
(old: HttpTypes.StoreCart | null | undefined) => {
if (!old) return old
return {
...old,
items: (old.items ?? []).map((cartItem) =>
cartItem.id === lineId ? { ...cartItem, quantity } : cartItem
),
}
}
)
const previousItem = previousCart?.items?.find((i) => i.id === lineId)
if (previousItem) {
const delta = quantity - previousItem.quantity
queryClient.setQueryData(
["cart", "cart-quantity"],
(old: number | undefined) => Math.max(0, (old ?? 0) + delta)
)
}
return { ...coerceMutationContext(userContext), previousCart }
},
onError: (error, variables, onMutateResult, context) => {
if (onMutateResult?.previousCart) {
queryClient.setQueryData(["cart"], onMutateResult.previousCart)
const total = (onMutateResult.previousCart.items ?? []).reduce(
(acc, i) => acc + i.quantity,
0
)
queryClient.setQueryData(["cart", "cart-quantity"], total)
}
options?.onError?.(error, variables, onMutateResult, context)
},
async onSuccess(...args) {
await queryClient.invalidateQueries({
exact: false,
queryKey: ["cart"],
})
await options?.onSuccess?.(...args)
},
})
}
type LineItemQuantityUpdater = {
quantity: number
error: Error | null
onQuantityChange: (value: number) => void
onQuantityCommit: (value: number) => void
onQuantityFocus: () => void
onQuantityBlur: () => void
}
export const useLineItemQuantityUpdater = ({
lineId,
initialQuantity,
}: {
lineId: string
initialQuantity: number
}): LineItemQuantityUpdater => {
const { mutateAsync, error, reset } = useUpdateLineItem({
onSuccess: () => {
reset()
},
})
const [quantity, setQuantity] = useState(initialQuantity)
const timerRef = useRef | null>(null)
const inFlightRef = useRef(false)
const queuedQuantityRef = useRef(null)
const lastCommittedRef = useRef(initialQuantity)
const isEditingRef = useRef(false)
useEffect(() => {
lastCommittedRef.current = initialQuantity
if (isEditingRef.current) return
setQuantity(initialQuantity)
}, [initialQuantity])
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
}, [])
const flushQuantityUpdate = useCallback(async () => {
if (inFlightRef.current) return
const quantityToCommit = queuedQuantityRef.current
if (
quantityToCommit === null ||
quantityToCommit === lastCommittedRef.current
) {
return
}
inFlightRef.current = true
queuedQuantityRef.current = null
lastCommittedRef.current = quantityToCommit
try {
await mutateAsync({ lineId, quantity: quantityToCommit })
} finally {
inFlightRef.current = false
if (queuedQuantityRef.current !== null) {
void flushQuantityUpdate()
}
}
}, [lineId, mutateAsync])
const scheduleQuantityUpdate = useCallback(
(nextQuantity: number) => {
queuedQuantityRef.current = nextQuantity
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
void flushQuantityUpdate()
}, 350)
},
[flushQuantityUpdate]
)
const onQuantityChange = useCallback(
(newQuantity: number) => {
setQuantity(newQuantity)
scheduleQuantityUpdate(newQuantity)
},
[scheduleQuantityUpdate]
)
const onQuantityCommit = useCallback(
(newQuantity: number) => {
queuedQuantityRef.current = newQuantity
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
void flushQuantityUpdate()
},
[flushQuantityUpdate]
)
const onQuantityFocus = useCallback(() => {
isEditingRef.current = true
}, [])
const onQuantityBlur = useCallback(() => {
isEditingRef.current = false
}, [])
return {
quantity,
error: error ?? null,
onQuantityChange,
onQuantityCommit,
onQuantityFocus,
onQuantityBlur,
}
}
type DeleteLineItemContext = {
previousCart: HttpTypes.StoreCart | null | undefined
}
export const useDeleteLineItem = (
options?: UseMutationOptions<
void,
Error,
{ lineId: string },
DeleteLineItemContext
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ["cart-delete-line-item"],
mutationFn: async (payload: { lineId: string }) => {
const response = await deleteLineItem(payload.lineId)
return response
},
...options,
onMutate: async ({ lineId }) => {
await queryClient.cancelQueries({ queryKey: ["cart"] })
const previousCart = queryClient.getQueryData(
["cart"]
)
queryClient.setQueryData(
["cart"],
(old: HttpTypes.StoreCart | null | undefined) => {
if (!old) return old
return {
...old,
items: (old.items ?? []).filter((item) => item.id !== lineId),
}
}
)
const removedItem = previousCart?.items?.find(
(item) => item.id === lineId
)
if (removedItem) {
queryClient.setQueryData(
["cart", "cart-quantity"],
(old: number | undefined) =>
Math.max(0, (old ?? 0) - removedItem.quantity)
)
}
return { previousCart }
},
onError: (error, variables, onMutateResult, context) => {
if (onMutateResult?.previousCart) {
queryClient.setQueryData(["cart"], onMutateResult.previousCart)
const total = (onMutateResult.previousCart.items ?? []).reduce(
(acc, item) => acc + item.quantity,
0
)
queryClient.setQueryData(["cart", "cart-quantity"], total)
}
options?.onError?.(error, variables, onMutateResult, context)
},
async onSuccess(...args) {
await queryClient.invalidateQueries({
exact: false,
queryKey: ["cart"],
})
await options?.onSuccess?.(...args)
},
})
}
export const useAddLineItem = (
options?: UseMutationOptions<
void,
Error,
{ variantId: string; quantity: number; countryCode: string | undefined },
unknown
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ["cart-add-line-item"],
mutationFn: async (payload: {
variantId: string
quantity: number
countryCode: string | undefined
}) => {
const response = await addToCart({ ...payload })
return response
},
...options,
async onSuccess(...args) {
await queryClient.invalidateQueries({
exact: false,
queryKey: ["cart"],
})
await options?.onSuccess?.(...args)
},
})
}
export const useSetShippingMethod = (
{ cartId }: { cartId: string },
options?: UseMutationOptions<
void,
Error,
{ shippingMethodId: string },
unknown
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ["shipping-update", cartId],
mutationFn: async ({ shippingMethodId }) => {
const response = await setShippingMethod({
cartId,
shippingMethodId,
})
return response
},
...options,
async onSuccess(...args) {
await queryClient.invalidateQueries({
exact: false,
queryKey: ["cart"],
})
await options?.onSuccess?.(...args)
},
})
}
export const addressesFormSchema = z
.object({
shipping_address: z.object({
first_name: z.string().min(1),
last_name: z.string().min(1),
company: z.string().optional(),
address_1: z.string().min(1),
address_2: z.string().optional(),
city: z.string().min(1),
postal_code: z.string().min(1),
province: z.string().optional(),
country_code: z.string().min(2),
phone: z.string().optional(),
}),
})
.and(
z.discriminatedUnion("same_as_billing", [
z.object({
same_as_billing: z.literal("on"),
}),
z.object({
same_as_billing: z.literal("off").optional(),
billing_address: z.object({
first_name: z.string().min(1),
last_name: z.string().min(1),
company: z.string().optional(),
address_1: z.string().min(1),
address_2: z.string().optional(),
city: z.string().min(1),
postal_code: z.string().min(1),
province: z.string().optional(),
country_code: z.string().min(2),
phone: z.string().optional(),
}),
}),
])
)
export const useSetShippingAddress = (
options?: UseMutationOptions<
{ success: boolean; error: string | null },
Error,
z.infer,
unknown
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ["shipping-address-update"],
mutationFn: async (payload) => {
const response = await setAddresses(payload)
return response
},
...options,
async onSuccess(...args) {
await queryClient.invalidateQueries({
exact: false,
queryKey: ["cart"],
})
await options?.onSuccess?.(...args)
},
})
}
export const useSetEmail = (
options?: UseMutationOptions<
{ success: boolean; error: string | null },
Error,
{ email: string; country_code: string },
unknown
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ["set-email"],
mutationFn: async (payload) => {
const response = await setEmail(payload)
return response
},
...options,
async onSuccess(...args) {
await queryClient.invalidateQueries({
exact: false,
queryKey: ["cart"],
})
await options?.onSuccess?.(...args)
},
})
}
export const useInitiatePaymentSession = (
options?: UseMutationOptions<
HttpTypes.StorePaymentCollectionResponse,
Error,
{
providerId: string
},
unknown
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ["initiate-payment"],
mutationFn: async (payload: { providerId: string }) => {
const response = await initiatePaymentSession(payload.providerId)
return response
},
...options,
async onSuccess(...args) {
await queryClient.invalidateQueries({
exact: false,
queryKey: ["cart"],
})
await options?.onSuccess?.(...args)
},
})
}
export const useSetPaymentMethod = (
options?: UseMutationOptions<
void,
Error,
{ sessionId: string; token: string | null | undefined },
unknown
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ["set-payment"],
mutationFn: async (payload) => {
const response = await setPaymentMethod(payload.sessionId, payload.token)
return response
},
...options,
async onSuccess(...args) {
await queryClient.invalidateQueries({
exact: false,
queryKey: ["cart"],
})
await options?.onSuccess?.(...args)
},
})
}
export const useGetPaymentMethod = (id: string | undefined) => {
return useQuery({
queryKey: ["payment", id],
queryFn: async () => {
if (!id) {
return null
}
const res = await getPaymentMethod(id)
return res
},
})
}
export const usePlaceOrder = (
options?: UseMutationOptions<
| {
type: "cart"
cart: HttpTypes.StoreCart
error: {
message: string
name: string
type: string
}
}
| {
type: "order"
order: HttpTypes.StoreOrder
}
| null,
Error,
null,
unknown
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ["place-order"],
mutationFn: async () => {
const response = await placeOrder()
return response
},
...options,
async onSuccess(...args) {
await queryClient.invalidateQueries({
exact: false,
queryKey: ["cart"],
})
await options?.onSuccess?.(...args)
},
})
}
export const useApplyPromotions = (
options?: UseMutationOptions
) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ["apply-promotion"],
mutationFn: async (payload) => {
const response = await applyPromotions(payload)
return response
},
...options,
async onSuccess(...args) {
await queryClient.invalidateQueries({
exact: false,
queryKey: ["cart"],
})
await options?.onSuccess?.(...args)
},
})
}
export const useUpdateRegion = (
options?: UseMutationOptions<
void,
Error,
{ countryCode: string; currentPath: string },
unknown
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ["update-region"],
mutationFn: async ({ countryCode, currentPath }) => {
await updateRegion(countryCode, currentPath)
},
...options,
async onSuccess(...args) {
await queryClient.invalidateQueries({
exact: false,
queryKey: ["cart"],
})
await queryClient.invalidateQueries({
exact: false,
queryKey: ["regions"],
})
await queryClient.invalidateQueries({
exact: false,
queryKey: ["products"],
})
await options?.onSuccess?.(...args)
},
})
}
================================================
FILE: storefront/src/hooks/country-code.tsx
================================================
import { useParams, usePathname } from "next/navigation"
export const useCountryCode = (
countryOptions?: {
country: string | undefined
region: string
label: string | undefined
}[]
) => {
const pathName = usePathname()
const params = useParams()
if (typeof params.countryCode === "string") {
return params.countryCode
}
if (countryOptions) {
// Check if the path contains a country code and update the current path
const pathParts = pathName.replace(/^\//, "").split("/")
if (pathParts.length > 1) {
const firstPathPart = pathParts[0]
const country = countryOptions.find(
(country) => country.country === firstPathPart
)
if (country) {
return country.country
}
}
} else {
const pathParts = pathName.replace(/^\//, "").split("/")
if (pathParts.length > 1 && pathParts[0].length === 2) {
return pathParts[0]
}
}
}
================================================
FILE: storefront/src/hooks/customer.ts
================================================
import {
useMutation,
UseMutationOptions,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
import {
addCustomerAddress,
deleteCustomerAddress,
getCustomer,
login,
signout,
signup,
updateCustomer,
updateCustomerAddress,
} from "@lib/data/customer"
import { z } from "zod"
import { StoreCustomer } from "@medusajs/types"
export const useCustomer = () => {
return useQuery({
queryKey: ["customer"],
queryFn: async () => {
const customer = await getCustomer()
return customer
},
staleTime: 5 * 60 * 1000,
})
}
export const loginFormSchema = z.object({
email: z.string().email(),
password: z.string().min(6),
redirect_url: z.string().optional().nullable(),
})
export const useLogin = (
options?: UseMutationOptions<
{ success: boolean; redirectUrl?: string; message?: string },
Error,
z.infer
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ["login"],
mutationFn: async (values: z.infer) => {
return login({ ...values })
},
onSuccess: async (...args) => {
await queryClient.invalidateQueries({ queryKey: ["customer"] })
await options?.onSuccess?.(...args)
},
...options,
})
}
export const useSignout = (
options?: UseMutationOptions
) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ["signout"],
mutationFn: async (countryCode: string) => {
return signout(countryCode)
},
onSuccess: async (...args) => {
await queryClient.invalidateQueries({ queryKey: ["customer"] })
await options?.onSuccess?.(...args)
},
...options,
})
}
export const updateCustomerFormSchema = z.object({
first_name: z.string().min(1),
last_name: z.string().min(1),
phone: z.string().optional().nullable(),
})
export const useUpdateCustomer = (
options?: UseMutationOptions<
{ state: "error" | "success" | "initial"; error?: string },
Error,
z.infer
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ["update-customer"],
mutationFn: async (values: z.infer) => {
return updateCustomer(values)
},
onSuccess: async (...args) => {
await queryClient.invalidateQueries({ queryKey: ["customer"] })
await options?.onSuccess?.(...args)
},
...options,
})
}
export const customerAddressSchema = z.object({
first_name: z.string().min(1),
last_name: z.string().min(1),
company: z.string().optional().nullable(),
address_1: z.string().min(1),
address_2: z.string().optional().nullable(),
city: z.string().min(1),
postal_code: z.string().min(1),
province: z.string().optional().nullable(),
country_code: z.string().min(2),
phone: z.string().optional().nullable(),
})
export const useAddressMutation = (
addressId?: string,
options?: UseMutationOptions<
{ addressId: string; success: boolean; error: string | null },
Error,
z.infer
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ["add-address", "update-address"],
mutationFn: async (values: z.infer) => {
return addressId
? updateCustomerAddress(addressId, values)
: addCustomerAddress(values)
},
onSuccess: async (...args) => {
await queryClient.invalidateQueries({ queryKey: ["customer"] })
await options?.onSuccess?.(...args)
},
...options,
})
}
export const useDeleteCustomerAddress = (
options?: UseMutationOptions
) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ["delete-address"],
mutationFn: async (addressId: string) => {
return deleteCustomerAddress(addressId)
},
onSuccess: async (...args) => {
await queryClient.invalidateQueries({ queryKey: ["customer"] })
await options?.onSuccess?.(...args)
},
...options,
})
}
export const signupFormSchema = z.object({
email: z.string().email(),
first_name: z.string().min(1),
last_name: z.string().min(1),
phone: z.string().optional().nullable(),
password: z.string().min(6),
})
export const useSignup = (
options?: UseMutationOptions<
{ success: boolean; error?: string | null; customer?: StoreCustomer },
Error,
z.infer
>
) => {
const queryClient = useQueryClient()
return useMutation({
mutationKey: ["signup"],
mutationFn: async (values: z.infer) => {
return signup(values)
},
onSuccess: async (...args) => {
await queryClient.invalidateQueries({ queryKey: ["customer"] })
await options?.onSuccess?.(...args)
},
...options,
})
}
================================================
FILE: storefront/src/hooks/store.tsx
================================================
import { getProductsListWithSort } from "@lib/data/products"
import { HttpTypes } from "@medusajs/types"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
import { useInfiniteQuery } from "@tanstack/react-query"
export const useStoreProducts = ({
page,
queryParams,
sortBy,
countryCode,
}: {
page: number
queryParams: HttpTypes.StoreProductListParams
sortBy: SortOptions | undefined
countryCode: string
}) => {
return useInfiniteQuery({
initialPageParam: page,
queryKey: ["products", queryParams, sortBy, countryCode],
queryFn: async ({ pageParam }) => {
return getProductsListWithSort({
page: pageParam,
queryParams,
sortBy,
countryCode,
})
},
getNextPageParam: (lastPage: {
response: { products: HttpTypes.StoreProduct[]; count: number }
nextPage: number | null
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
}) => {
if (!lastPage.nextPage) {
return undefined
}
return (
Math.ceil(lastPage.nextPage / (lastPage.queryParams?.limit || 12)) + 1
)
},
})
}
================================================
FILE: storefront/src/lib/config.ts
================================================
import Medusa from "@medusajs/js-sdk"
// Defaults to standard port for Medusa server
let MEDUSA_BACKEND_URL = "http://localhost:9000"
if (process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL) {
MEDUSA_BACKEND_URL = process.env.NEXT_PUBLIC_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,
})
================================================
FILE: storefront/src/lib/constants.tsx
================================================
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: ,
},
"pp_stripe-ideal_stripe": {
title: "iDeal",
icon: ,
},
"pp_stripe-bancontact_stripe": {
title: "Bancontact",
icon: ,
},
pp_paypal_paypal: {
title: "PayPal",
icon: ,
},
pp_system_default: {
title: "Manual Payment",
icon: ,
},
// 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")
}
================================================
FILE: storefront/src/lib/data/cart.ts
================================================
"use server"
import { HttpTypes } from "@medusajs/types"
import { revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import { z } from "zod"
import { PaymentMethod } from "@stripe/stripe-js"
import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { enrichLineItems } from "@lib/util/enrich-line-items"
import {
getCartId,
getAuthHeaders,
setCartId,
removeCartId,
} from "@lib/data/cookies"
import { getRegion } from "@lib/data/regions"
import { addressesFormSchema } from "hooks/cart"
export async function retrieveCart() {
const cartId = await getCartId()
if (!cartId) {
return null
}
const cart = await sdk.client
.fetch(`/store/carts/${cartId}`, {
next: { tags: ["cart"] },
headers: { ...(await getAuthHeaders()) },
cache: "no-store",
})
.then(({ cart }) => cart)
.catch(() => {
return null
})
if (cart?.items && cart.items.length && cart.region_id) {
cart.items = await enrichLineItems(cart.items, cart.region_id)
}
return cart
}
export async function getCartQuantity() {
const cart = await retrieveCart()
if (!cart || !cart.items || !cart.items.length) {
return 0
}
return cart.items.reduce((acc, item) => acc + item.quantity, 0)
}
export async function getOrSetCart(input: unknown) {
if (typeof input !== "string") {
throw new Error("Invalid input when retrieving cart")
}
const countryCode = input
let cart = await retrieveCart()
const region = await getRegion(countryCode)
if (!region) {
throw new Error(`Region not found for country code: ${countryCode}`)
}
if (!cart) {
const cartResp = await sdk.store.cart.create(
{ region_id: region.id },
{},
await getAuthHeaders()
)
cart = cartResp.cart
await setCartId(cart.id)
revalidateTag("cart")
}
if (cart && cart?.region_id !== region.id) {
await sdk.store.cart.update(
cart.id,
{ region_id: region.id },
{},
await getAuthHeaders()
)
revalidateTag("cart")
}
return cart
}
async function updateCart(data: HttpTypes.StoreUpdateCart) {
const cartId = await getCartId()
if (!cartId) {
throw new Error("No existing cart found, please create one before updating")
}
return sdk.store.cart
.update(cartId, data, {}, await getAuthHeaders())
.then(({ cart }) => {
revalidateTag("cart")
return cart
})
.catch(medusaError)
}
export async function addToCart({
variantId,
quantity,
countryCode,
}: {
variantId: unknown
quantity: unknown
countryCode: unknown
}) {
if (typeof variantId !== "string") {
throw new Error("Missing variant ID when adding to cart")
}
if (
typeof quantity !== "number" ||
quantity < 1 ||
!Number.isSafeInteger(quantity)
) {
throw new Error("Missing quantity when adding to cart")
}
if (typeof countryCode !== "string") {
throw new Error("Missing country code when adding to cart")
}
const cart = await getOrSetCart(countryCode)
if (!cart) {
throw new Error("Error retrieving or creating cart")
}
await sdk.store.cart
.createLineItem(
cart.id,
{
variant_id: variantId,
quantity,
},
{},
await getAuthHeaders()
)
.then(() => {
revalidateTag("cart")
})
.catch(medusaError)
}
export async function updateLineItem({
lineId,
quantity,
}: {
lineId: unknown
quantity: unknown
}) {
if (typeof lineId !== "string") {
throw new Error("Missing lineItem ID when updating line item")
}
if (
typeof quantity !== "number" ||
quantity < 1 ||
!Number.isSafeInteger(quantity)
) {
throw new Error("Missing quantity when updating line item")
}
const cartId = await getCartId()
if (!cartId) {
throw new Error("Missing cart ID when updating line item")
}
await sdk.store.cart
.updateLineItem(cartId, lineId, { quantity }, {}, await getAuthHeaders())
.then(() => {
revalidateTag("cart")
})
.catch(medusaError)
}
export async function deleteLineItem(lineId: unknown) {
if (typeof lineId !== "string") {
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")
}
await sdk.store.cart
.deleteLineItem(cartId, lineId, undefined, await getAuthHeaders())
.then(() => {
revalidateTag("cart")
})
.catch(medusaError)
revalidateTag("cart")
}
export async function setShippingMethod({
cartId,
shippingMethodId,
}: {
cartId: unknown
shippingMethodId: unknown
}) {
if (typeof cartId !== "string") {
throw new Error("Missing cart ID when setting shipping method")
}
if (typeof shippingMethodId !== "string") {
throw new Error("Missing shipping method ID when setting shipping method")
}
return sdk.store.cart
.addShippingMethod(
cartId,
{ option_id: shippingMethodId },
{},
await getAuthHeaders()
)
.then(() => {
revalidateTag("cart")
})
.catch(medusaError)
}
export async function setPaymentMethod(
session_id: string,
token: string | null | undefined
) {
await sdk.client
.fetch("/store/custom/stripe/set-payment-method", {
method: "POST",
body: { session_id, token },
})
.then((resp) => {
revalidateTag("cart")
return resp
})
.catch(medusaError)
}
export async function getPaymentMethod(id: string) {
return await sdk.client
.fetch(`/store/custom/stripe/get-payment-method/${id}`)
.then((resp: PaymentMethod) => {
return resp
})
.catch(medusaError)
}
export async function initiatePaymentSession(provider_id: unknown) {
const cart = await retrieveCart()
if (!cart) {
throw new Error("Can't initiate payment without cart")
}
if (typeof provider_id !== "string") {
throw new Error("Invalid payment provider")
}
return sdk.store.payment
.initiatePaymentSession(
cart,
{
provider_id,
},
{},
await getAuthHeaders()
)
.then((resp) => {
revalidateTag("cart")
return resp
})
.catch(medusaError)
}
export async function applyPromotions(codes: string[]) {
const cartId = await getCartId()
if (!cartId) {
throw new Error("No existing cart found")
}
await updateCart({ promo_codes: codes })
.then(() => {
revalidateTag("cart")
})
.catch(medusaError)
}
export async function removePromotions(codes: string[]) {
const cartId = await getCartId()
if (!cartId) {
throw new Error("No existing cart found")
}
if (!Array.isArray(codes) || !codes.length) {
throw new Error("No promotion codes provided")
}
if (codes.some((code) => typeof code !== "string" || !code.trim())) {
throw new Error("Invalid promotion codes")
}
await sdk.client
.fetch(`/store/carts/${cartId}/promotions`, {
method: "DELETE",
body: { promo_codes: codes },
headers: { ...(await getAuthHeaders()) },
})
.then(() => {
revalidateTag("cart")
})
.catch(medusaError)
}
export async function setEmail({
email,
country_code,
}: {
email: string
country_code: string
}) {
try {
const cartId = await getCartId()
if (!cartId) {
throw new Error("No existing cart found when setting addresses")
}
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Could not get your cart",
}
}
const countryCode = z.string().min(2).safeParse(country_code)
if (!countryCode.success) {
return { success: false, error: "Invalid country code" }
}
await updateCart({ email })
return { success: true, error: null }
}
export async function setAddresses(
formData: z.infer
) {
try {
if (!formData) {
throw new Error("No form data found when setting addresses")
}
const cartId = await getCartId()
if (!cartId) {
throw new Error("No existing cart found when setting addresses")
}
await updateCart({
shipping_address: formData.shipping_address,
billing_address:
formData.same_as_billing === "on"
? formData.shipping_address
: formData.billing_address,
})
revalidateTag("shipping")
return { success: true, error: null }
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : "Could not set addresses",
}
}
}
export async function placeOrder() {
const cartId = await getCartId()
if (!cartId) {
throw new Error("No existing cart found when placing an order")
}
const cartRes = await sdk.store.cart
.complete(cartId, {}, await getAuthHeaders())
.then((cartRes) => {
revalidateTag("cart")
revalidateTag("orders")
return cartRes
})
.catch(medusaError)
if (cartRes?.type === "order") {
await removeCartId()
}
return cartRes
}
/**
* Updates the countryCode param and revalidate the regions cache
* @param regionId
* @param countryCode
*/
export async function updateRegion(countryCode: string, currentPath: string) {
if (typeof countryCode !== "string") {
throw new Error("Invalid country code")
}
if (typeof currentPath !== "string") {
throw new Error("Invalid current path")
}
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 })
revalidateTag("cart")
}
revalidateTag("regions")
revalidateTag("products")
redirect(`/${countryCode}${currentPath}`)
}
================================================
FILE: storefront/src/lib/data/categories.ts
================================================
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
export const listCategories = async function () {
return sdk.client
.fetch<{ product_categories: HttpTypes.StoreProductCategory[] }>(
"/store/product-categories",
{
query: { fields: "+category_children" },
next: { tags: ["categories"] },
cache: "force-cache",
}
)
.then(({ product_categories }) => product_categories)
}
export const getCategoriesList = async function (
offset: number = 0,
limit: number = 100,
fields?: (keyof HttpTypes.StoreProductCategory)[]
) {
return sdk.client.fetch<{
product_categories: HttpTypes.StoreProductCategory[]
}>("/store/product-categories", {
query: {
limit,
offset,
fields: fields ? fields.join(",") : undefined,
},
next: { tags: ["categories"] },
cache: "force-cache",
})
}
export const getCategoryByHandle = async function (categoryHandle: string[]) {
return sdk.client.fetch(
`/store/product-categories`,
{
query: { handle: categoryHandle },
next: { tags: ["categories"] },
cache: "force-cache",
}
)
}
================================================
FILE: storefront/src/lib/data/collections.ts
================================================
import { sdk } from "@lib/config"
import { getProductsList } from "@lib/data/products"
import { HttpTypes } from "@medusajs/types"
export const retrieveCollection = async function (id: string) {
return sdk.client
.fetch<{ collection: HttpTypes.StoreCollection }>(
`/store/collections/${id}`,
{
next: { tags: ["collections"] },
cache: "force-cache",
}
)
.then(({ collection }) => collection)
}
export const getCollectionsList = async function (
offset: number = 0,
limit: number = 100,
fields?: (keyof HttpTypes.StoreCollection)[]
): Promise<{ collections: HttpTypes.StoreCollection[]; count: number }> {
return sdk.client
.fetch<{
collections: HttpTypes.StoreCollection[]
count: number
}>("/store/collections", {
query: { limit, offset, fields: fields ? fields.join(",") : undefined },
next: { tags: ["collections"] },
cache: "force-cache",
})
.then(({ collections }) => ({ collections, count: collections.length }))
}
export const getCollectionByHandle = async function (
handle: string,
fields?: (keyof HttpTypes.StoreCollection)[]
): Promise {
return sdk.client
.fetch(`/store/collections`, {
query: {
handle,
fields: fields ? fields.join(",") : undefined,
limit: 1,
},
next: { tags: ["collections"] },
cache: "force-cache",
})
.then(({ collections }) => collections[0])
}
export const getCollectionsWithProducts = async (
countryCode: string
): Promise => {
const { collections } = await getCollectionsList(0, 3)
if (!collections) {
return null
}
const collectionIds = collections
.map((collection) => collection.id)
.filter(Boolean) as string[]
const { response } = await getProductsList({
queryParams: { collection_id: collectionIds },
countryCode,
})
response.products.forEach((product) => {
const collection = collections.find(
(collection) => collection.id === product.collection_id
)
if (collection) {
if (!collection.products) {
collection.products = []
}
collection.products.push(product)
}
})
return collections as unknown as HttpTypes.StoreCollection[]
}
================================================
FILE: storefront/src/lib/data/cookies.ts
================================================
import "server-only"
import { cookies } from "next/headers"
export const getAuthHeaders = async (): Promise<
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
{ authorization: string } | {}
> => {
const token = (await cookies()).get("_medusa_jwt")?.value
if (token) {
return { authorization: `Bearer ${token}` }
}
return {}
}
export const setAuthToken = async (token: string) => {
return (await cookies()).set("_medusa_jwt", token, {
maxAge: 60 * 60 * 24 * 7,
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
})
}
export const removeAuthToken = async () => {
return (await cookies()).set("_medusa_jwt", "", {
maxAge: -1,
})
}
export const getCartId = async () => {
return (await cookies()).get("_medusa_cart_id")?.value
}
export const setCartId = async (cartId: string) => {
return (await 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 () => {
return (await cookies()).set("_medusa_cart_id", "", { maxAge: -1 })
}
================================================
FILE: storefront/src/lib/data/customer.ts
================================================
"use server"
import { z } from "zod"
import { redirect } from "next/navigation"
import { revalidateTag } from "next/cache"
import { HttpTypes } from "@medusajs/types"
import { sdk } from "@lib/config"
import {
getAuthHeaders,
setAuthToken,
removeAuthToken,
getCartId,
} from "@lib/data/cookies"
import {
customerAddressSchema,
loginFormSchema,
signupFormSchema,
updateCustomerFormSchema,
} from "hooks/customer"
export const getCustomer = async function () {
return await sdk.client
.fetch<{ customer: HttpTypes.StoreCustomer }>(`/store/customers/me`, {
next: { tags: ["customer"] },
headers: { ...(await getAuthHeaders()) },
cache: "no-store",
})
.then(({ customer }) => customer)
.catch(() => null)
}
export const updateCustomer = async function (
formData: z.infer
): Promise<
{ state: "initial" | "success" } | { state: "error"; error: string }
> {
return sdk.store.customer
.update(
{
first_name: formData.first_name,
last_name: formData.last_name,
phone: formData.phone ?? undefined,
},
{},
await getAuthHeaders()
)
.then(() => {
revalidateTag("customer")
return {
state: "success" as const,
}
})
.catch(() => {
revalidateTag("customer")
return {
state: "error" as const,
error: "Failed to update customer personal information",
}
})
}
export async function signup(formData: z.infer) {
try {
const token = await sdk.auth.register("customer", "emailpass", {
email: formData.email,
password: formData.password,
})
const customHeaders = { authorization: `Bearer ${token}` }
const { customer: createdCustomer } = await sdk.store.customer.create(
{
email: formData.email,
first_name: formData.first_name,
last_name: formData.last_name,
phone: formData.phone ?? undefined,
},
{},
customHeaders
)
const loginToken = await sdk.auth.login("customer", "emailpass", {
email: formData.email,
password: formData.password,
})
if (typeof loginToken === "object") {
redirect(loginToken.location)
return { success: true, customer: createdCustomer }
}
await setAuthToken(loginToken)
await sdk.client.fetch("/store/custom/customer/send-welcome-email", {
method: "POST",
headers: await getAuthHeaders(),
})
revalidateTag("customer")
const cartId = await getCartId()
if (cartId) {
await sdk.store.cart.transferCart(cartId, {}, await getAuthHeaders())
revalidateTag("cart")
}
return { success: true, customer: createdCustomer }
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : `${error}`,
}
}
}
export async function login(formData: z.infer) {
const redirectUrl = formData.redirect_url
try {
const token = await sdk.auth.login("customer", "emailpass", {
email: formData.email,
password: formData.password,
})
if (typeof token === "object") {
return { success: true, redirectUrl: token.location }
}
await setAuthToken(token)
revalidateTag("customer")
const cartId = await getCartId()
if (cartId) {
await sdk.store.cart.transferCart(cartId, {}, await getAuthHeaders())
revalidateTag("cart")
}
return { success: true, redirectUrl: redirectUrl || "/" }
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : `${error}`,
}
}
}
export async function signout(countryCode: string) {
await sdk.auth.logout()
await removeAuthToken()
revalidateTag("customer")
return countryCode
}
export const addCustomerAddress = async (
formData: z.infer
) => {
return sdk.store.customer
.createAddress(
{
first_name: formData.first_name,
last_name: formData.last_name,
company: formData.company ?? undefined,
address_1: formData.address_1,
address_2: formData.address_2 ?? undefined,
city: formData.city,
postal_code: formData.postal_code,
province: formData.province ?? undefined,
country_code: formData.country_code,
phone: formData.phone ?? undefined,
},
{},
await getAuthHeaders()
)
.then(({ customer }) => {
revalidateTag("customer")
return {
addressId: customer.addresses[customer.addresses.length - 1].id,
success: true,
error: null,
}
})
.catch((err) => {
revalidateTag("customer")
return { addressId: "", success: false, error: err.toString() }
})
}
export const deleteCustomerAddress = async (
addressId: unknown
): Promise => {
if (typeof addressId !== "string") {
throw new Error("Invalid input data")
}
await sdk.store.customer
.deleteAddress(addressId, await getAuthHeaders())
.then(() => {
return { success: true, error: null }
})
.catch((err) => {
return { success: false, error: err.toString() }
})
revalidateTag("customer")
}
export const updateCustomerAddress = async (
addressId: string,
formData: z.infer
) => {
if (!addressId) {
throw new Error("Invalid input data")
}
return sdk.store.customer
.updateAddress(
addressId,
{
first_name: formData.first_name,
last_name: formData.last_name,
company: formData.company ?? undefined,
address_1: formData.address_1,
address_2: formData.address_2 ?? undefined,
city: formData.city,
postal_code: formData.postal_code,
province: formData.province ?? undefined,
country_code: formData.country_code,
phone: formData.phone ?? undefined,
},
{},
await getAuthHeaders()
)
.then(() => {
revalidateTag("customer")
return { addressId, success: true, error: null }
})
.catch((err) => {
revalidateTag("customer")
return { addressId, success: false, error: err.toString() }
})
}
export async function requestPasswordReset() {
const customer = await getCustomer()
if (!customer) {
return {
success: false as const,
error: "No customer found",
}
}
await sdk.auth.resetPassword("logged-in-customer", "emailpass", {
identifier: customer.email,
})
return {
success: true as const,
}
}
const resetPasswordStateSchema = z.object({
email: z.string().email(),
token: z.string(),
})
const resetPasswordFormSchema = z.object({
type: z.literal("reset"),
current_password: z.string().min(6),
new_password: z.string().min(6),
confirm_new_password: z.string().min(6),
})
const forgotPasswordSchema = z.object({
type: z.literal("forgot"),
new_password: z.string().min(6),
confirm_new_password: z.string().min(6),
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const baseSchema = z.discriminatedUnion("type", [
resetPasswordFormSchema,
forgotPasswordSchema,
])
export async function resetPassword(
currentState: unknown,
formData: z.infer
): Promise<
z.infer &
({ state: "initial" | "success" } | { state: "error"; error: string })
> {
const validatedState = resetPasswordStateSchema.parse(currentState)
if (formData.type === "reset") {
try {
await sdk.auth.login("customer", "emailpass", {
email: validatedState.email,
password: formData.current_password,
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
return {
...validatedState,
state: "error" as const,
error: "Wrong password",
}
}
}
return sdk.auth
.updateProvider(
formData.type === "reset" ? "logged-in-customer" : "customer",
"emailpass",
{
email: validatedState.email,
password: formData.new_password,
},
validatedState.token
)
.then(() => {
return {
...validatedState,
state: "success" as const,
}
})
.catch(() => {
return {
...validatedState,
state: "error" as const,
error: "Failed to update password",
}
})
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const forgotPasswordFormSchema = z.object({
email: z.string().email(),
})
export async function forgotPassword(
_currentState: unknown,
formData: z.infer
): Promise<
{ state: "initial" | "success" } | { state: "error"; error: string }
> {
return sdk.auth
.resetPassword("customer", "emailpass", {
identifier: formData.email,
})
.then(() => {
return {
state: "success" as const,
}
})
.catch(() => {
return {
state: "error" as const,
error: "Failed to reset password",
}
})
}
export async function updateDefaultShippingAddress(addressId: string) {
if (!addressId) {
return { success: false, error: "No address id provided" }
}
return sdk.store.customer
.updateAddress(
addressId,
{
is_default_shipping: true,
},
{},
await getAuthHeaders()
)
.then(() => {
revalidateTag("customer")
return { success: true, error: null }
})
.catch((err) => {
revalidateTag("customer")
return { success: false, error: err.toString() }
})
}
export async function updateDefaultBillingAddress(addressId: string) {
if (!addressId) {
return { success: false, error: "No address id provided" }
}
return sdk.store.customer
.updateAddress(
addressId,
{
is_default_billing: true,
},
{},
await getAuthHeaders()
)
.then(() => {
revalidateTag("customer")
return { success: true, error: null }
})
.catch((err) => {
revalidateTag("customer")
return { success: false, error: err.toString() }
})
}
================================================
FILE: storefront/src/lib/data/fulfillment.ts
================================================
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
// Shipping actions
export const listCartShippingMethods = async function (cartId: string) {
return sdk.client
.fetch(
`/store/shipping-options`,
{
query: { cart_id: cartId },
next: { tags: ["shipping"] },
cache: "force-cache",
}
)
.then(({ shipping_options }) => shipping_options)
.catch(() => {
return null
})
}
================================================
FILE: storefront/src/lib/data/orders.ts
================================================
"use server"
import { cache } from "react"
import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { enrichLineItems } from "@lib/util/enrich-line-items"
import { getAuthHeaders } from "@lib/data/cookies"
import { HttpTypes } from "@medusajs/types"
export const retrieveOrder = cache(async (id: unknown) => {
if (typeof id !== "string") {
throw new Error("Invalid order id")
}
const order = await sdk.client
.fetch(`/store/orders/${id}`, {
query: { fields: "*payment_collections.payments" },
next: { tags: ["orders"] },
headers: { ...(await getAuthHeaders()) },
})
.then(({ order }) => order)
.catch((err) => medusaError(err))
if (order.items?.length && order.region_id) {
order.items = await enrichLineItems(order.items, order.region_id)
}
return order
})
export const listOrders = async function (
limit: number = 10,
offset: number = 0
) {
if (
typeof limit !== "number" ||
typeof offset !== "number" ||
limit < 1 ||
offset < 0 ||
limit > 100 ||
!Number.isSafeInteger(offset)
) {
throw new Error("Invalid input data")
}
return sdk.client
.fetch(`/store/orders`, {
query: { limit, offset, order: "-created_at" },
next: { tags: ["orders"] },
headers: { ...(await getAuthHeaders()) },
})
.catch((err) => medusaError(err))
}
================================================
FILE: storefront/src/lib/data/payment.ts
================================================
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
// Shipping actions
export const listCartPaymentMethods = async function (regionId: string) {
return sdk.client
.fetch(
`/store/payment-providers`,
{
query: { region_id: regionId },
next: { tags: ["payment_providers"] },
cache: "force-cache",
}
)
.then(({ payment_providers }) => payment_providers)
.catch(() => {
return null
})
}
================================================
FILE: storefront/src/lib/data/product-types.ts
================================================
import { sdk } from "@lib/config"
import { HttpTypes, PaginatedResponse } from "@medusajs/types"
export const getProductTypesList = async function (
offset: number = 0,
limit: number = 100,
fields?: (keyof HttpTypes.StoreProductType)[]
): Promise<{ productTypes: HttpTypes.StoreProductType[]; count: number }> {
return sdk.client
.fetch<
PaginatedResponse<{
product_types: HttpTypes.StoreProductType[]
count: number
}>
>("/store/custom/product-types", {
query: { limit, offset, fields: fields ? fields.join(",") : undefined },
next: { tags: ["product-types"] },
cache: "force-cache",
})
.then(({ product_types, count }) => ({
productTypes: product_types,
count,
}))
}
export const getProductTypeByHandle = async function (
handle: string
): Promise {
return sdk.client
.fetch<
PaginatedResponse<{
product_types: HttpTypes.StoreProductType[]
count: number
}>
>("/store/custom/product-types", {
query: { handle, limit: 1 },
next: { tags: ["product-types"] },
cache: "force-cache",
})
.then(({ product_types }) => product_types[0])
}
================================================
FILE: storefront/src/lib/data/products.ts
================================================
"use server"
import { sdk } from "@lib/config"
import { HttpTypes } from "@medusajs/types"
import { getRegion } from "@lib/data/regions"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
import { sortProducts } from "@lib/util/sort-products"
export const getProductsById = async function ({
ids,
regionId,
}: {
ids: string[]
regionId: string
}) {
return sdk.client
.fetch<{ products: HttpTypes.StoreProduct[] }>(`/store/products`, {
query: {
id: ids,
region_id: regionId,
fields: "*variants.calculated_price,+variants.inventory_quantity",
} satisfies HttpTypes.StoreProductListParams,
next: { tags: ["products"] },
cache: "force-cache",
})
.then(({ products }) => products)
}
export const getProductByHandle = async function (
handle: string,
regionId: string
) {
return sdk.client
.fetch<{ products: HttpTypes.StoreProduct[] }>(`/store/products`, {
query: {
handle,
region_id: regionId,
fields: "*variants.calculated_price,+variants.inventory_quantity",
} satisfies HttpTypes.StoreProductListParams,
next: { tags: ["products"] },
})
.then(({ products }) => products[0])
}
export const getProductFashionDataByHandle = async function (handle: string) {
return sdk.client.fetch<{
materials: {
id: string
name: string
colors: {
id: string
name: string
hex_code: string
}[]
}[]
}>(`/store/custom/fashion/${handle}`, {
method: "GET",
next: { tags: ["products"] },
cache: "force-cache",
})
}
export const getProductsList = async function ({
pageParam = 1,
queryParams,
countryCode,
}: {
pageParam?: number
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductListParams
countryCode: string
}): Promise<{
response: { products: HttpTypes.StoreProduct[]; count: number }
nextPage: number | null
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductListParams
}> {
const page = Math.max(1, pageParam || 1)
const limit = queryParams?.limit || 12
const offset = (page - 1) * limit
const region = await getRegion(countryCode)
if (!region) {
return {
response: { products: [], count: 0 },
nextPage: null,
}
}
return sdk.client
.fetch<{ products: HttpTypes.StoreProduct[]; count: number }>(
`/store/products`,
{
query: {
limit,
offset,
region_id: region.id,
fields: "*variants.calculated_price",
...queryParams,
} satisfies HttpTypes.StoreProductListParams,
next: { tags: ["products"] },
cache: "force-cache",
}
)
.then(({ products, count }) => {
const nextPage = count > offset + limit ? page + 1 : null
return {
response: {
products,
count,
},
nextPage,
queryParams,
}
})
}
/**
* This will fetch 100 products to the Next.js cache and sort them based on the sortBy parameter.
* It will then return the paginated products based on the page and limit parameters.
*/
export const getProductsListWithSort = async function ({
page = 1,
queryParams,
sortBy = "created_at",
countryCode,
}: {
page?: number
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
sortBy?: SortOptions
countryCode: string
}): Promise<{
response: { products: HttpTypes.StoreProduct[]; count: number }
nextPage: number | null
queryParams?: HttpTypes.FindParams & HttpTypes.StoreProductParams
}> {
const limit = queryParams?.limit || 12
const {
response: { products, count },
} = await getProductsList({
pageParam: 0,
queryParams: {
...queryParams,
limit: 100,
},
countryCode,
})
const sortedProducts = sortProducts(products, sortBy)
const pageParam = (page - 1) * limit
const nextPage = count > pageParam + limit ? pageParam + limit : null
const paginatedProducts = sortedProducts.slice(pageParam, pageParam + limit)
return {
response: {
products: paginatedProducts,
count,
},
nextPage,
queryParams,
}
}
================================================
FILE: storefront/src/lib/data/regions.ts
================================================
"use server"
import { sdk } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
import { HttpTypes } from "@medusajs/types"
export const listRegions = async function () {
return sdk.client
.fetch<{ regions: HttpTypes.StoreRegion[] }>(`/store/regions`, {
method: "GET",
next: { tags: ["regions"] },
cache: "force-cache",
})
.then(({ regions }) => regions)
.catch(medusaError)
}
export const retrieveRegion = async function (id: string) {
return sdk.client
.fetch<{ region: HttpTypes.StoreRegion }>(`/store/regions/${id}`, {
method: "GET",
next: { tags: [`regions`] },
cache: "force-cache",
})
.then(({ region }) => region)
.catch(medusaError)
}
const regionMap = new Map()
export const getRegion = async function (countryCode: string) {
try {
if (regionMap.has(countryCode)) {
return regionMap.get(countryCode)
}
const regions = await listRegions()
if (!regions) {
return null
}
regions.forEach((region) => {
region.countries?.forEach((c) => {
regionMap.set(c?.iso_2 ?? "", region)
})
})
const region = countryCode
? regionMap.get(countryCode)
: regionMap.get("us")
return region
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
return null
}
}
================================================
FILE: storefront/src/lib/search-client.ts
================================================
import { MeiliSearch } from "meilisearch"
const endpoint =
process.env.NEXT_PUBLIC_SEARCH_ENDPOINT || "http://localhost:7700"
const apiKey = process.env.NEXT_PUBLIC_SEARCH_API_KEY || "test_key"
export interface MeiliSearchProductHit {
id: string
handle: string
title: string
thumbnail: string
variants: string[]
}
export const searchClient = new MeiliSearch({
host: endpoint,
apiKey,
})
================================================
FILE: storefront/src/lib/util/collections.ts
================================================
import { z } from "zod"
export const collectionMetadataCustomFieldsSchema = z.object({
image: z
.object({
id: z.string(),
url: z.string().url(),
})
.optional(),
description: z.string().optional(),
collection_page_image: z
.object({
id: z.string(),
url: z.string().url(),
})
.optional(),
collection_page_heading: z.string().optional(),
collection_page_content: z.string().optional(),
product_page_heading: z.string().optional(),
product_page_image: z
.object({
id: z.string(),
url: z.string().url(),
})
.optional(),
product_page_wide_image: z
.object({
id: z.string(),
url: z.string().url(),
})
.optional(),
product_page_cta_image: z
.object({
id: z.string(),
url: z.string().url(),
})
.optional(),
product_page_cta_heading: z.string().optional(),
product_page_cta_link: z.string().optional(),
})
================================================
FILE: storefront/src/lib/util/compare-addresses.ts
================================================
import { HttpTypes } from "@medusajs/types"
import { isEqual, pick } from "lodash"
export default function compareAddresses(
address1: Pick<
HttpTypes.StoreCartAddress,
| "first_name"
| "last_name"
| "address_1"
| "address_2"
| "company"
| "postal_code"
| "city"
| "country_code"
| "province"
| "phone"
>,
address2: Pick<
HttpTypes.StoreCartAddress,
| "first_name"
| "last_name"
| "address_1"
| "address_2"
| "company"
| "postal_code"
| "city"
| "country_code"
| "province"
| "phone"
>
) {
return isEqual(
pick(address1, [
"first_name",
"last_name",
"address_1",
"address_2",
"company",
"postal_code",
"city",
"country_code",
"province",
"phone",
]),
pick(address2, [
"first_name",
"last_name",
"address_1",
"address_2",
"company",
"postal_code",
"city",
"country_code",
"province",
"phone",
])
)
}
================================================
FILE: storefront/src/lib/util/enrich-line-items.ts
================================================
import "server-only"
import { HttpTypes } from "@medusajs/types"
import { omit } from "lodash"
import { getProductsById } from "@lib/data/products"
export async function enrichLineItems<
T extends HttpTypes.StoreCartLineItem[] | HttpTypes.StoreOrderLineItem[],
>(lineItems: T | null, regionId: string): Promise {
if (!lineItems) return [] as unknown as T
// Prepare query parameters
const queryParams = {
ids: lineItems.map((lineItem) => lineItem.product_id!),
regionId,
}
// Fetch products by their IDs
const products = await getProductsById(queryParams)
// If there are no line items or products, return an empty array
if (!lineItems?.length || !products) {
return [] as unknown as T
}
// Enrich line items with product and variant information
const enrichedItems = lineItems.map((item) => {
const product = products.find((p) => p.id === item.product_id)
const variant = product?.variants?.find((v) => v.id === item.variant_id)
// If product or variant is not found, return the original item
if (!product || !variant) {
return item
}
// If product and variant are found, enrich the item
return {
...item,
variant: {
...variant,
product: omit(product, "variants"),
},
}
}) as T
return enrichedItems
}
================================================
FILE: storefront/src/lib/util/env.ts
================================================
export const getBaseURL = () => {
return process.env.NEXT_PUBLIC_BASE_URL || "https://localhost:8000"
}
================================================
FILE: storefront/src/lib/util/get-precentage-diff.ts
================================================
export const getPercentageDiff = (original: number, calculated: number) => {
const diff = original - calculated
const decrease = (diff / original) * 100
return decrease.toFixed()
}
================================================
FILE: storefront/src/lib/util/get-product-price.ts
================================================
import { HttpTypes } from "@medusajs/types"
import { getPercentageDiff } from "@lib/util/get-precentage-diff"
import { convertToLocale } from "@lib/util/money"
export const getPricesForVariant = (variant: HttpTypes.StoreProductVariant) => {
if (!variant?.calculated_price?.calculated_amount) {
return null
}
return {
calculated_price_number: variant.calculated_price.calculated_amount,
calculated_price: convertToLocale({
amount: variant.calculated_price.calculated_amount,
currency_code: variant.calculated_price.currency_code ?? "",
}),
original_price_number: variant.calculated_price.original_amount,
original_price: convertToLocale({
amount: variant.calculated_price.original_amount ?? 0,
currency_code: variant.calculated_price.currency_code ?? "",
}),
currency_code: variant.calculated_price.currency_code,
price_type: variant.calculated_price.calculated_price?.price_list_type,
percentage_diff: getPercentageDiff(
variant.calculated_price.original_amount ?? 0,
variant.calculated_price.calculated_amount
),
}
}
export function getProductPrice({
product,
variantId,
}: {
product: HttpTypes.StoreProduct
variantId?: string
}) {
if (!product || !product.id) {
throw new Error("No product provided")
}
const cheapestPrice = () => {
if (!product || !product.variants?.length) {
return null
}
const cheapestVariant = product.variants
.filter((v) => !!v.calculated_price)
.sort((a, b) => {
return (
(a.calculated_price?.calculated_amount ?? 0) -
(b.calculated_price?.calculated_amount ?? 0)
)
})[0]
return getPricesForVariant(cheapestVariant)
}
const variantPrice = () => {
if (!product || !variantId) {
return null
}
const variant = product.variants?.find(
(v) => v.id === variantId || v.sku === variantId
)
if (!variant) {
return null
}
return getPricesForVariant(variant)
}
return {
product,
cheapestPrice: cheapestPrice(),
variantPrice: variantPrice(),
}
}
================================================
FILE: storefront/src/lib/util/inventory.ts
================================================
import { HttpTypes } from "@medusajs/types"
export function getVariantItemsInStock(variant: HttpTypes.StoreProductVariant) {
// If we don't manage inventory, we can always add to cart
if (variant && !variant.manage_inventory) {
return Number.MAX_SAFE_INTEGER
}
// If we allow back orders on the variant, we can always add to cart
if (variant.allow_backorder) {
return Number.MAX_SAFE_INTEGER
}
// If there is inventory available, return the inventory quantity
if (variant.manage_inventory && (variant.inventory_quantity || 0) > 0) {
return variant.inventory_quantity!
}
// Otherwise, return 0
return 0
}
================================================
FILE: storefront/src/lib/util/isEmpty.ts
================================================
export const isObject = (input: unknown) => input instanceof Object
export const isArray = (input: unknown) => Array.isArray(input)
export const isEmpty = (input: unknown) => {
return (
input === null ||
input === undefined ||
(isObject(input) && Object.keys(input).length === 0) ||
(isArray(input) && input.length === 0) ||
(typeof input === "string" && input.trim().length === 0)
)
}
================================================
FILE: storefront/src/lib/util/medusa-error.ts
================================================
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default function medusaError(error: any): never {
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
const u = new URL(error.config.url, error.config.baseURL)
console.error("Resource:", u.toString())
console.error("Response data:", error.response.data)
console.error("Status code:", error.response.status)
console.error("Headers:", error.response.headers)
// Extracting the error message from the response data
const message = error.response.data.message || error.response.data
throw new Error(message.charAt(0).toUpperCase() + message.slice(1) + ".")
} else if (error.request) {
// The request was made but no response was received
throw new Error("No response received: " + error.request)
} else {
// Something happened in setting up the request that triggered an Error
throw new Error("Error setting up the request: " + error.message)
}
}
================================================
FILE: storefront/src/lib/util/money.ts
================================================
import { isEmpty } from "@lib/util/isEmpty"
type ConvertToLocaleParams = {
amount: number
currency_code: string
minimumFractionDigits?: number
maximumFractionDigits?: number
locale?: string
}
export const convertToLocale = ({
amount,
currency_code,
minimumFractionDigits,
maximumFractionDigits,
locale = "en-US",
}: ConvertToLocaleParams) => {
return currency_code && !isEmpty(currency_code)
? new Intl.NumberFormat(locale, {
style: "currency",
currency: currency_code,
minimumFractionDigits,
maximumFractionDigits,
}).format(amount)
: amount.toString()
}
================================================
FILE: storefront/src/lib/util/react-query.tsx
================================================
import * as React from "react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
const queryClient = new QueryClient()
export const ReactQueryProvider: React.FC<{ children?: React.ReactNode }> = ({
children,
}) => {
return (
{children}
)
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export const withReactQueryProvider = (
Component: React.FC
) => {
const WrappedComponent = (props: T) => (
)
WrappedComponent.displayName = `withReactQueryProvider(${Component.displayName || Component.name || "Component"})`
return WrappedComponent
}
================================================
FILE: storefront/src/lib/util/repeat.ts
================================================
const repeat = (times: number) => {
return Array.from(Array(times).keys())
}
export default repeat
================================================
FILE: storefront/src/lib/util/sort-products.ts
================================================
import { HttpTypes } from "@medusajs/types"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
interface MinPricedProduct extends HttpTypes.StoreProduct {
_minPrice?: number
}
/**
* Helper function to sort products by price until the store API supports sorting by price
* @param products
* @param sortBy
* @returns products sorted by price
*/
export function sortProducts(
products: HttpTypes.StoreProduct[],
sortBy: SortOptions
): HttpTypes.StoreProduct[] {
const sortedProducts = products as MinPricedProduct[]
if (["price_asc", "price_desc"].includes(sortBy)) {
// Precompute the minimum price for each product
sortedProducts.forEach((product) => {
if (product.variants && product.variants.length > 0) {
product._minPrice = Math.min(
...product.variants.map(
(variant) => variant?.calculated_price?.calculated_amount || 0
)
)
} else {
product._minPrice = Infinity
}
})
// Sort products based on the precomputed minimum prices
sortedProducts.sort((a, b) => {
const diff = a._minPrice! - b._minPrice!
return sortBy === "price_asc" ? diff : -diff
})
}
if (sortBy === "created_at") {
sortedProducts.sort((a, b) => {
return (
new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()
)
})
}
return sortedProducts
}
================================================
FILE: storefront/src/lib/webmcp/WebMCPProvider.tsx
================================================
"use client"
import React from "react"
import { registerWebMCPTools } from "./register-tools"
import { useRouter } from "next/navigation"
export const WebMCPProvider = () => {
const router = useRouter()
React.useEffect(() => {
const cleanup = registerWebMCPTools(router)
return cleanup
}, [router])
return null
}
================================================
FILE: storefront/src/lib/webmcp/is-supported.ts
================================================
export const isWebMCPSupported = (): boolean => {
if (typeof window === "undefined") return false
const enabled = process.env.NEXT_PUBLIC_ENABLE_WEBMCP === "true"
if (!enabled) return false
if (!window.isSecureContext) return false
const nav = navigator as Navigator & {
modelContext?: {
registerTool?: (tool: unknown) => void
}
}
return (
!!nav.modelContext && typeof nav.modelContext.registerTool === "function"
)
}
================================================
FILE: storefront/src/lib/webmcp/register-tools.ts
================================================
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"
import { isWebMCPSupported } from "./is-supported"
import {
checkoutPrepareTool,
navigateToCartTool,
navigateToProductTool,
} from "./tools/checkout"
import { productsSearchTool } from "./tools/products-search"
import { cartManageTool } from "./tools/cart"
import { WebMCPClient } from "./types"
import { applyPromotionTool, removePromotionTool } from "./tools/promotion"
interface Navigator extends globalThis.Navigator {
modelContext: {
registerTool: (
tool: {
name: string
description: string
inputSchema: object
execute: (input: unknown, client: WebMCPClient) => Promise
annotations?: {
readOnlyHint?: boolean
}
},
options?: { signal?: AbortSignal }
) => void
unregisterTool?: (name: string) => void
}
}
export const registerWebMCPTools = (router?: AppRouterInstance) => {
if (!isWebMCPSupported()) {
console.info("WebMCP is not supported, skipping registration")
return () => {}
}
const modelContext = (navigator as unknown as Navigator).modelContext
const controller = new AbortController()
try {
type RegisterableWebMCPTool = {
name: string
description: string
inputSchema: object
annotations?: {
readOnlyHint?: boolean
}
handler: (
input: unknown,
context?: {
router?: AppRouterInstance
client?: WebMCPClient
}
) => Promise
}
const tools: RegisterableWebMCPTool[] = [
productsSearchTool,
navigateToProductTool,
navigateToCartTool,
cartManageTool,
applyPromotionTool,
removePromotionTool,
checkoutPrepareTool,
] as RegisterableWebMCPTool[]
tools.forEach((tool) => {
modelContext.registerTool(
{
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
annotations: tool.annotations,
execute: async (input, client) => {
return await tool.handler(input, { router, client })
},
},
{ signal: controller.signal }
)
})
} catch (error) {
console.error("WebMCP registration failed", error)
}
return () => controller.abort()
}
================================================
FILE: storefront/src/lib/webmcp/tools/cart.ts
================================================
import {
addToCart,
deleteLineItem,
retrieveCart,
updateLineItem,
} from "@lib/data/cart"
import {
CartSnapshot,
WebMCPTool,
WebMCPToolContext,
WebMCPToolResult,
} from "../types"
import { mapCartToResult } from "../utils"
interface CartManageInput {
action: "add" | "remove" | "update" | "view"
variant_id?: string
quantity?: number
line_id?: string
}
export const cartManage = async (
input: CartManageInput,
context?: WebMCPToolContext
): Promise> => {
const { action, variant_id: variantId, quantity, line_id: lineId } = input
const addQuantity = action === "add" ? (quantity ?? 1) : quantity
const pathNameParts = window.location.pathname.replace(/^\//, "").split("/")
const countryCode = pathNameParts[0]
if (!countryCode) {
return {
ok: false,
error: {
code: "INVALID_COUNTRY_CODE",
message: "Your country code is invalid.",
},
}
}
if (action !== "view" && context?.client) {
const actionConfirmed = await context.client.requestUserInteraction(() =>
Promise.resolve(
window.confirm(
`Confirm cart action: ${action}${
action === "add" ? ` ${addQuantity} item(s)` : ""
}?`
)
)
)
if (!actionConfirmed) {
return {
ok: false,
error: {
code: "USER_CANCELLED",
message: "User cancelled cart action confirmation.",
},
}
}
}
try {
switch (action) {
case "add":
if (!variantId) {
return {
ok: false,
error: {
code: "MISSING_VARIANT",
message: "variant_id is required for add action",
},
}
}
await addToCart({ variantId, quantity: addQuantity, countryCode })
break
case "update":
if (!lineId) {
return {
ok: false,
error: {
code: "MISSING_LINE_ID",
message: "line_id is required for update action",
},
}
}
if (quantity === undefined) {
return {
ok: false,
error: {
code: "MISSING_QUANTITY",
message: "quantity is required for update action",
},
}
}
await updateLineItem({ lineId, quantity })
break
case "remove":
if (!lineId) {
return {
ok: false,
error: {
code: "MISSING_LINE_ID",
message: "line_id is required for remove action",
},
}
}
await deleteLineItem(lineId)
break
case "view":
break
}
const cart = await retrieveCart()
if (!cart) {
return {
ok: false,
error: {
code: "CART_MISSING",
message: "Cart is missing",
},
}
}
return {
ok: true,
data: mapCartToResult(cart),
meta: {
tool: "cart.manage",
},
}
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Failed to perform cart action"
return {
ok: false,
error: {
code: "CART_OPERATION_FAILED",
message,
},
}
}
}
export const cartManageTool: WebMCPTool = {
name: "cart.manage",
description: "Manage shopping cart (add, remove, update, view)",
inputSchema: {
type: "object",
properties: {
action: {
type: "string",
enum: ["add", "remove", "update", "view"],
description: "Action to perform",
},
variant_id: {
type: "string",
description: "Variant ID (required for add)",
},
quantity: {
type: "number",
description:
"Quantity for the action. Optional for add (defaults to 1), required for update.",
},
line_id: {
type: "string",
description: "Line item ID (required for remove/update)",
},
},
required: ["action"],
oneOf: [
{
properties: {
action: { const: "add" },
variant_id: { type: "string" },
},
required: ["action", "variant_id"],
},
{
properties: {
action: { const: "remove" },
line_id: { type: "string" },
},
required: ["action", "line_id"],
},
{
properties: {
action: { const: "update" },
line_id: { type: "string" },
},
required: ["action", "line_id"],
},
{
properties: {
action: { const: "view" },
},
required: ["action"],
},
],
additionalProperties: false,
},
handler: cartManage,
}
================================================
FILE: storefront/src/lib/webmcp/tools/checkout.ts
================================================
import { retrieveCart } from "@lib/data/cart"
import { WebMCPTool, WebMCPToolResult } from "../types"
export interface NavigateToProductInput {
handle: string
options?: Record
}
type NavigateToResult = {
path: string
}
const normalizeOptionKey = (key: string) =>
key.trim().toLowerCase().replace(/\s+/g, "_")
export const navigateToProduct = async (
input: NavigateToProductInput,
context?: {
router?: {
push: (href: string) => void
}
}
): Promise> => {
const normalizedOptionKeys = new Set(
Object.keys(input.options ?? {}).map((key) => normalizeOptionKey(key))
)
const hasColor = normalizedOptionKeys.has("color")
const hasMaterial = normalizedOptionKeys.has("material")
if (hasColor && !hasMaterial) {
return {
ok: false,
error: {
code: "MATERIAL_REQUIRED",
message:
"Material must be provided when Color is set. Example: { Material: 'Leather', Color: 'Red' }.",
},
}
}
const queryParams = new URLSearchParams()
Object.entries(input.options ?? {}).forEach(([key, value]) => {
if (!key.trim() || !value.trim()) {
return
}
const normalizedKey = normalizeOptionKey(key)
queryParams.set(`mcp_opt_${normalizedKey}`, value)
})
const path = `/products/${input.handle}${
queryParams.toString() ? `?${queryParams.toString()}` : ""
}`
try {
context?.router?.push(path)
return {
ok: true,
data: {
path,
},
meta: {
tool: "navigation.toProduct",
},
}
} catch (error) {
console.error(error)
return {
ok: false,
error: {
code: "NAVIGATION_FAILED",
message: "Failed to navigate to product",
},
}
}
}
export const navigateToCart = async (
_input: Record,
context?: {
router?: {
push: (href: string) => void
}
}
): Promise> => {
const path = "/cart"
try {
context?.router?.push(path)
return {
ok: true,
data: {
path,
},
meta: {
tool: "navigation.toCart",
},
}
} catch (error) {
console.error(error)
return {
ok: false,
error: {
code: "NAVIGATION_FAILED",
message: "Failed to navigate to cart",
},
}
}
}
export const checkoutPrepare = async (
_input: Record,
context?: {
router?: {
push: (href: string) => void
}
}
): Promise> => {
const path = "/checkout"
try {
const cart = await retrieveCart()
if (!cart) {
return {
ok: false,
error: {
code: "CART_MISSING",
message: "Cart is missing",
},
}
}
if (!cart.items || cart.items.length < 1) {
return {
ok: false,
error: {
code: "CART_EMPTY",
message: "Cart is empty",
},
}
}
context?.router?.push(path)
return {
ok: true,
data: { path },
meta: { tool: "checkout.prepare" },
}
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Failed to prepare checkout"
return {
ok: false,
error: {
code: "CHECKOUT_PREPARE_FAILED",
message,
},
}
}
}
export const navigateToProductTool: WebMCPTool<
NavigateToProductInput,
NavigateToResult
> = {
name: "navigation.toProduct",
description:
"Navigate to product detail page, optionally preselect options. For products with Material and Color, Material must be set before Color.",
annotations: {
readOnlyHint: false,
},
inputSchema: {
type: "object",
properties: {
handle: { type: "string", description: "Product handle/slug" },
options: {
type: "object",
description:
"Optional option map like { Material: 'Cotton', Size: 'M' }. If providing Color, also provide Material.",
additionalProperties: { type: "string" },
},
},
required: ["handle"],
additionalProperties: false,
},
handler: navigateToProduct,
}
export const navigateToCartTool: WebMCPTool<
Record,
NavigateToResult
> = {
name: "navigation.toCart",
description: "Navigate to shopping cart page",
annotations: {
readOnlyHint: false,
},
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
handler: navigateToCart,
}
export const checkoutPrepareTool: WebMCPTool<
Record,
NavigateToResult
> = {
name: "checkout.prepare",
description:
"Validate that the shopping cart has items and navigate to the checkout page. Performs pre-checkout validation (checks cart exists and is not empty) before proceeding. Returns error if cart is missing or empty.",
annotations: {
readOnlyHint: false,
},
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
handler: checkoutPrepare,
}
================================================
FILE: storefront/src/lib/webmcp/tools/products-search.ts
================================================
import { getProductsListWithSort } from "@lib/data/products"
import { MeiliSearchProductHit, searchClient } from "@lib/search-client"
import { getProductPrice } from "@lib/util/get-product-price"
import { HttpTypes } from "@medusajs/types"
import { WebMCPTool, WebMCPToolResult } from "../types"
export interface ProductSearchInput {
query?: string
collection_ids?: string[]
category_ids?: string[]
type_ids?: string[]
sort?: "latest_arrivals" | "lowest_price" | "highest_price"
limit?: number
}
interface ProductSearchData {
products: Array<{
id: string
title: string
handle: string
thumbnail?: string
price?: { amount: number; currency_code: string }
collection_ids?: string[]
category_ids?: string[]
type_id?: string
tags?: string[]
variants?: Array<{
id: string
title?: string
inventory_quantity?: number | null
}>
options?: Array<{
id: string
title: string
values?: Array<{
id: string
value: string
}>
}>
}>
}
export const productsSearch = async (
params: ProductSearchInput
): Promise> => {
const pathNameParts = window.location.pathname.replace(/^\//, "").split("/")
const countryCode = pathNameParts[0]
if (!countryCode) {
return {
ok: false,
error: {
code: "INVALID_COUNTRY_CODE",
message: "Your country code is invalid.",
},
}
}
try {
const results = params.query
? await searchClient
.index("products")
.search(params.query)
: null
const queryParams: HttpTypes.StoreProductListParams = {
limit: Math.min(36, params.limit || 12),
}
if (params.collection_ids && params.collection_ids.length) {
queryParams["collection_id"] = params.collection_ids
}
if (params.category_ids && params.category_ids.length) {
queryParams["category_id"] = params.category_ids
}
if (params.type_ids) {
queryParams["type_id"] = params.type_ids
}
if (results) {
queryParams["id"] = results.hits.map((h) => h.id)
}
if (params.sort === "latest_arrivals") {
queryParams["order"] = "created_at"
}
const medusaProducts = await getProductsListWithSort({
countryCode,
queryParams,
sortBy:
params.sort === "highest_price"
? "price_desc"
: params.sort === "lowest_price"
? "price_asc"
: "created_at",
})
return {
ok: true,
data: {
products: medusaProducts.response.products.map((product) => {
const { cheapestPrice } = getProductPrice({
product,
})
return {
id: product.id,
title: product.title,
handle: product.handle,
thumbnail: product.thumbnail ?? undefined,
price: cheapestPrice
? {
amount: cheapestPrice.calculated_price_number,
currency_code: cheapestPrice.currency_code!,
}
: undefined,
variants: product.variants?.map((variant) => ({
id: variant.id,
title: variant.title ?? undefined,
inventory_quantity: variant.inventory_quantity,
})),
options: product.options?.map((option) => ({
id: option.id,
title: option.title,
values: option.values?.map((valopt) => ({
id: valopt.id,
value: valopt.value,
})),
})),
category_ids:
product.categories?.map((category) => category.id) ?? [],
collection_ids: product.collection ? [product.collection.id] : [],
tags: product.tags?.map((tag) => tag.value) ?? [],
type_id: product.type_id ?? undefined,
}
}),
},
meta: {
tool: "products.search",
},
}
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "Failed to search products"
return {
ok: false,
error: {
code: "SEARCH_FAILED",
message,
},
}
}
}
export const productsSearchTool: WebMCPTool<
ProductSearchInput,
ProductSearchData
> = {
name: "products.search",
description:
"Search and retrieve product information (price, variants, options, categories, tags) with optional filters and sorting.",
annotations: {
readOnlyHint: true,
},
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description:
"Search query. Can be omitted to fetch products by filters only.",
},
collection_ids: { type: "array", items: { type: "string" } },
category_ids: { type: "array", items: { type: "string" } },
type_ids: { type: "array", items: { type: "string" } },
sort: {
type: "string",
enum: ["latest_arrivals", "lowest_price", "highest_price"],
},
limit: { type: "number", minimum: 1, maximum: 36 },
},
additionalProperties: false,
},
handler: productsSearch,
}
================================================
FILE: storefront/src/lib/webmcp/tools/promotion.ts
================================================
import { applyPromotions, removePromotions, retrieveCart } from "@lib/data/cart"
import { CartSnapshot, WebMCPTool, WebMCPToolResult } from "../types"
import { mapCartToResult } from "../utils"
interface PromotionInput {
code: string
}
export const cartApplyPromotion = async (
input: PromotionInput
): Promise> => {
if (!input.code) {
return {
ok: false,
error: {
code: "MISSING_CODE",
message: "Promotion code is required",
},
}
}
try {
await applyPromotions([input.code])
const cart = await retrieveCart()
if (!cart) {
return {
ok: false,
error: {
code: "CART_MISSING",
message: "No active cart found",
},
}
}
return {
ok: true,
data: mapCartToResult(cart),
meta: {
tool: "cart.applyPromotion",
},
}
} catch (error) {
console.error("[cartApplyPromotion] Error:", error)
return {
ok: false,
error: {
code: "APPLY_FAILED",
message: "Failed to apply promotion code",
},
}
}
}
export const cartRemovePromotion = async (
input: PromotionInput
): Promise> => {
if (!input.code) {
return {
ok: false,
error: {
code: "MISSING_CODE",
message: "Promotion code is required",
},
}
}
try {
await removePromotions([input.code])
const cart = await retrieveCart()
if (!cart) {
return {
ok: false,
error: {
code: "CART_MISSING",
message: "No active cart found",
},
}
}
return {
ok: true,
data: mapCartToResult(cart),
meta: {
tool: "cart.removePromotion",
},
}
} catch (error) {
console.error("[cartRemovePromotion] Error:", error)
return {
ok: false,
error: {
code: "REMOVE_FAILED",
message: "Failed to remove promotion code",
},
}
}
}
export const applyPromotionTool: WebMCPTool = {
name: "cart.applyPromotion",
description:
"Apply a discount/promotion code to the shopping cart. Returns updated cart with applied discount, including new subtotal, total, and discount amount. Common error codes: MISSING_CODE (promotion code is required), CART_MISSING (no active cart found), APPLY_FAILED (failed to apply promotion code).",
annotations: {
readOnlyHint: false,
},
inputSchema: {
type: "object",
properties: {
code: {
type: "string",
description:
"Promotion/discount code to apply (e.g., 'SUMMER25', 'FREESHIP')",
},
},
additionalProperties: false,
required: ["code"],
},
handler: cartApplyPromotion,
}
export const removePromotionTool: WebMCPTool = {
name: "cart.removePromotion",
description:
"Remove a previously applied discount/promotion code from the shopping cart. Returns updated cart with recalculated totals after discount removal. Use this when the user wants to replace a code or remove an applied discount.",
annotations: {
readOnlyHint: false,
},
inputSchema: {
type: "object",
properties: {
code: {
type: "string",
description:
"Promotion/discount code to remove (must match an applied code)",
},
},
additionalProperties: false,
required: ["code"],
},
handler: cartRemovePromotion,
}
================================================
FILE: storefront/src/lib/webmcp/types.ts
================================================
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"
export interface WebMCPClient {
requestUserInteraction: (callback: () => Promise | T) => Promise
}
export interface WebMCPToolContext {
router?: AppRouterInstance
client?: WebMCPClient
}
export type WebMCPToolResult =
| {
ok: true
data: TData
meta: {
tool: string
}
}
| {
ok: false
error: {
code: string
message: string
}
}
export interface WebMCPTool {
name: string
description: string
inputSchema: Record
annotations?: {
readOnlyHint?: boolean
}
handler: (
input: TInput,
context?: WebMCPToolContext
) => Promise>
}
export interface CartSnapshot {
cart: {
id: string
currency_code: string
subtotal: number
total: number
discount_total?: number
items: Array<{
id: string
title: string
variant_id: string
quantity: number
unit_price: number
total: number
}>
discount_codes?: string[]
}
}
================================================
FILE: storefront/src/lib/webmcp/utils.ts
================================================
import {
StoreCart,
StoreCartLineItem,
StoreCartPromotion,
} from "@medusajs/types"
export const mapCartToResult = (cart: StoreCart) => {
return {
cart: {
id: cart.id,
currency_code: cart.currency_code,
subtotal: cart.subtotal ?? 0,
total: cart.total ?? 0,
discount_total: cart.discount_total,
items:
cart.items?.map((item: StoreCartLineItem) => ({
id: item.id,
title: item.title,
variant_id: item.variant_id ?? "",
quantity: item.quantity,
unit_price: item.unit_price,
total: item.total ?? 0,
})) || [],
discount_codes:
cart.promotions
?.map((p: StoreCartPromotion) => p.code)
.filter((code): code is string => code !== undefined) || [],
},
}
}
================================================
FILE: storefront/src/middleware.ts
================================================
import { HttpTypes } from "@medusajs/types"
import { notFound } from "next/navigation"
import { NextRequest, NextResponse } from "next/server"
const BACKEND_URL = process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL
const PUBLISHABLE_API_KEY = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
const DEFAULT_REGION = process.env.NEXT_PUBLIC_DEFAULT_REGION || "us"
const regionMapCache = {
regionMap: new Map(),
regionMapUpdated: Date.now(),
}
async function getRegionMap() {
const { regionMap, regionMapUpdated } = regionMapCache
if (
!regionMap.keys().next().value ||
regionMapUpdated < Date.now() - 3600 * 1000
) {
// Fetch regions from Medusa. We can't use the JS client here because middleware is running on Edge and the client needs a Node environment.
const { regions } = await fetch(`${BACKEND_URL}/store/regions`, {
headers: {
"x-publishable-api-key": PUBLISHABLE_API_KEY!,
},
next: {
revalidate: 3600,
tags: ["regions"],
},
}).then((res) => res.json())
if (!regions?.length) {
notFound()
}
// Create a map of country codes to regions.
regions.forEach((region: HttpTypes.StoreRegion) => {
region.countries?.forEach((c) => {
regionMapCache.regionMap.set(c.iso_2 ?? "", region)
})
})
regionMapCache.regionMapUpdated = Date.now()
}
return regionMapCache.regionMap
}
/**
* Fetches regions from Medusa and sets the region cookie.
* @param request
* @param response
*/
async function getCountryCode(
request: NextRequest,
regionMap: Map
) {
try {
let countryCode
const vercelCountryCode = request.headers
.get("x-vercel-ip-country")
?.toLowerCase()
const urlCountryCode = request.nextUrl.pathname.split("/")[1]?.toLowerCase()
if (urlCountryCode && regionMap.has(urlCountryCode)) {
countryCode = urlCountryCode
} else if (vercelCountryCode && regionMap.has(vercelCountryCode)) {
countryCode = vercelCountryCode
} else if (regionMap.has(DEFAULT_REGION)) {
countryCode = DEFAULT_REGION
} else if (regionMap.keys().next().value) {
countryCode = regionMap.keys().next().value
}
return countryCode
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.error(
"Middleware.ts: Error getting the country code. Did you set up regions in your Medusa Admin and define a NEXT_PUBLIC_MEDUSA_BACKEND_URL environment variable?"
)
}
}
}
/**
* Middleware to handle region selection and onboarding status.
*/
export async function middleware(request: NextRequest) {
const regionMap = await getRegionMap()
const countryCode = regionMap && (await getCountryCode(request, regionMap))
const urlHasCountryCode =
countryCode && request.nextUrl.pathname.split("/")[1].includes(countryCode)
// check if one of the country codes is in the url
if (urlHasCountryCode) {
return NextResponse.next()
}
const redirectPath =
request.nextUrl.pathname === "/" ? "" : request.nextUrl.pathname
const queryString = request.nextUrl.search ? request.nextUrl.search : ""
let redirectUrl = request.nextUrl.href
let response = NextResponse.redirect(redirectUrl, 307)
// If no country code is set, we redirect to the relevant region.
if (!urlHasCountryCode && countryCode) {
redirectUrl = `${request.nextUrl.origin}/${countryCode}${redirectPath}${queryString}`
response = NextResponse.redirect(`${redirectUrl}`, 307)
}
return response
}
export const config = {
matcher: [
"/((?!api|_next/static|favicon.ico|_next/image|images|robots.txt).*)",
],
}
================================================
FILE: storefront/src/modules/account/components/AddressMultiple.tsx
================================================
import { twMerge } from "tailwind-merge"
import { BaseRegionCountry } from "@medusajs/types/dist/http/region/common"
import { StoreCustomerAddress, StoreRegion } from "@medusajs/types"
import { UiCloseButton, UiDialog, UiDialogTrigger } from "@/components/Dialog"
import { UiModal, UiModalOverlay } from "@/components/ui/Modal"
import { DeleteAddressButton } from "@modules/account/components/DeleteAddressButton"
import { Button } from "@/components/Button"
import { UpsertAddressForm } from "@modules/account/components/UpsertAddressForm"
export const AddressMultiple: React.FC<{
address: StoreCustomerAddress
countries: BaseRegionCountry[]
region: StoreRegion | null | undefined
className?: string
}> = ({ address, countries, region, className }) => {
return (
Address
{address.address_1}
Country
{countries.find((country) => country.iso_2 === address.country_code)
?.display_name || address.country_code}
{Boolean(address.address_2) && (
Apartment, suite, etc.
{address.address_2}
)}
Postal Code
{address.postal_code}
Do you want to delete this address?