From f3748eaa59e380ff423165d51edb0c20fc825904 Mon Sep 17 00:00:00 2001 From: Victor Gerbrands Date: Mon, 18 Mar 2024 16:26:20 +0100 Subject: [PATCH 1/2] feat: auction - init --- .../(main)/products/[handle]/page.tsx | 49 ++++- src/lib/data/index.ts | 30 +++ src/lib/util/time-ago.ts | 18 ++ src/modules/products/actions.ts | 21 +++ .../components/auction-actions/index.tsx | 172 ++++++++++++++++++ .../components/auction-countdown/index.tsx | 66 +++++++ .../components/auction-time-ago/index.tsx | 8 + .../components/product-preview/price.tsx | 2 +- src/modules/products/templates/index.tsx | 17 +- .../product-actions-wrapper/index.tsx | 2 +- src/types/global.ts | 18 ++ 11 files changed, 385 insertions(+), 18 deletions(-) create mode 100644 src/lib/util/time-ago.ts create mode 100644 src/modules/products/actions.ts create mode 100644 src/modules/products/components/auction-actions/index.tsx create mode 100644 src/modules/products/components/auction-countdown/index.tsx create mode 100644 src/modules/products/components/auction-time-ago/index.tsx 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 8cc0d3073..6234ec41f 100644 --- a/src/lib/data/index.ts +++ b/src/lib/data/index.ts @@ -49,6 +49,36 @@ const getMedusaHeaders = (tags: string[] = []) => { return headers } +// Auction actions +export async function listAuctions(productId: string) { + 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..cf9fdefc7 --- /dev/null +++ b/src/modules/products/actions.ts @@ -0,0 +1,21 @@ +"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 { + await createBid(auctionId, amount, customerId) + revalidateTag("auctions") + } 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..713d8d70c --- /dev/null +++ b/src/modules/products/components/auction-actions/index.tsx @@ -0,0 +1,172 @@ +"use client" + +import { Customer, Region } from "@medusajs/medusa" +import { PricedProduct } from "@medusajs/medusa/dist/types/pricing" +import { Button, Heading, Input, Text } from "@medusajs/ui" +import { FormEvent, useRef, useState } from "react" + +import Divider from "@modules/common/components/divider" +import { placeBid } from "@modules/products/actions" +import { formatAmount } from "@lib/util/prices" +import { Auction } from "types/global" +import AuctionCountdown from "../auction-countdown" +import User from "@modules/common/icons/user" +import AuctionTimeAgo from "../auction-time-ago" + +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?.reduce((a, b) => { + return Math.max(a, b.amount) + }, 0) + + const currentBid = formatAmount({ + amount: maxBid || auction.starting_price, + region, + }) + + const minNextBid = formatAmount({ + amount: maxBid + 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 || auction.starting_price)) { + setIsLoading(false) + setError("Please enter an amount higher than the current bid") + return + } + + await placeBid({ + auctionId: auction.id, + amount: amount * 100 || 0, + customerId: customer.id, + }).catch((e) => { + setError(e) + }) + + setAmount(undefined) + formRef.current?.reset() + setIsLoading(false) + } + + if (!customer) + return ( +
+

Sign in to place a bid

+
+ ) + + return ( +
+
+ + + +
+
+ Current bid: + {currentBid} +
+
+
+ setAmount(parseFloat(e.target.value))} + /> + + {error && {error}} +
+ +
+ +
+
+
+ + + {auction.bids.length ? "All bids" : "No bids yet"} + +
+ {auction.bids?.map((bid, idx) => { + const bidder = bid.customer_id.slice(-4) + return ( + + + {bidder} + + + {formatAmount({ amount: bid.amount, region })} + + + + ) + })} +
+
+
+
+ + Ends at: {new Date(auction.ends_at).toDateString()} + +
+
+
+ ) +} 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..2a97c237a --- /dev/null +++ b/src/modules/products/components/auction-countdown/index.tsx @@ -0,0 +1,66 @@ +"use client" + +import { Text } from "@medusajs/ui" +import React, { useState, useEffect } from "react" + +interface Props { + targetDate: Date +} + +const AuctionCountdown: React.FC = ({ targetDate }) => { + 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..ab3bd48f1 --- /dev/null +++ b/src/modules/products/components/auction-time-ago/index.tsx @@ -0,0 +1,8 @@ +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/price.tsx b/src/modules/products/components/product-preview/price.tsx index 80df56dcb..99e38aaff 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 8c5a85ba6..1151d09d3 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 +} From b5c5514b8b4cbf8b9b32dc72625b880dd9e66ac2 Mon Sep 17 00:00:00 2001 From: Victor Gerbrands Date: Tue, 19 Mar 2024 15:21:24 +0100 Subject: [PATCH 2/2] fix --- src/lib/data/index.ts | 10 +- src/modules/products/actions.ts | 3 +- .../components/auction-actions/index.tsx | 194 ++++++++++-------- .../components/auction-bids/index.tsx | 53 +++++ .../components/auction-countdown/index.tsx | 10 +- .../components/auction-time-ago/index.tsx | 4 +- .../components/product-preview/index.tsx | 23 ++- 7 files changed, 194 insertions(+), 103 deletions(-) create mode 100644 src/modules/products/components/auction-bids/index.tsx diff --git a/src/lib/data/index.ts b/src/lib/data/index.ts index 6234ec41f..c0d9d9e6e 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" @@ -50,7 +54,9 @@ const getMedusaHeaders = (tags: string[] = []) => { } // Auction actions -export async function listAuctions(productId: string) { +export async function listAuctions( + productId: string +): Promise<{ auctions: Auction[] }> { const headers = getMedusaHeaders(["auctions"]) return fetch( diff --git a/src/modules/products/actions.ts b/src/modules/products/actions.ts index cf9fdefc7..4c99d140b 100644 --- a/src/modules/products/actions.ts +++ b/src/modules/products/actions.ts @@ -13,8 +13,9 @@ export async function placeBid({ customerId: string }) { try { - await createBid(auctionId, amount, customerId) + 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 index 713d8d70c..020153f8a 100644 --- a/src/modules/products/components/auction-actions/index.tsx +++ b/src/modules/products/components/auction-actions/index.tsx @@ -2,16 +2,16 @@ import { Customer, Region } from "@medusajs/medusa" import { PricedProduct } from "@medusajs/medusa/dist/types/pricing" -import { Button, Heading, Input, Text } from "@medusajs/ui" -import { FormEvent, useRef, useState } from "react" +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 { formatAmount } from "@lib/util/prices" import { Auction } from "types/global" +import AuctionBids from "../auction-bids" import AuctionCountdown from "../auction-countdown" -import User from "@modules/common/icons/user" -import AuctionTimeAgo from "../auction-time-ago" +import LocalizedClientLink from "@modules/common/components/localized-client-link" type AuctionsActionsProps = { product: PricedProduct @@ -36,17 +36,21 @@ export default function AuctionsActions({ const [isloading, setIsLoading] = useState(false) const [error, setError] = useState(null) - const maxBid = auction.bids?.reduce((a, b) => { - return Math.max(a, b.amount) - }, 0) + 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 || auction.starting_price, + amount: maxBid?.amount, region, }) const minNextBid = formatAmount({ - amount: maxBid + 500 || auction.starting_price + 500, + amount: maxBid ? maxBid?.amount + 500 : auction?.starting_price + 500, region, }) @@ -70,9 +74,9 @@ export default function AuctionsActions({ return } - if (amount * 100 < (maxBid || auction.starting_price)) { + if (amount * 100 < (maxBid?.amount + 500 || auction.starting_price + 500)) { setIsLoading(false) - setError("Please enter an amount higher than the current bid") + setError("Please enter an amount higher than " + minNextBid) return } @@ -80,93 +84,111 @@ export default function AuctionsActions({ auctionId: auction.id, amount: amount * 100 || 0, customerId: customer.id, - }).catch((e) => { - setError(e) }) + .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) } - if (!customer) - return ( -
-

Sign in to place a bid

-
- ) - return (
-
- - - -
-
- Current bid: - {currentBid} -
-
-
- setAmount(parseFloat(e.target.value))} - /> - - {error && {error}} -
- -
- -
-
-
+ {!auction &&

No active auction.

} + + {auction && ( + <> +
+ + + + - - {auction.bids.length ? "All bids" : "No bids yet"} - +
+
+ + 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}} +
+ +
+ )} +
- {auction.bids?.map((bid, idx) => { - const bidder = bid.customer_id.slice(-4) - return ( - - - {bidder} - - - {formatAmount({ amount: bid.amount, region })} - - - - ) - })} +
+ + + + +
+ + + Ends at: {new Date(auction.ends_at).toDateString()} + +
-
-
- - 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 index 2a97c237a..22b2fb522 100644 --- a/src/modules/products/components/auction-countdown/index.tsx +++ b/src/modules/products/components/auction-countdown/index.tsx @@ -1,13 +1,9 @@ "use client" import { Text } from "@medusajs/ui" -import React, { useState, useEffect } from "react" +import { useState, useEffect } from "react" -interface Props { - targetDate: Date -} - -const AuctionCountdown: React.FC = ({ targetDate }) => { +const AuctionCountdown = ({ targetDate }: { targetDate: Date }) => { const calculateTimeLeft = () => { const difference = targetDate.getTime() - new Date().getTime() let timeLeft = {} @@ -42,7 +38,7 @@ const AuctionCountdown: React.FC = ({ targetDate }) => { } timerComponents.push( - + {timeLeft[interval as keyof typeof timeLeft]} {interval}{" "} diff --git a/src/modules/products/components/auction-time-ago/index.tsx b/src/modules/products/components/auction-time-ago/index.tsx index ab3bd48f1..897f6ab30 100644 --- a/src/modules/products/components/auction-time-ago/index.tsx +++ b/src/modules/products/components/auction-time-ago/index.tsx @@ -2,7 +2,9 @@ import { timeAgo } from "@lib/util/time-ago" import { Bid } from "types/global" const AuctionTimeAgo = ({ bid }: { bid: Bid }) => ( - {timeAgo(new Date(bid.created_at))} + + {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 e794ec5f9..bf5ec5208 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}