init starter package

This commit is contained in:
2025-10-16 17:29:57 +02:00
commit b7c67b5834
216 changed files with 37028 additions and 0 deletions
@@ -0,0 +1,73 @@
"use client"
import { IconBadge, clx } from "@medusajs/ui"
import {
SelectHTMLAttributes,
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react"
import ChevronDown from "@modules/common/icons/chevron-down"
type NativeSelectProps = {
placeholder?: string
errors?: Record<string, unknown>
touched?: Record<string, unknown>
} & Omit<SelectHTMLAttributes<HTMLSelectElement>, "size">
const CartItemSelect = forwardRef<HTMLSelectElement, NativeSelectProps>(
({ placeholder = "Select...", className, children, ...props }, ref) => {
const innerRef = useRef<HTMLSelectElement>(null)
const [isPlaceholder, setIsPlaceholder] = useState(false)
useImperativeHandle<HTMLSelectElement | null, HTMLSelectElement | null>(
ref,
() => innerRef.current
)
useEffect(() => {
if (innerRef.current && innerRef.current.value === "") {
setIsPlaceholder(true)
} else {
setIsPlaceholder(false)
}
}, [innerRef.current?.value])
return (
<div>
<IconBadge
onFocus={() => innerRef.current?.focus()}
onBlur={() => innerRef.current?.blur()}
className={clx(
"relative flex items-center txt-compact-small border text-ui-fg-base group",
className,
{
"text-ui-fg-subtle": isPlaceholder,
}
)}
>
<select
ref={innerRef}
{...props}
className="appearance-none bg-transparent border-none px-4 transition-colors duration-150 focus:border-gray-700 outline-none w-16 h-16 items-center justify-center"
>
<option disabled value="">
{placeholder}
</option>
{children}
</select>
<span className="absolute flex pointer-events-none justify-end w-8 group-hover:animate-pulse">
<ChevronDown />
</span>
</IconBadge>
</div>
)
}
)
CartItemSelect.displayName = "CartItemSelect"
export default CartItemSelect
@@ -0,0 +1,25 @@
import { Heading, Text } from "@medusajs/ui"
import InteractiveLink from "@modules/common/components/interactive-link"
const EmptyCartMessage = () => {
return (
<div className="py-48 px-2 flex flex-col justify-center items-start" data-testid="empty-cart-message">
<Heading
level="h1"
className="flex flex-row text-3xl-regular gap-x-2 items-baseline"
>
Cart
</Heading>
<Text className="text-base-regular mt-4 mb-6 max-w-[32rem]">
You don&apos;t have anything in your cart. Let&apos;s change that, use
the link below to start browsing our products.
</Text>
<div>
<InteractiveLink href="/store">Explore products</InteractiveLink>
</div>
</div>
)
}
export default EmptyCartMessage
+144
View File
@@ -0,0 +1,144 @@
"use client"
import { Table, Text, clx } from "@medusajs/ui"
import { updateLineItem } from "@lib/data/cart"
import { HttpTypes } from "@medusajs/types"
import CartItemSelect from "@modules/cart/components/cart-item-select"
import ErrorMessage from "@modules/checkout/components/error-message"
import DeleteButton from "@modules/common/components/delete-button"
import LineItemOptions from "@modules/common/components/line-item-options"
import LineItemPrice from "@modules/common/components/line-item-price"
import LineItemUnitPrice from "@modules/common/components/line-item-unit-price"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import Spinner from "@modules/common/icons/spinner"
import Thumbnail from "@modules/products/components/thumbnail"
import { useState } from "react"
type ItemProps = {
item: HttpTypes.StoreCartLineItem
type?: "full" | "preview"
currencyCode: string
}
const Item = ({ item, type = "full", currencyCode }: ItemProps) => {
const [updating, setUpdating] = useState(false)
const [error, setError] = useState<string | null>(null)
const changeQuantity = async (quantity: number) => {
setError(null)
setUpdating(true)
await updateLineItem({
lineId: item.id,
quantity,
})
.catch((err) => {
setError(err.message)
})
.finally(() => {
setUpdating(false)
})
}
// TODO: Update this to grab the actual max inventory
const maxQtyFromInventory = 10
const maxQuantity = item.variant?.manage_inventory ? 10 : maxQtyFromInventory
return (
<Table.Row className="w-full" data-testid="product-row">
<Table.Cell className="!pl-0 p-4 w-24">
<LocalizedClientLink
href={`/products/${item.product_handle}`}
className={clx("flex", {
"w-16": type === "preview",
"small:w-24 w-12": type === "full",
})}
>
<Thumbnail
thumbnail={item.thumbnail}
images={item.variant?.product?.images}
size="square"
/>
</LocalizedClientLink>
</Table.Cell>
<Table.Cell className="text-left">
<Text
className="txt-medium-plus text-ui-fg-base"
data-testid="product-title"
>
{item.product_title}
</Text>
<LineItemOptions variant={item.variant} data-testid="product-variant" />
</Table.Cell>
{type === "full" && (
<Table.Cell>
<div className="flex gap-2 items-center w-28">
<DeleteButton id={item.id} data-testid="product-delete-button" />
<CartItemSelect
value={item.quantity}
onChange={(value) => changeQuantity(parseInt(value.target.value))}
className="w-14 h-10 p-4"
data-testid="product-select-button"
>
{/* TODO: Update this with the v2 way of managing inventory */}
{Array.from(
{
length: Math.min(maxQuantity, 10),
},
(_, i) => (
<option value={i + 1} key={i}>
{i + 1}
</option>
)
)}
<option value={1} key={1}>
1
</option>
</CartItemSelect>
{updating && <Spinner />}
</div>
<ErrorMessage error={error} data-testid="product-error-message" />
</Table.Cell>
)}
{type === "full" && (
<Table.Cell className="hidden small:table-cell">
<LineItemUnitPrice
item={item}
style="tight"
currencyCode={currencyCode}
/>
</Table.Cell>
)}
<Table.Cell className="!pr-0">
<span
className={clx("!pr-0", {
"flex flex-col items-end h-full justify-center": type === "preview",
})}
>
{type === "preview" && (
<span className="flex gap-x-1 ">
<Text className="text-ui-fg-muted">{item.quantity}x </Text>
<LineItemUnitPrice
item={item}
style="tight"
currencyCode={currencyCode}
/>
</span>
)}
<LineItemPrice
item={item}
style="tight"
currencyCode={currencyCode}
/>
</span>
</Table.Cell>
</Table.Row>
)
}
export default Item
@@ -0,0 +1,26 @@
import { Button, Heading, Text } from "@medusajs/ui"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
const SignInPrompt = () => {
return (
<div className="bg-white flex items-center justify-between">
<div>
<Heading level="h2" className="txt-xlarge">
Already have an account?
</Heading>
<Text className="txt-medium text-ui-fg-subtle mt-2">
Sign in for a better experience.
</Text>
</div>
<div>
<LocalizedClientLink href="/account">
<Button variant="secondary" className="h-10" data-testid="sign-in-button">
Sign in
</Button>
</LocalizedClientLink>
</div>
</div>
)
}
export default SignInPrompt
+51
View File
@@ -0,0 +1,51 @@
import ItemsTemplate from "./items"
import Summary from "./summary"
import EmptyCartMessage from "../components/empty-cart-message"
import SignInPrompt from "../components/sign-in-prompt"
import Divider from "@modules/common/components/divider"
import { HttpTypes } from "@medusajs/types"
const CartTemplate = ({
cart,
customer,
}: {
cart: HttpTypes.StoreCart | null
customer: HttpTypes.StoreCustomer | null
}) => {
return (
<div className="py-12">
<div className="content-container" data-testid="cart-container">
{cart?.items?.length ? (
<div className="grid grid-cols-1 small:grid-cols-[1fr_360px] gap-x-40">
<div className="flex flex-col bg-white py-6 gap-y-6">
{!customer && (
<>
<SignInPrompt />
<Divider />
</>
)}
<ItemsTemplate cart={cart} />
</div>
<div className="relative">
<div className="flex flex-col gap-y-8 sticky top-12">
{cart && cart.region && (
<>
<div className="bg-white py-6">
<Summary cart={cart as any} />
</div>
</>
)}
</div>
</div>
</div>
) : (
<div>
<EmptyCartMessage />
</div>
)}
</div>
</div>
)
}
export default CartTemplate
+57
View File
@@ -0,0 +1,57 @@
import repeat from "@lib/util/repeat"
import { HttpTypes } from "@medusajs/types"
import { Heading, Table } from "@medusajs/ui"
import Item from "@modules/cart/components/item"
import SkeletonLineItem from "@modules/skeletons/components/skeleton-line-item"
type ItemsTemplateProps = {
cart?: HttpTypes.StoreCart
}
const ItemsTemplate = ({ cart }: ItemsTemplateProps) => {
const items = cart?.items
return (
<div>
<div className="pb-3 flex items-center">
<Heading className="text-[2rem] leading-[2.75rem]">Cart</Heading>
</div>
<Table>
<Table.Header className="border-t-0">
<Table.Row className="text-ui-fg-subtle txt-medium-plus">
<Table.HeaderCell className="!pl-0">Item</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
<Table.HeaderCell>Quantity</Table.HeaderCell>
<Table.HeaderCell className="hidden small:table-cell">
Price
</Table.HeaderCell>
<Table.HeaderCell className="!pr-0 text-right">
Total
</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{items
? items
.sort((a, b) => {
return (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1
})
.map((item) => {
return (
<Item
key={item.id}
item={item}
currencyCode={cart?.currency_code}
/>
)
})
: repeat(5).map((i) => {
return <SkeletonLineItem key={i} />
})}
</Table.Body>
</Table>
</div>
)
}
export default ItemsTemplate
+51
View File
@@ -0,0 +1,51 @@
"use client"
import repeat from "@lib/util/repeat"
import { HttpTypes } from "@medusajs/types"
import { Table, clx } from "@medusajs/ui"
import Item from "@modules/cart/components/item"
import SkeletonLineItem from "@modules/skeletons/components/skeleton-line-item"
type ItemsTemplateProps = {
cart: HttpTypes.StoreCart
}
const ItemsPreviewTemplate = ({ cart }: ItemsTemplateProps) => {
const items = cart.items
const hasOverflow = items && items.length > 4
return (
<div
className={clx({
"pl-[1px] overflow-y-scroll overflow-x-hidden no-scrollbar max-h-[420px]":
hasOverflow,
})}
>
<Table>
<Table.Body data-testid="items-table">
{items
? items
.sort((a, b) => {
return (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1
})
.map((item) => {
return (
<Item
key={item.id}
item={item}
type="preview"
currencyCode={cart.currency_code}
/>
)
})
: repeat(5).map((i) => {
return <SkeletonLineItem key={i} />
})}
</Table.Body>
</Table>
</div>
)
}
export default ItemsPreviewTemplate
+48
View File
@@ -0,0 +1,48 @@
"use client"
import { Button, Heading } from "@medusajs/ui"
import CartTotals from "@modules/common/components/cart-totals"
import Divider from "@modules/common/components/divider"
import DiscountCode from "@modules/checkout/components/discount-code"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import { HttpTypes } from "@medusajs/types"
type SummaryProps = {
cart: HttpTypes.StoreCart & {
promotions: HttpTypes.StorePromotion[]
}
}
function getCheckoutStep(cart: HttpTypes.StoreCart) {
if (!cart?.shipping_address?.address_1 || !cart.email) {
return "address"
} else if (cart?.shipping_methods?.length === 0) {
return "delivery"
} else {
return "payment"
}
}
const Summary = ({ cart }: SummaryProps) => {
const step = getCheckoutStep(cart)
return (
<div className="flex flex-col gap-y-4">
<Heading level="h2" className="text-[2rem] leading-[2.75rem]">
Summary
</Heading>
<DiscountCode cart={cart} />
<Divider />
<CartTotals totals={cart} />
<LocalizedClientLink
href={"/checkout?step=" + step}
data-testid="checkout-button"
>
<Button className="w-full h-10">Go to checkout</Button>
</LocalizedClientLink>
</div>
)
}
export default Summary