diff --git a/src/app/[countryCode]/(main)/products/[handle]/page.tsx b/src/app/[countryCode]/(main)/products/[handle]/page.tsx
index ee4562130..3828323c1 100644
--- a/src/app/[countryCode]/(main)/products/[handle]/page.tsx
+++ b/src/app/[countryCode]/(main)/products/[handle]/page.tsx
@@ -2,14 +2,22 @@ import { Metadata } from "next"
import { notFound } from "next/navigation"
import {
+ getCustomer,
getProductByHandle,
getProductsList,
getRegion,
+ listAuctions,
listRegions,
retrievePricedProductById,
} from "@lib/data"
import { Region } from "@medusajs/medusa"
-import ProductTemplate from "@modules/products/templates"
+import AuctionsActions from "@modules/products/components/auction-actions"
+import ImageGallery from "@modules/products/components/image-gallery"
+import ProductTabs from "@modules/products/components/product-tabs"
+import RelatedProducts from "@modules/products/components/related-products"
+import ProductInfo from "@modules/products/templates/product-info"
+import SkeletonRelatedProducts from "@modules/skeletons/templates/skeleton-related-products"
+import { Suspense } from "react"
type Props = {
params: { countryCode: string; handle: string }
@@ -84,6 +92,10 @@ const getPricedProductByHandle = async (handle: string, region: Region) => {
}
export default async function ProductPage({ params }: Props) {
+ const { product } = await getProductByHandle(params.handle).then(
+ (product) => product
+ )
+
const region = await getRegion(params.countryCode)
if (!region) {
@@ -92,15 +104,38 @@ export default async function ProductPage({ params }: Props) {
const pricedProduct = await getPricedProductByHandle(params.handle, region)
- if (!pricedProduct) {
+ if (!pricedProduct || !pricedProduct.id) {
notFound()
}
+ const customer = await getCustomer()
+
+ const {
+ auctions: { [0]: auction },
+ } = await listAuctions(pricedProduct.id)
+
return (
-
+ <>
+
+
+ }>
+
+
+
+ >
)
}
diff --git a/src/lib/data/index.ts b/src/lib/data/index.ts
index 9e4a7afa7..62bbfb2ac 100644
--- a/src/lib/data/index.ts
+++ b/src/lib/data/index.ts
@@ -16,7 +16,11 @@ import { cache } from "react"
import sortProducts from "@lib/util/sort-products"
import transformProductPreview from "@lib/util/transform-product-preview"
import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
-import { ProductCategoryWithChildren, ProductPreviewType } from "types/global"
+import {
+ Auction,
+ ProductCategoryWithChildren,
+ ProductPreviewType,
+} from "types/global"
import { medusaClient } from "@lib/config"
import medusaError from "@lib/util/medusa-error"
@@ -51,6 +55,38 @@ const getMedusaHeaders = (tags: string[] = []) => {
return headers
}
+// Auction actions
+export async function listAuctions(
+ productId: string
+): Promise<{ auctions: Auction[] }> {
+ const headers = getMedusaHeaders(["auctions"])
+
+ return fetch(
+ `http://localhost:9000/store/auctions?product_id=${productId}&status=active`,
+ {
+ headers,
+ }
+ )
+ .then((response) => response.json())
+ .catch((err) => medusaError(err))
+}
+
+export async function createBid(
+ auctionId: string,
+ amount: number,
+ customerId: string
+) {
+ const headers = getMedusaHeaders(["auctions"])
+
+ return fetch(`http://localhost:9000/store/auctions/${auctionId}/bids`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ amount, customer_id: customerId }),
+ })
+ .then((response) => response.json())
+ .catch((err) => medusaError(err))
+}
+
// Cart actions
export async function createCart(data = {}) {
const headers = getMedusaHeaders(["cart"])
diff --git a/src/lib/util/time-ago.ts b/src/lib/util/time-ago.ts
new file mode 100644
index 000000000..a52fe2a53
--- /dev/null
+++ b/src/lib/util/time-ago.ts
@@ -0,0 +1,18 @@
+export function timeAgo(date: Date) {
+ const now = new Date()
+ const diff = Math.abs(now.getTime() - date.getTime())
+ const seconds = Math.floor(diff / 1000)
+ const minutes = Math.floor(seconds / 60)
+ const hours = Math.floor(minutes / 60)
+ const days = Math.floor(hours / 24)
+
+ if (days > 0) {
+ return `${days} day${days > 1 ? "s" : ""} ago`
+ } else if (hours > 0) {
+ return `${hours} h ago`
+ } else if (minutes > 0) {
+ return `${minutes} min ago`
+ } else {
+ return `${seconds} s ago`
+ }
+}
diff --git a/src/modules/products/actions.ts b/src/modules/products/actions.ts
new file mode 100644
index 000000000..4c99d140b
--- /dev/null
+++ b/src/modules/products/actions.ts
@@ -0,0 +1,22 @@
+"use server"
+
+import { createBid } from "@lib/data"
+import { revalidateTag } from "next/cache"
+
+export async function placeBid({
+ auctionId,
+ amount,
+ customerId,
+}: {
+ auctionId: string
+ amount: number
+ customerId: string
+}) {
+ try {
+ const res = await createBid(auctionId, amount, customerId)
+ revalidateTag("auctions")
+ return res
+ } catch (error: any) {
+ return error.toString()
+ }
+}
diff --git a/src/modules/products/components/auction-actions/index.tsx b/src/modules/products/components/auction-actions/index.tsx
new file mode 100644
index 000000000..020153f8a
--- /dev/null
+++ b/src/modules/products/components/auction-actions/index.tsx
@@ -0,0 +1,194 @@
+"use client"
+
+import { Customer, Region } from "@medusajs/medusa"
+import { PricedProduct } from "@medusajs/medusa/dist/types/pricing"
+import { Button, Input, Text } from "@medusajs/ui"
+import { FormEvent, Suspense, useRef, useState } from "react"
+
+import { formatAmount } from "@lib/util/prices"
+import Divider from "@modules/common/components/divider"
+import { placeBid } from "@modules/products/actions"
+import { Auction } from "types/global"
+import AuctionBids from "../auction-bids"
+import AuctionCountdown from "../auction-countdown"
+import LocalizedClientLink from "@modules/common/components/localized-client-link"
+
+type AuctionsActionsProps = {
+ product: PricedProduct
+ region: Region
+ auction: Auction
+ customer?: Omit | null
+}
+
+export type PriceType = {
+ calculated_price: string
+ original_price?: string
+ price_type?: "sale" | "default"
+ percentage_diff?: string
+}
+
+export default function AuctionsActions({
+ region,
+ auction,
+ customer,
+}: AuctionsActionsProps) {
+ const [amount, setAmount] = useState()
+ const [isloading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const maxBid =
+ auction?.bids.length > 0
+ ? auction?.bids?.reduce((a, b) => {
+ if (a.amount > b.amount) return a
+ return b
+ })
+ : { amount: auction?.starting_price, customer_id: "" }
+
+ const currentBid = formatAmount({
+ amount: maxBid?.amount,
+ region,
+ })
+
+ const minNextBid = formatAmount({
+ amount: maxBid ? maxBid?.amount + 500 : auction?.starting_price + 500,
+ region,
+ })
+
+ const formRef = useRef(null)
+
+ const handlePlaceBid = async (e: FormEvent) => {
+ e.preventDefault()
+
+ setError(null)
+ setIsLoading(true)
+
+ if (!customer) {
+ setIsLoading(false)
+ setError("Please sign in to place a bid")
+ return
+ }
+
+ if (!amount) {
+ setIsLoading(false)
+ setError("Please enter a valid amount")
+ return
+ }
+
+ if (amount * 100 < (maxBid?.amount + 500 || auction.starting_price + 500)) {
+ setIsLoading(false)
+ setError("Please enter an amount higher than " + minNextBid)
+ return
+ }
+
+ await placeBid({
+ auctionId: auction.id,
+ amount: amount * 100 || 0,
+ customerId: customer.id,
+ })
+ .then((res) => {
+ if (res.message && res.highestBid) {
+ const message =
+ "Please enter an amount higher than " +
+ formatAmount({ amount: res.highestBid, region })
+ setError(message)
+ }
+ })
+ .catch((e) => {
+ setError(e)
+ })
+
+ setAmount(undefined)
+ formRef.current?.reset()
+ setIsLoading(false)
+ }
+
+ return (
+
+ {!auction &&
No active auction.
}
+
+ {auction && (
+ <>
+
+
+
+ Current bid:
+
+
+ {currentBid}
+
+ {maxBid.customer_id === customer?.id && (
+
+ You are the highest bidder!
+
+ )}
+
+ {!customer ? (
+
+
+ Sign in
+ {" "}
+ to place a bid
+
+ ) : (
+
+ )}
+
+
+
+
+ Ends at: {new Date(auction.ends_at).toDateString()}
+
+
+
+ >
+ )}
+
+ )
+}
diff --git a/src/modules/products/components/auction-bids/index.tsx b/src/modules/products/components/auction-bids/index.tsx
new file mode 100644
index 000000000..8b99f4b29
--- /dev/null
+++ b/src/modules/products/components/auction-bids/index.tsx
@@ -0,0 +1,53 @@
+import { formatAmount } from "@lib/util/prices"
+import { User } from "@medusajs/icons"
+import { Customer, Region } from "@medusajs/medusa"
+import { Heading, Text, clx } from "@medusajs/ui"
+import { Bid } from "types/global"
+import AuctionTimeAgo from "../auction-time-ago"
+
+const AuctionBids = ({
+ bids,
+ region,
+ customer,
+}: {
+ bids: Bid[]
+ region: Region
+ customer?: Omit | null
+}) => {
+ return (
+ <>
+ {bids.length ? "All bids" : "No bids yet"}
+
+ {bids?.slice(0, 8).map((bid, idx) => {
+ const bidder =
+ bid.customer_id === customer?.id ? "You" : bid.customer_id.slice(-4)
+
+ return (
+
+
+ {bidder}
+
+
+ {formatAmount({ amount: bid.amount, region })}
+
+
+
+ )
+ })}
+
+
+ {bids.length > 8 && (
+
+ ...and {bids.length - 8} more
+
+ )}
+ >
+ )
+}
+
+export default AuctionBids
diff --git a/src/modules/products/components/auction-countdown/index.tsx b/src/modules/products/components/auction-countdown/index.tsx
new file mode 100644
index 000000000..22b2fb522
--- /dev/null
+++ b/src/modules/products/components/auction-countdown/index.tsx
@@ -0,0 +1,62 @@
+"use client"
+
+import { Text } from "@medusajs/ui"
+import { useState, useEffect } from "react"
+
+const AuctionCountdown = ({ targetDate }: { targetDate: Date }) => {
+ const calculateTimeLeft = () => {
+ const difference = targetDate.getTime() - new Date().getTime()
+ let timeLeft = {}
+
+ if (difference > 0) {
+ timeLeft = {
+ d: Math.floor(difference / (1000 * 60 * 60 * 24)),
+ h: Math.floor((difference / (1000 * 60 * 60)) % 24),
+ m: Math.floor((difference / 1000 / 60) % 60),
+ s: Math.floor((difference / 1000) % 60),
+ }
+ }
+
+ return timeLeft
+ }
+
+ const [timeLeft, setTimeLeft] = useState(calculateTimeLeft())
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setTimeLeft(calculateTimeLeft())
+ }, 1000)
+
+ return () => clearTimeout(timer)
+ })
+
+ const timerComponents: JSX.Element[] = []
+
+ Object.keys(timeLeft).forEach((interval) => {
+ if (!timeLeft[interval as keyof typeof timeLeft]) {
+ return
+ }
+
+ timerComponents.push(
+
+ {timeLeft[interval as keyof typeof timeLeft]}
+ {interval}{" "}
+
+ )
+ })
+
+ return (
+
+ {timerComponents.length ? (
+
+ Closes in{" "}
+ {timerComponents}
+
+ ) : (
+ Closed
+ )}
+
+ )
+}
+
+export default AuctionCountdown
diff --git a/src/modules/products/components/auction-time-ago/index.tsx b/src/modules/products/components/auction-time-ago/index.tsx
new file mode 100644
index 000000000..897f6ab30
--- /dev/null
+++ b/src/modules/products/components/auction-time-ago/index.tsx
@@ -0,0 +1,10 @@
+import { timeAgo } from "@lib/util/time-ago"
+import { Bid } from "types/global"
+
+const AuctionTimeAgo = ({ bid }: { bid: Bid }) => (
+
+ {timeAgo(new Date(bid.created_at))}
+
+)
+
+export default AuctionTimeAgo
diff --git a/src/modules/products/components/product-preview/index.tsx b/src/modules/products/components/product-preview/index.tsx
index 996db0566..6a83e1aa4 100644
--- a/src/modules/products/components/product-preview/index.tsx
+++ b/src/modules/products/components/product-preview/index.tsx
@@ -2,12 +2,13 @@ import { Text } from "@medusajs/ui"
import { ProductPreviewType } from "types/global"
-import { retrievePricedProductById } from "@lib/data"
+import { listAuctions, retrievePricedProductById } from "@lib/data"
import { getProductPrice } from "@lib/util/get-product-price"
import { Region } from "@medusajs/medusa"
import LocalizedClientLink from "@modules/common/components/localized-client-link"
import Thumbnail from "../thumbnail"
import PreviewPrice from "./price"
+import { formatAmount } from "@lib/util/prices"
export default async function ProductPreview({
productPreview,
@@ -27,10 +28,20 @@ export default async function ProductPreview({
return null
}
- const { cheapestPrice } = getProductPrice({
- product: pricedProduct,
- region,
- })
+ const auction = await listAuctions(pricedProduct.id!).then(
+ ({ auctions }) => auctions[0]
+ )
+
+ const maxBid = auction?.bids?.reduce((a, b) => {
+ return Math.max(a, b.amount)
+ }, 0)
+
+ const currentBid = maxBid
+ ? formatAmount({
+ amount: maxBid || auction?.starting_price,
+ region,
+ })
+ : "No active auction"
return (
{productPreview.title}
- {cheapestPrice &&
}
+
{currentBid}
diff --git a/src/modules/products/components/product-preview/price.tsx b/src/modules/products/components/product-preview/price.tsx
index 56b44c990..a7686c420 100644
--- a/src/modules/products/components/product-preview/price.tsx
+++ b/src/modules/products/components/product-preview/price.tsx
@@ -1,6 +1,6 @@
import { Text, clx } from "@medusajs/ui"
-import { PriceType } from "../product-actions"
+import { PriceType } from "../auction-actions"
export default async function PreviewPrice({ price }: { price: PriceType }) {
return (
diff --git a/src/modules/products/templates/index.tsx b/src/modules/products/templates/index.tsx
index cf3e7f727..532df4cc4 100644
--- a/src/modules/products/templates/index.tsx
+++ b/src/modules/products/templates/index.tsx
@@ -2,26 +2,26 @@ import { Region } from "@medusajs/medusa"
import { PricedProduct } from "@medusajs/medusa/dist/types/pricing"
import React, { Suspense } from "react"
+import AuctionsActions from "@modules/products/components/auction-actions"
import ImageGallery from "@modules/products/components/image-gallery"
-import ProductActions from "@modules/products/components/product-actions"
-import ProductOnboardingCta from "@modules/products/components/product-onboarding-cta"
import ProductTabs from "@modules/products/components/product-tabs"
import RelatedProducts from "@modules/products/components/related-products"
import ProductInfo from "@modules/products/templates/product-info"
import SkeletonRelatedProducts from "@modules/skeletons/templates/skeleton-related-products"
import { notFound } from "next/navigation"
-import ProductActionsWrapper from "./product-actions-wrapper"
type ProductTemplateProps = {
product: PricedProduct
region: Region
countryCode: string
+ auctions: Record[]
}
const ProductTemplate: React.FC = ({
product,
region,
countryCode,
+ auctions,
}) => {
if (!product || !product.id) {
return notFound()
@@ -38,12 +38,11 @@ const ProductTemplate: React.FC = ({
diff --git a/src/modules/products/templates/product-actions-wrapper/index.tsx b/src/modules/products/templates/product-actions-wrapper/index.tsx
index 4be6d2e06..ddf33b31b 100644
--- a/src/modules/products/templates/product-actions-wrapper/index.tsx
+++ b/src/modules/products/templates/product-actions-wrapper/index.tsx
@@ -1,6 +1,6 @@
import { retrievePricedProductById } from "@lib/data"
import { Region } from "@medusajs/medusa"
-import ProductActions from "@modules/products/components/product-actions"
+import ProductActions from "@modules/products/components/auction-actions"
/**
* Fetches real time pricing for a product and renders the product actions component.
diff --git a/src/types/global.ts b/src/types/global.ts
index eaeb41ce2..c777c9d83 100644
--- a/src/types/global.ts
+++ b/src/types/global.ts
@@ -56,3 +56,21 @@ export type ProductCategoryWithChildren = Omit<
category_children: ProductCategory[]
category_parent?: ProductCategory
}
+
+export type Auction = {
+ id: string
+ starting_price: number
+ starts_at: Date
+ ends_at: Date
+ product_id: string
+ region_id: string
+ bids: Bid[]
+}
+
+export type Bid = {
+ id: string
+ amount: number
+ auction_id: string
+ customer_id: string
+ created_at: Date
+}