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 + + ) : ( +
+
+ setAmount(parseFloat(e.target.value))} + /> + + {error && {error}} +
+ +
+ )} +
+
+
+ + + + +
+
+ + + 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 +}