![]() |
VOOZH | about |
We’re so glad you’re here. You can expect all the best TNS content to arrive Monday through Friday to keep you on top of the news and at the top of your game.
Check your inbox for a confirmation email where you can adjust your preferences and even join additional groups.
Follow TNS on your favorite social media networks.
Become a TNS follower on LinkedIn.
Check out the latest featured and trending stories while you wait for your first TNS newsletter.
``` git clone --branch starter-template --single-branch git@github.com:daveclinton/stream-bidding-site.git cd stream-bidding-site pnpm install ```
```bash NEXT_PUBLIC_STREAM_KEY=your-key-here STREAM_API_SECRET=your-secret-here NEXT_PUBLIC_API_URL=http://localhost:3000 ```
```
const apiKey = process.env.NEXT_PUBLIC_STREAM_KEY;
const apiSecret = process.env.STREAM_API_SECRET;
if (!apiKey || !apiSecret) {
console.error("Missing Stream API credentials:", { apiKey, apiSecret });
return NextResponse.json(
{ error: "Server configuration error" },
{ status: 500 }
);
}
```
```
const body = await req.json();
const { userId, productId = "product-1" } = body as {
userId?: string;
productId?: string;
};
```
```
if (!userId || typeof userId !== "string") {
return NextResponse.json(
{ error: "Valid user ID is required" },
{ status: 400 }
);
}
```
```
const product = PRODUCTS[productId];
if (!product) {
return NextResponse.json({ error: "Product not found" }, { status: 404 });
}
```
const serverClient = StreamChat.getInstance(apiKey, apiSecret);
To manage user data in the chat system, ensure the user is updated by adding or updating their information in the chat service. Use the provided `userId` to identify the user and assign them the `user` role during this process. This step guarantees that the user’s details are either created if they don’t exist or updated if they already do.
```
await serverClient.upsertUser({
id: userId,
name: userId,
role: "user",
});
```
```
const channelId = `auction-${productId}`;
const channel = serverClient.channel("messaging", channelId, {
name: `Bidding for ${product.name}`,
product: product,
auctionEnd: product.endTime.toISOString(),
created_by_id: "system",
});
try {
await channel.create();
console.log(`Channel ${channelId} created or already exists`);
} catch (error) {
console.log(
"Channel creation error (likely exists):",
(error as Error).message
);
}
await channel.addMembers([userId]);
Finally, we calculate an expiration time (seven days from now) and generate a token for the user to authenticate with the chat service.
``` const expirationTime = Math.floor(Date.now() / 1000) + 604800; const token = serverClient.createToken(userId, expirationTime); ```
```
console.log(
"Generated token for user:",
userId,
"expires:",
new Date(expirationTime * 1000).toISOString()
);
return NextResponse.json({
token,
product,
});
```
```
} catch (error) {
const typedError = error as Error;
console.error("Stream token error details:", {
message: typedError.message,
stack: typedError.stack,
});
return NextResponse.json(
{ error: "Failed to process request", details: typedError.message },
{ status: 500 }
);
}
```
const productId = new URL(req.url).searchParams.get("id");
When a `productId` is provided, search for the corresponding product within the `PRODUCTS` data using the given identifier. If the product is located, proceed with the retrieved information. If no product matches the provided `productId`, return a `404` error to indicate that the product could not be found.
```
if (productId) {
const product = PRODUCTS[productId];
if (!product) return NextResponse.json({ error: "Product not found" }, { status: 404 });
return NextResponse.json(product);
}
```
return NextResponse.json(Object.values(PRODUCTS));
If any errors occur during the process (invalid URL, database errors), we catch them and return a `500` error with a message.
```
} catch (error) {
return NextResponse.json({ error: "Failed to fetch products" }, { status: 500 });
}
```
```
const { NEXT_PUBLIC_STREAM_KEY: apiKey, STREAM_API_SECRET: apiSecret } = process.env;
if (!apiKey || !apiSecret) {
return NextResponse.json({ error: "Server configuration error" }, { status: 500 });
}
```
const serverClient = StreamChat.getInstance(apiKey, apiSecret);
To process the auction finalization, extract the `productId, winner` and `amount` from the request body. Verify that all these required fields are present in the request to ensure the necessary information is available to complete the operation successfully.
```
const { productId, winner, amount } = await req.json();
if (!productId || !winner || !amount) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
```
```
await channel.sendMessage({
text: `🏆 Auction for ${productId} is over. ${winner} won with $${amount.toFixed(2)}`,
user_id: "system",
auction_finalized: true,
winner,
final_amount: amount,
});
```
```
await channel.update({
auction_status: "completed",
winner,
final_amount: amount,
completed_at: new Date().toISOString(),
});
```
return NextResponse.json({ success: true, message: "Auction finalized successfully" });
To handle potential issues during the auction finalization, wrap the process in a try-catch block. If any errors occur, catch them and return an error response with an appropriate HTTP status code (500 for server errors) and a message detailing the issue, ensuring the requester is informed of the failure.
```
} catch (error) {
return NextResponse.json({ error: "Failed to finalize auction", details: (error as Error).message }, { status: 500 });
}
```
We have already created the `ProductListClient.tsx` and `ProductsPageSkeleton.tsx` files in the starter template within our components directory. You just need to import and use them here.
```
import { Suspense } from "react";
import ProductsList from "@/components/ProductListClient";
import { ProductsPageSkeleton } from "@/components/ProductPageSkelton";
import { getAllProducts } from "@/lib/products";
export default async function Page() {
const products = await getAllProducts();
return (
<main className="container mx-auto py-12 px-4 max-w-7xl">
<div className="space-y-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-4xl font-bold tracking-tight">Live Auctions</h1>
<p className="text-muted-foreground mt-2">
Discover unique items and place your bids before time runs out
</p>
</div>
</div>
<Suspense fallback={<ProductsPageSkeleton />}>
<ProductsList initialProducts={products} />
</Suspense>
</div>
</main>
);
}
```
```
import { Product } from "@/types/product";
import ClientBiddingPage from "./ClientBiddingPage";
import { getProductById } from "@/lib/products";
import { notFound } from "next/navigation";
export default async function ServerBiddingPage({
params,
}: {
params: Promise<{ productId: string }>;
}) {
const { productId } = await params;
let product: Product | null = null;
let error: string | null = null;
try {
product = await getProductById(productId);
if (!product) {
notFound();
}
} catch (err) {
console.error("Failed to fetch product data:", err);
error = "Failed to load product information";
}
return <ClientBiddingPage product={product} error={error} />;
}
```
```
const [client, setClient] = useState<StreamChat<DefaultGenerics> | null>(
null
);
const [channel, setChannel] = useState<StreamChannel<DefaultGenerics> | null>(
null
);
const [currentBid, setCurrentBid] = useState<number>(0);
const [highestBidder, setHighestBidder] = useState<string | null>(null);
const [bidInput, setBidInput] = useState<string>("");
const [error, setError] = useState<string | null>(initialError);
const [, setIsLoading] = useState<boolean>(false);
const [userId, setUserId] = useState<string>("");
const [isConnecting, setIsConnecting] = useState<boolean>(false);
const [isJoining, setIsJoining] = useState<boolean>(false);
const [timeRemaining, setTimeRemaining] = useState<string>("");
const [isAuctionEnded, setIsAuctionEnded] = useState<boolean>(false);
const [winner, setWinner] = useState<string | null>(null);
```
```
useEffect(() => {
setUserId(`user-${Math.random().toString(36).substring(2, 7)}`);
if (product) {
setCurrentBid(product.currentBid || product.startingBid);
const endTime = new Date(product.endTime);
if (endTime <= new Date() || product.status === "ended") {
setIsAuctionEnded(true);
setTimeRemaining("Auction ended");
}
}
}, [product]);
```
```
useEffect(() => {
if (!product) return;
const timer = setInterval(() => {
const now = new Date();
const endTime = new Date(product.endTime);
const diff = endTime.getTime() - now.getTime();
if (diff <= 0) {
clearInterval(timer);
setTimeRemaining("Auction ended");
setIsAuctionEnded(true);
if (channel && highestBidder) {
declareWinner();
}
} else {
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
if (hours > 0) {
setTimeRemaining(`${hours}h ${minutes}m ${seconds}s`);
} else {
setTimeRemaining(`${minutes}m ${seconds}s`);
}
}
}, 1000);
return () => clearInterval(timer);
}, [product, channel, highestBidder]);
```
```
const handleConnect = async () => {
if (!userId || !product) return;
try {
setError(null);
setIsConnecting(true);
// Get authentication token from backend
const res = await fetch("/api/stream-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, productId: product.id }),
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || "Failed to fetch token");
}
const { token } = (await res.json()) as { token: string };
const apiKey = process.env.NEXT_PUBLIC_STREAM_KEY;
if (!apiKey) {
throw new Error("Stream API key is not configured");
}
// Disconnect existing user if connected
if (client) {
await client.disconnectUser();
}
// Create and connect new client
const chatClient = StreamChat.getInstance<DefaultGenerics>(apiKey);
await chatClient.connectUser(
{
id: userId,
name: userId,
image: "<https://i.imgur.com/fR9Jz14.png>", // Avatar image
},
token
);
setClient(chatClient);
// Set up reconnection logic
chatClient.on((event: Event<DefaultGenerics>) => {
if (event.type === "connection.changed" && !event.online) {
console.log("Connection lost, attempting to reconnect...");
setError("Connection lost. Reconnecting...");
handleConnect();
}
});
await joinChannel(chatClient);
} catch (err) {
const typedError = err as Error;
console.error("Connect error:", typedError.message);
setError(`Failed to connect: ${typedError.message}`);
} finally {
setIsConnecting(false);
}
};
```
```
const joinChannel = async (chatClient: StreamChat<DefaultGenerics>) => {
if (!chatClient.user || !product) {
setError("Client not connected or product not available. Please reconnect.");
handleConnect();
return;
}
try {
setIsJoining(true);
setError(null);
// Create or join channel for this specific auction
const channelId = `auction-${product.id}`;
const chatChannel = chatClient.channel("messaging", channelId, {
name: `Bidding for ${product.name}`,
product: product,
auctionEnd: new Date(product.endTime).toISOString(),
});
// Start watching for messages
await chatChannel.watch();
setChannel(chatChannel);
// Load existing messages and find current highest bid
const response = await chatChannel.query({ messages: { limit: 100 } });
const messages = response.messages || [];
// Check if auction has already ended
const auctionEndMessage = messages.find((msg) => msg.auctionEnd === true);
if (auctionEndMessage) {
setIsAuctionEnded(true);
setWinner((auctionEndMessage.winner as string) || null);
if (typeof auctionEndMessage.finalBid === "number") {
setCurrentBid(auctionEndMessage.finalBid);
}
}
// Parse bid history from messages
const bidMessages: BidMessage[] = messages
.map((msg) => {
const text = msg.text || "";
const match = text.match(/(\\w+) placed a bid of \\$(\\d+\\.?\\d*)/);
if (match) {
const [, bidder, amount] = match;
return { bidder, amount: Number.parseFloat(amount) };
}
return null;
})
.filter((bid): bid is BidMessage => bid !== null);
// Set current highest bid
if (bidMessages.length > 0) {
const highestBid = bidMessages.reduce((prev, current) =>
prev.amount > current.amount ? prev : current
);
setCurrentBid(Math.max(highestBid.amount, product.startingBid));
setHighestBidder(highestBid.bidder);
} else {
setCurrentBid(product.startingBid);
}
// Listen for new messages/bids
chatChannel.on((event: Event<DefaultGenerics>) => {
if (event.type === "message.new") {
const messageText = event.message?.text || "";
if (event.message?.auctionEnd === true) {
setIsAuctionEnded(true);
setWinner((event.message.winner as string) || null);
return;
}
const match = messageText.match(/(\\w+) placed a bid of \\$(\\d+\\.?\\d*)/);
if (match) {
const [, bidder, amount] = match;
const bidValue = Number.parseFloat(amount);
if (bidValue > currentBid) {
setCurrentBid(bidValue);
setHighestBidder(bidder);
}
}
}const handleBid = async () => {
if (!channel || !product) {
setError("Please join the channel first.");
return;
}
if (isAuctionEnded) {
setError("This auction has ended.");
return;
}
const bidValue = Number.parseFloat(bidInput);
if (isNaN(bidValue)) {
setError("Please enter a valid number.");
return;
}
if (bidValue <= currentBid) {
setError(
`Your bid must be higher than the current bid of $${currentBid.toFixed(2)}.`
);
return;
}
try {
setIsLoading(true);
setError(null);
await channel.sendMessage({
text: `${userId} placed a bid of $${bidValue.toFixed(2)}`,
});
setCurrentBid(bidValue);
setHighestBidder(userId);
setBidInput("");
} catch (err) {
const typedError = err as Error;
console.error("Bid error:", typedError.message);
setError(`Failed to place bid: ${typedError.message}`);
} finally {
setIsLoading(false);
}
};
});
} catch (err) {
const typedError = err as Error;
console.error("Join channel error:", typedError.message);
setError(`Failed to join bidding room: ${typedError.message}`);
} finally {
setIsJoining(false);
}
};
```
```
const handleBid = async () => {
if (!channel || !product) {
setError("Please join the channel first.");
return;
}
if (isAuctionEnded) {
setError("This auction has ended.");
return;
}
const bidValue = Number.parseFloat(bidInput);
if (isNaN(bidValue)) {
setError("Please enter a valid number.");
return;
}
if (bidValue <= currentBid) {
setError(
`Your bid must be higher than the current bid of $${currentBid.toFixed(2)}.`
);
return;
}
try {
setIsLoading(true);
setError(null);
await channel.sendMessage({
text: `${userId} placed a bid of $${bidValue.toFixed(2)}`,
});
setCurrentBid(bidValue);
setHighestBidder(userId);
setBidInput("");
} catch (err) {
const typedError = err as Error;
console.error("Bid error:", typedError.message);
setError(`Failed to place bid: ${typedError.message}`);
} finally {
setIsLoading(false);
}
};
```
```
const declareWinner = async () => {
if (!channel || !highestBidder || !product) return;
try {
await channel.sendMessage({
text: `🎉 Auction ended! ${highestBidder} won with a bid of $${currentBid.toFixed(2)}`,
auctionEnd: true,
winner: highestBidder,
finalBid: currentBid,
});
setWinner(highestBidder);
await fetch("/api/finalize-auction", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
productId: product.id,
winner: highestBidder,
amount: currentBid,
}),
});
} catch (err) {
console.error("Failed to declare winner:", err);
setError("Failed to finalize auction");
}
};
```
```
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
Chat,
Channel,
Window,
ChannelHeader,
MessageList,
MessageInput,
} from "stream-chat-react";
import "stream-chat-react/dist/css/v2/index.css";
import {
StreamChat,
type Channel as StreamChannel,
type DefaultGenerics,
} from "stream-chat";
type ChatInterfaceProps = {
client: StreamChat<DefaultGenerics> | null;
channel: StreamChannel<DefaultGenerics> | null;
isJoining: boolean;
isConnecting: boolean;
handleConnect: () => Promise<void>;
isAuctionEnded: boolean;
};
export default function ChatInterface({
client,
channel,
isJoining,
isConnecting,
handleConnect,
isAuctionEnded,
}: ChatInterfaceProps) {
return (
<div className="w-full md:w-2/3 h-screen">
{client && channel ? (
<div className="h-full">
<Chat client={client} theme="messaging light">
<Channel channel={channel}>
<Window>
<ChannelHeader />
<MessageList />
<MessageInput disabled={isAuctionEnded} />
</Window>
</Channel>
</Chat>
</div>
) : (
<div
className={cn(
"flex justify-center items-center h-full",
"bg-muted/30"
)}
>
<div className="text-center p-8 max-w-md">
<h2 className="text-xl font-semibold mb-4">Live Auction Chat</h2>
<p className="text-muted-foreground mb-6">
Join the auction to view the live bidding chat and interact with
other bidders
</p>
{isJoining ? (
<div className="flex justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : (
<Button onClick={handleConnect} disabled={isConnecting}>
Join Now
</Button>
)}
</div>
</div>
)}
</div>
);
}
```
```
return (
<div className="flex flex-col md:flex-row min-h-screen bg-background">
<div className="w-full md:w-1/3 p-6 border-r">
<Button variant="ghost" size="sm" className="mb-6" asChild>
<Link href="/">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to All Auctions
</Link>
</Button>
<div className="space-y-6">
<ProductDetails product={product} />
<AuctionStatus
isAuctionEnded={isAuctionEnded}
timeRemaining={timeRemaining}
currentBid={currentBid}
highestBidder={highestBidder}
winner={winner}
userId={userId}
/>
<BiddingInterface
client={client}
userId={userId}
currentBid={currentBid}
isAuctionEnded={isAuctionEnded}
isConnecting={isConnecting}
isLoading={isJoining}
handleConnect={handleConnect}
handleBid={handleBid}
error={error}
winner={winner}
bidInput={bidInput}
setBidInput={setBidInput}
/>
</div>
</div>
<ChatInterface
client={client}
channel={channel}
isJoining={isJoining}
isConnecting={isConnecting}
handleConnect={handleConnect}
isAuctionEnded={isAuctionEnded}
/>
</div>
);
```
npm install -g vercel
vercel login
At the root of our bidding project, just prompt:
vercel
And you will get a prompt with a few questions to set up your project:
vercel env add