feat(33-06): add market prices section to catalog detail page
- Add useGlobalItemPrices and useGlobalItemCommunityStats hooks - Add MarketPricesSection component with user's market MSRP prominent - Show community price stats per market with median and report count - Collapsible "Other Markets" section (collapsed by default) - Import useCurrency, useExchangeRates, formatPrice for market display Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,43 @@ export function useLinkItem() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MarketPriceData {
|
||||||
|
id: number;
|
||||||
|
globalItemId: number;
|
||||||
|
market: string;
|
||||||
|
currency: string;
|
||||||
|
priceCents: number;
|
||||||
|
source: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommunityPriceStat {
|
||||||
|
market: string;
|
||||||
|
currency: string;
|
||||||
|
medianPrice: number;
|
||||||
|
reportCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalItemPrices(globalItemId: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["global-item-prices", globalItemId],
|
||||||
|
queryFn: () =>
|
||||||
|
apiGet<{ marketPrices: MarketPriceData[] }>(
|
||||||
|
`/api/market-prices/global-items/${globalItemId}/prices`,
|
||||||
|
),
|
||||||
|
enabled: globalItemId > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalItemCommunityStats(globalItemId: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["global-item-community-stats", globalItemId],
|
||||||
|
queryFn: () =>
|
||||||
|
apiGet<CommunityPriceStat[]>(`/api/community-prices/${globalItemId}`),
|
||||||
|
enabled: globalItemId > 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useUnlinkItem() {
|
export function useUnlinkItem() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
import { GearImage, imageContainerBg } from "../../components/GearImage";
|
import { GearImage, imageContainerBg } from "../../components/GearImage";
|
||||||
import { useAuth } from "../../hooks/useAuth";
|
import { useAuth } from "../../hooks/useAuth";
|
||||||
|
import { useCurrency } from "../../hooks/useCurrency";
|
||||||
|
import { useExchangeRates } from "../../hooks/useExchangeRates";
|
||||||
import { useFormatters } from "../../hooks/useFormatters";
|
import { useFormatters } from "../../hooks/useFormatters";
|
||||||
import { useGlobalItem } from "../../hooks/useGlobalItems";
|
import {
|
||||||
|
useGlobalItem,
|
||||||
|
useGlobalItemCommunityStats,
|
||||||
|
useGlobalItemPrices,
|
||||||
|
} from "../../hooks/useGlobalItems";
|
||||||
|
import { formatPrice, type Currency } from "../../lib/formatters";
|
||||||
import { LucideIcon } from "../../lib/iconData";
|
import { LucideIcon } from "../../lib/iconData";
|
||||||
import { useUIStore } from "../../stores/uiStore";
|
import { useUIStore } from "../../stores/uiStore";
|
||||||
|
|
||||||
@@ -235,6 +243,9 @@ function GlobalItemDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Market Prices Section */}
|
||||||
|
<MarketPricesSection globalItemId={Number(globalItemId)} />
|
||||||
|
|
||||||
{/* Product page link */}
|
{/* Product page link */}
|
||||||
{item.sourceUrl && (
|
{item.sourceUrl && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
@@ -251,3 +262,99 @@ function GlobalItemDetail() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MarketPricesSection({
|
||||||
|
globalItemId,
|
||||||
|
}: { globalItemId: number }) {
|
||||||
|
const { market: userMarket } = useCurrency();
|
||||||
|
const { price } = useFormatters();
|
||||||
|
const { data: pricesData } = useGlobalItemPrices(globalItemId);
|
||||||
|
const { data: communityStats } = useGlobalItemCommunityStats(globalItemId);
|
||||||
|
const [showOtherMarkets, setShowOtherMarkets] = useState(false);
|
||||||
|
|
||||||
|
const marketPrices = pricesData?.marketPrices ?? [];
|
||||||
|
const stats = communityStats ?? [];
|
||||||
|
|
||||||
|
// No data at all — don't render section
|
||||||
|
if (marketPrices.length === 0 && stats.length === 0) return null;
|
||||||
|
|
||||||
|
const userMarketPrice = marketPrices.find((p) => p.market === userMarket);
|
||||||
|
const otherMarketPrices = marketPrices.filter(
|
||||||
|
(p) => p.market !== userMarket,
|
||||||
|
);
|
||||||
|
const userMarketStats = stats.filter((s) => s.market === userMarket);
|
||||||
|
const otherMarketStats = stats.filter((s) => s.market !== userMarket);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 mb-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 mb-3">Price</h3>
|
||||||
|
|
||||||
|
{/* User's market MSRP */}
|
||||||
|
{userMarketPrice && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-lg font-semibold text-gray-900">
|
||||||
|
{formatPrice(userMarketPrice.priceCents, userMarketPrice.currency as Currency)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 ml-2">
|
||||||
|
MSRP ({userMarketPrice.market})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Community stats for user's market */}
|
||||||
|
{userMarketStats.map((stat) => (
|
||||||
|
<p key={`${stat.market}-${stat.currency}`} className="text-sm text-gray-700 mb-1">
|
||||||
|
Community ({stat.market}):{" "}
|
||||||
|
{formatPrice(stat.medianPrice, stat.currency as Currency)} median{" "}
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
({stat.reportCount} reports)
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Other Markets collapsible */}
|
||||||
|
{(otherMarketPrices.length > 0 || otherMarketStats.length > 0) && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowOtherMarkets(!showOtherMarkets)}
|
||||||
|
className="text-sm text-gray-500 cursor-pointer hover:text-gray-700 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<LucideIcon
|
||||||
|
name={showOtherMarkets ? "chevron-down" : "chevron-right"}
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
Other Markets
|
||||||
|
</button>
|
||||||
|
{showOtherMarkets && (
|
||||||
|
<div className="pl-4 mt-2 space-y-1">
|
||||||
|
{otherMarketPrices.map((p) => (
|
||||||
|
<div key={`${p.market}-${p.currency}`}>
|
||||||
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
{formatPrice(p.priceCents, p.currency as Currency)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 ml-2">
|
||||||
|
MSRP ({p.market})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{otherMarketStats.map((stat) => (
|
||||||
|
<p
|
||||||
|
key={`${stat.market}-${stat.currency}`}
|
||||||
|
className="text-sm text-gray-700"
|
||||||
|
>
|
||||||
|
Community ({stat.market}):{" "}
|
||||||
|
{formatPrice(stat.medianPrice, stat.currency as Currency)}{" "}
|
||||||
|
median{" "}
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
({stat.reportCount} reports)
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user