From eb6edba71496f6ebc9078fbf9aa1136e7df93907 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 13 Jan 2026 23:15:48 +0800 Subject: [PATCH 1/5] feat: market page detail + campaigns --- .../components/campaign-modal.tsx | 46 ++-- .../components/charts/chart-utils.tsx | 11 +- .../components/charts/rate-chart.tsx | 6 +- .../components/charts/volume-chart.tsx | 6 +- .../components/market-header.tsx | 209 ++++++++++-------- src/hooks/useMarketCampaigns.ts | 2 + src/stores/useMarketDetailChartState.ts | 16 +- src/utils/merklApi.ts | 18 +- src/utils/merklTypes.ts | 1 + 9 files changed, 201 insertions(+), 114 deletions(-) diff --git a/src/features/market-detail/components/campaign-modal.tsx b/src/features/market-detail/components/campaign-modal.tsx index 7d568a38..b5736214 100644 --- a/src/features/market-detail/components/campaign-modal.tsx +++ b/src/features/market-detail/components/campaign-modal.tsx @@ -15,6 +15,13 @@ const CAMPAIGN_TYPE_CONFIG: Record void; @@ -22,30 +29,39 @@ type CampaignModalProps = { }; function getUrlIdentifier(campaign: SimplifiedCampaign): string { + // Always prefer opportunityIdentifier from the Opportunity object + if (campaign.opportunityIdentifier) { + return campaign.opportunityIdentifier; + } + // Fallback for legacy data switch (campaign.type) { case 'MORPHOSUPPLY_SINGLETOKEN': return campaign.targetToken?.address ?? campaign.campaignId; - case 'MULTILENDBORROW': - return campaign.opportunityIdentifier ?? campaign.campaignId; default: return campaign.marketId.slice(0, 42); } } +function formatCampaignDate(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + function CampaignRow({ campaign }: { campaign: SimplifiedCampaign }) { const urlIdentifier = getUrlIdentifier(campaign); const merklUrl = getMerklCampaignURL(campaign.chainId, campaign.type, urlIdentifier); - const { badge, actionType, actionVerb } = CAMPAIGN_TYPE_CONFIG[campaign.type] ?? CAMPAIGN_TYPE_CONFIG.MORPHOSUPPLY; + const { badge } = CAMPAIGN_TYPE_CONFIG[campaign.type] ?? CAMPAIGN_TYPE_CONFIG.MORPHOSUPPLY; return (
+ {/* Header: Badge + Reward Token + APR */}
- {/* Campaign Type Badge */} {badge} - - {/* Reward Token */}
{campaign.rewardToken.symbol}
- - {/* APR */} +{campaign.apr.toFixed(2)}% APR
-
- Extra incentives for all {actionType} who {actionVerb} to this market, earning {campaign.rewardToken.symbol} rewards. -
+ {/* Campaign Name */} + {campaign.name &&

{campaign.name}

} -
+ {/* Date Range + Link */} +
+ + {formatCampaignDate(campaign.startTimestamp)} — {formatCampaignDate(campaign.endTimestamp)} + !BLACKLISTED_CAMPAIGN_IDS.includes(c.campaignId)); + return ( - {campaigns.map((campaign) => ( + {filteredCampaigns.map((campaign) => ( = { '1d': '1D', '7d': '7D', '30d': '30D', + '3m': '3M', + '6m': '6M', }; type GradientConfig = { @@ -63,7 +65,14 @@ export function ChartTooltipContent({ active, payload, label, formatValue }: Cha if (!active || !payload) return null; return (
-

{new Date((label ?? 0) * 1000).toLocaleDateString()}

+

+ {new Date((label ?? 0) * 1000).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} +

{payload.map((entry: any) => (
{ + const handleTimeframeChange = (timeframe: '1d' | '7d' | '30d' | '3m' | '6m') => { setTimeframe(timeframe); }; @@ -124,7 +124,7 @@ function RateChart({ marketId, chainId, market }: RateChartProps) {
diff --git a/src/features/market-detail/components/charts/volume-chart.tsx b/src/features/market-detail/components/charts/volume-chart.tsx index 3169b4a1..8502b3a5 100644 --- a/src/features/market-detail/components/charts/volume-chart.tsx +++ b/src/features/market-detail/components/charts/volume-chart.tsx @@ -44,7 +44,7 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) { liquidity: true, }); - const handleTimeframeChange = (timeframe: '1d' | '7d' | '30d') => { + const handleTimeframeChange = (timeframe: '1d' | '7d' | '30d' | '3m' | '6m') => { setTimeframe(timeframe); }; @@ -180,7 +180,7 @@ function VolumeChart({ marketId, chainId, market }: VolumeChartProps) {
diff --git a/src/features/market-detail/components/market-header.tsx b/src/features/market-detail/components/market-header.tsx index 847763ea..43c685e9 100644 --- a/src/features/market-detail/components/market-header.tsx +++ b/src/features/market-detail/components/market-header.tsx @@ -1,7 +1,9 @@ 'use client'; +import { useState } from 'react'; import Image from 'next/image'; import { formatUnits } from 'viem'; +import { motion, AnimatePresence } from 'framer-motion'; import { ChevronDownIcon } from '@radix-ui/react-icons'; import { GrStatusGood } from 'react-icons/gr'; import { IoWarningOutline, IoEllipsisVertical } from 'react-icons/io5'; @@ -129,6 +131,7 @@ export function MarketHeader({ onSupplyClick, onBorrowClick, }: MarketHeaderProps) { + const [isExpanded, setIsExpanded] = useState(false); const { short: rateLabel } = useRateLabel(); const { isAprDisplay } = useAppSettings(); const networkImg = getNetworkImg(network); @@ -221,13 +224,15 @@ export function MarketHeader({ width={40} height={40} /> - +
+ +
@@ -283,7 +288,7 @@ export function MarketHeader({ content={ } > @@ -307,7 +312,7 @@ export function MarketHeader({
{/* Advanced Details - Expandable */} -
- - - Advanced Details +
+
setIsExpanded(!isExpanded)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsExpanded(!isExpanded); + } + }} + role="button" + tabIndex={0} + aria-expanded={isExpanded} + > {renderSummaryBadges()} -
- -
- {/* Global Warnings (debt + general) at top */} - - - {/* Two-column grid */} -
- {/* LEFT: Market Configuration */} -
-
-

Market Configuration

- -
- -
-
- Loan: - -
-
- Collateral: - -
-
- IRM: - -
-
+
+ Advanced Details + +
+
- {/* Asset warnings */} - -
+ + {isExpanded && ( + +
+ {/* Global Warnings (debt + general) at top */} + - {/* RIGHT: Oracle */} -
-
-

Oracle

- + {/* Two-column grid */} +
+ {/* LEFT: Market Configuration */} +
+
+

Market Configuration

+ +
+ +
+
+ Loan: + +
+
+ Collateral: + +
+
+ IRM: + +
+
+ + {/* Asset warnings */} + +
+ + {/* RIGHT: Oracle */} +
+
+

Oracle

+ +
+ + + + {/* Oracle warnings */} + +
+
- - - - {/* Oracle warnings */} - -
-
-
-
+ + )} + +
); diff --git a/src/hooks/useMarketCampaigns.ts b/src/hooks/useMarketCampaigns.ts index 9e00722b..11d5eaff 100644 --- a/src/hooks/useMarketCampaigns.ts +++ b/src/hooks/useMarketCampaigns.ts @@ -42,6 +42,8 @@ export function useMarketCampaigns(options: MarketCampaignsOptions): UseMarketCa ) : []; + console.log('singleTokenCampaigns', singleTokenCampaigns); + // Combine both types of campaigns const allMarketCampaigns = [...directMarketCampaigns, ...singleTokenCampaigns]; const activeCampaigns = allMarketCampaigns.filter((campaign) => campaign.isActive); diff --git a/src/stores/useMarketDetailChartState.ts b/src/stores/useMarketDetailChartState.ts index f7fec2c9..84c62d14 100644 --- a/src/stores/useMarketDetailChartState.ts +++ b/src/stores/useMarketDetailChartState.ts @@ -3,9 +3,10 @@ import type { TimeseriesOptions } from '@/utils/types'; const DAY_IN_SECONDS = 24 * 60 * 60; const WEEK_IN_SECONDS = 7 * DAY_IN_SECONDS; +const MONTH_IN_SECONDS = 30 * DAY_IN_SECONDS; // Helper to calculate time range based on timeframe string -const calculateTimeRange = (timeframe: '1d' | '7d' | '30d'): TimeseriesOptions => { +const calculateTimeRange = (timeframe: '1d' | '7d' | '30d' | '3m' | '6m'): TimeseriesOptions => { const endTimestamp = Math.floor(Date.now() / 1000); let startTimestamp; let interval: TimeseriesOptions['interval'] = 'HOUR'; @@ -15,7 +16,14 @@ const calculateTimeRange = (timeframe: '1d' | '7d' | '30d'): TimeseriesOptions = break; case '30d': startTimestamp = endTimestamp - 30 * DAY_IN_SECONDS; - // Use DAY interval for longer ranges if desired, adjust as needed + interval = 'DAY'; + break; + case '3m': + startTimestamp = endTimestamp - 3 * MONTH_IN_SECONDS; + interval = 'DAY'; + break; + case '6m': + startTimestamp = endTimestamp - 6 * MONTH_IN_SECONDS; interval = 'DAY'; break; default: @@ -26,13 +34,13 @@ const calculateTimeRange = (timeframe: '1d' | '7d' | '30d'): TimeseriesOptions = }; type ChartState = { - selectedTimeframe: '1d' | '7d' | '30d'; + selectedTimeframe: '1d' | '7d' | '30d' | '3m' | '6m'; selectedTimeRange: TimeseriesOptions; volumeView: 'USD' | 'Asset'; }; type ChartActions = { - setTimeframe: (timeframe: '1d' | '7d' | '30d') => void; + setTimeframe: (timeframe: '1d' | '7d' | '30d' | '3m' | '6m') => void; setVolumeView: (view: 'USD' | 'Asset') => void; }; diff --git a/src/utils/merklApi.ts b/src/utils/merklApi.ts index aa45dcce..3c3df17a 100644 --- a/src/utils/merklApi.ts +++ b/src/utils/merklApi.ts @@ -77,7 +77,19 @@ function isCampaignActive(campaign: MerklCampaign): boolean { // Helper to extract common campaign fields function getBaseCampaignFields( campaign: MerklCampaign, -): Pick { +): Pick< + SimplifiedCampaign, + | 'chainId' + | 'campaignId' + | 'type' + | 'apr' + | 'rewardToken' + | 'startTimestamp' + | 'endTimestamp' + | 'isActive' + | 'name' + | 'opportunityIdentifier' +> { return { chainId: campaign.computeChainId, campaignId: campaign.campaignId, @@ -91,6 +103,8 @@ function getBaseCampaignFields( startTimestamp: campaign.startTimestamp, endTimestamp: campaign.endTimestamp, isActive: isCampaignActive(campaign), + name: campaign.Opportunity?.name, + opportunityIdentifier: campaign.Opportunity?.identifier, }; } @@ -128,13 +142,11 @@ export function simplifyMerklCampaign(campaign: MerklCampaign): SimplifiedCampai export function expandMultiLendBorrowCampaign(campaign: MerklCampaign): SimplifiedCampaign[] { const baseFields = getBaseCampaignFields(campaign); const markets = campaign.params.markets ?? []; - const opportunityIdentifier = campaign.Opportunity?.identifier; return markets.map((m) => ({ ...baseFields, marketId: m.campaignParameters.market, collateralToken: { symbol: m.campaignParameters.symbolCollateralToken }, loanToken: { symbol: m.campaignParameters.symbolLoanToken }, - opportunityIdentifier, })); } diff --git a/src/utils/merklTypes.ts b/src/utils/merklTypes.ts index 28d5d92d..e32f6bd9 100644 --- a/src/utils/merklTypes.ts +++ b/src/utils/merklTypes.ts @@ -154,5 +154,6 @@ export type SimplifiedCampaign = { startTimestamp: number; endTimestamp: number; isActive: boolean; + name?: string; opportunityIdentifier?: string; }; From a20aff2ce5442350976c36ad62c9c92f02a5c12b Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 13 Jan 2026 23:20:57 +0800 Subject: [PATCH 2/5] chore: cleanup --- .../market-detail/components/campaign-modal.tsx | 10 +++++----- .../market-detail/components/market-header.tsx | 15 ++++----------- src/hooks/useMarketCampaigns.ts | 2 -- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/features/market-detail/components/campaign-modal.tsx b/src/features/market-detail/components/campaign-modal.tsx index b5736214..4d16d4c3 100644 --- a/src/features/market-detail/components/campaign-modal.tsx +++ b/src/features/market-detail/components/campaign-modal.tsx @@ -8,11 +8,11 @@ import { Modal, ModalBody, ModalHeader } from '@/components/common/Modal'; import { getMerklCampaignURL } from '@/utils/external'; import type { SimplifiedCampaign, MerklCampaignType } from '@/utils/merklTypes'; -const CAMPAIGN_TYPE_CONFIG: Record = { - MORPHOSUPPLY: { badge: 'Lender Rewards', actionType: 'lenders', actionVerb: 'lend' }, - MORPHOSUPPLY_SINGLETOKEN: { badge: 'Lender Rewards', actionType: 'lenders', actionVerb: 'lend' }, - MULTILENDBORROW: { badge: 'Lend/Borrow Rewards', actionType: 'users', actionVerb: 'participate in' }, - MORPHOBORROW: { badge: 'Borrow Rewards', actionType: 'borrowers', actionVerb: 'borrow' }, +const CAMPAIGN_TYPE_CONFIG: Record = { + MORPHOSUPPLY: { badge: 'Lender Rewards', actionType: 'lenders' }, + MORPHOSUPPLY_SINGLETOKEN: { badge: 'Lender Rewards', actionType: 'lenders' }, + MULTILENDBORROW: { badge: 'Lend/Borrow Rewards', actionType: 'users' }, + MORPHOBORROW: { badge: 'Borrow Rewards', actionType: 'borrowers' }, }; // Blacklisted campaign IDs - these will be filtered out diff --git a/src/features/market-detail/components/market-header.tsx b/src/features/market-detail/components/market-header.tsx index 43c685e9..bf2e6239 100644 --- a/src/features/market-detail/components/market-header.tsx +++ b/src/features/market-detail/components/market-header.tsx @@ -374,17 +374,10 @@ export function MarketHeader({ {/* Advanced Details - Expandable */}
-
setIsExpanded(!isExpanded)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setIsExpanded(!isExpanded); - } - }} - role="button" - tabIndex={0} aria-expanded={isExpanded} > {renderSummaryBadges()} @@ -392,7 +385,7 @@ export function MarketHeader({ Advanced Details
-
+ {isExpanded && ( diff --git a/src/hooks/useMarketCampaigns.ts b/src/hooks/useMarketCampaigns.ts index 11d5eaff..9e00722b 100644 --- a/src/hooks/useMarketCampaigns.ts +++ b/src/hooks/useMarketCampaigns.ts @@ -42,8 +42,6 @@ export function useMarketCampaigns(options: MarketCampaignsOptions): UseMarketCa ) : []; - console.log('singleTokenCampaigns', singleTokenCampaigns); - // Combine both types of campaigns const allMarketCampaigns = [...directMarketCampaigns, ...singleTokenCampaigns]; const activeCampaigns = allMarketCampaigns.filter((campaign) => campaign.isActive); From a7ab5af13ed891a6fedd4e19a79cb2807a2bad08 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 13 Jan 2026 23:25:36 +0800 Subject: [PATCH 3/5] chore: line orders --- .../components/charts/rate-chart.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/features/market-detail/components/charts/rate-chart.tsx b/src/features/market-detail/components/charts/rate-chart.tsx index 965e9c17..6d7b748b 100644 --- a/src/features/market-detail/components/charts/rate-chart.tsx +++ b/src/features/market-detail/components/charts/rate-chart.tsx @@ -196,6 +196,16 @@ function RateChart({ marketId, chainId, market }: RateChartProps) { {...chartLegendStyle} {...legendHandlers} /> + - )} From d4707810949168ae200aaeeecc2f0c4877e7a936 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 13 Jan 2026 23:26:55 +0800 Subject: [PATCH 4/5] chore: simplify --- .../market-detail/components/campaign-modal.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/features/market-detail/components/campaign-modal.tsx b/src/features/market-detail/components/campaign-modal.tsx index 4d16d4c3..a28399ed 100644 --- a/src/features/market-detail/components/campaign-modal.tsx +++ b/src/features/market-detail/components/campaign-modal.tsx @@ -8,11 +8,11 @@ import { Modal, ModalBody, ModalHeader } from '@/components/common/Modal'; import { getMerklCampaignURL } from '@/utils/external'; import type { SimplifiedCampaign, MerklCampaignType } from '@/utils/merklTypes'; -const CAMPAIGN_TYPE_CONFIG: Record = { - MORPHOSUPPLY: { badge: 'Lender Rewards', actionType: 'lenders' }, - MORPHOSUPPLY_SINGLETOKEN: { badge: 'Lender Rewards', actionType: 'lenders' }, - MULTILENDBORROW: { badge: 'Lend/Borrow Rewards', actionType: 'users' }, - MORPHOBORROW: { badge: 'Borrow Rewards', actionType: 'borrowers' }, +const CAMPAIGN_TYPE_CONFIG: Record = { + MORPHOSUPPLY: { badge: 'Lender Rewards' }, + MORPHOSUPPLY_SINGLETOKEN: { badge: 'Lender Rewards' }, + MULTILENDBORROW: { badge: 'Lend/Borrow Rewards' }, + MORPHOBORROW: { badge: 'Borrow Rewards' }, }; // Blacklisted campaign IDs - these will be filtered out From 8c9058ae4ffa2f9cdfd94514eb4d942530993fc9 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 13 Jan 2026 23:35:45 +0800 Subject: [PATCH 5/5] chore: effective blacklist --- .../market-detail/components/campaign-modal.tsx | 12 +----------- src/hooks/useMarketCampaigns.ts | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/features/market-detail/components/campaign-modal.tsx b/src/features/market-detail/components/campaign-modal.tsx index a28399ed..64241c1b 100644 --- a/src/features/market-detail/components/campaign-modal.tsx +++ b/src/features/market-detail/components/campaign-modal.tsx @@ -15,13 +15,6 @@ const CAMPAIGN_TYPE_CONFIG: Record = { MORPHOBORROW: { badge: 'Borrow Rewards' }, }; -// Blacklisted campaign IDs - these will be filtered out -const BLACKLISTED_CAMPAIGN_IDS: string[] = [ - // Seems to be reporting bad APY, not singleton for all market for sure - // https://app.merkl.xyz/opportunities/base/MORPHOSUPPLY_SINGLETOKEN/0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 - '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913WHITELIST_PER_PROTOCOL', -]; - type CampaignModalProps = { isOpen: boolean; onClose: () => void; @@ -106,9 +99,6 @@ function CampaignRow({ campaign }: { campaign: SimplifiedCampaign }) { export function CampaignModal({ isOpen, onClose, campaigns }: CampaignModalProps) { if (!isOpen) return null; - // Filter out blacklisted campaigns - const filteredCampaigns = campaigns.filter((c) => !BLACKLISTED_CAMPAIGN_IDS.includes(c.campaignId)); - return ( - {filteredCampaigns.map((campaign) => ( + {campaigns.map((campaign) => ( !BLACKLISTED_CAMPAIGN_IDS.includes(campaign.campaignId.toLowerCase()), + ); + + console.log(`[useMarketCampaigns] Active Campaigns for market ${normalizedMarketId.slice(0, 6)}`, allMarketCampaigns); + const activeCampaigns = allMarketCampaigns.filter((campaign) => campaign.isActive); return {