Files
signal-platform/frontend/src/pages/TickerDetailPage.tsx
T
dennisthiessen f59c0c3484
Deploy / lint (push) Successful in 8s
Deploy / test (push) Failing after 1m4s
Deploy / deploy (push) Has been skipped
Add system alerts log with nav badge and Admin Alerts tab.
Persist job and ingestion warnings/errors for 7 days, surface a dismissible top-nav badge, treat stale OHLCV as a warning (e.g. ticker renames), and show market bar age on the ticker freshness chip.
2026-07-14 13:38:04 +02:00

629 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useEffect, useState, lazy, Suspense } from 'react';
import { useParams } from 'react-router-dom';
import { useTickerDetail } from '../hooks/useTickerDetail';
import { useFetchSymbolData } from '../hooks/useFetchSymbolData';
import { useWatchlist, useAddToWatchlist, useRemoveFromWatchlist } from '../hooks/useWatchlist';
import { useTrades } from '../hooks/useTrades';
import { usePaperTrades } from '../hooks/usePaperTrades';
import { useActivation } from '../hooks/useActivation';
import { topPickSymbol, qualifiesSetup } from '../lib/qualification';
import type { FetchSelector } from '../api/ingestion';
import { CandlestickChart } from '../components/charts/CandlestickChart';
import { RadarChart, radarAxesFromDimensions } from '../components/charts/horizon';
import { ScoreCard } from '../components/ui/ScoreCard';
import { useTickerNames } from '../hooks/useTickers';
import { SkeletonCard } from '../components/ui/Skeleton';
import { SentimentPanel } from '../components/ticker/SentimentPanel';
import { FundamentalsPanel } from '../components/ticker/FundamentalsPanel';
import { IndicatorSelector } from '../components/ticker/IndicatorSelector';
import { RecommendationPanel } from '../components/ticker/RecommendationPanel';
import { ProductionRankStrip } from '../components/ticker/ProductionRankStrip';
import { Button } from '../components/ui/Button';
import { Callout } from '../components/ui/Callout';
import { formatPrice } from '../lib/format';
import type { TradeSetup } from '../lib/types';
import type { FieldPoint } from '../components/ticker/StandingMatrix';
// Lazy so recharts (heavy) ships in its own chunk, not the main ticker bundle.
const StandingMatrix = lazy(() => import('../components/ticker/StandingMatrix'));
const detailTabs = ['Analysis', 'Standing', 'Dimensions', 'Indicators', 'S/R Levels', 'Sentiment', 'Fundamentals'] as const;
type DetailTab = (typeof detailTabs)[number];
function SectionError({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
<Callout variant="error" onRetry={onRetry}>
{message}
</Callout>
);
}
function StatusPill({ tone, label, title }: { tone: 'blue' | 'emerald'; label: string; title?: string }) {
const tones = {
blue: 'bg-blue-500/15 text-blue-300 border-blue-500/30',
emerald: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
} as const;
return (
<span
title={title}
className={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium ${tones[tone]}`}
>
{label}
</span>
);
}
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
interface DataStatusItem {
label: string;
available: boolean;
timestamp?: string | null;
selector: FetchSelector; // what a refresh of this row fetches
paid?: boolean; // provider call that may cost money/quota
}
function RefreshIcon({ spinning }: { spinning: boolean }) {
return (
<svg className={`h-3.5 w-3.5 ${spinning ? 'animate-spin' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h5M20 20v-5h-5M4 9a8 8 0 0114-3M20 15a8 8 0 01-14 3" />
</svg>
);
}
function DataFreshnessBar({
items,
onRefresh,
pendingLabel,
busy,
}: {
items: DataStatusItem[];
onRefresh: (item: DataStatusItem) => void;
pendingLabel: string | null;
busy: boolean;
}) {
return (
<div className="flex flex-wrap gap-x-5 gap-y-2">
{items.map((item) => (
<div key={item.label} className="flex items-center gap-1.5">
<span className={`inline-block h-2 w-2 rounded-full shrink-0 ${
item.available ? 'bg-emerald-400 shadow-lg shadow-emerald-400/40' : 'bg-gray-600'
}`} />
<span className="text-xs text-gray-400">{item.label}</span>
{item.available && item.timestamp ? (
<span className="text-[10px] text-gray-500">{timeAgo(item.timestamp)}</span>
) : !item.available ? (
<span className="text-[10px] text-gray-600">no data</span>
) : null}
<button
onClick={() => onRefresh(item)}
disabled={busy}
title={item.paid ? `Fetch ${item.label} (uses provider quota)` : `Recompute ${item.label}`}
className="ml-0.5 text-gray-500 hover:text-blue-300 disabled:opacity-40 transition-colors"
>
<RefreshIcon spinning={pendingLabel === item.label} />
</button>
{item.paid && <span className="text-[9px] text-amber-500/70" title="Uses a paid/quota provider call">$</span>}
</div>
))}
</div>
);
}
export default function TickerDetailPage() {
const { symbol = '' } = useParams<{ symbol: string }>();
const [showGateTraffic, setShowGateTraffic] = useState(false);
const companyName = useTickerNames().get(symbol.toUpperCase());
const {
ohlcv,
scores,
srLevels,
gateTargetLadder,
sentiment,
fundamentals,
trades,
} = useTickerDetail(symbol, showGateTraffic);
const ingestion = useFetchSymbolData();
const watchlist = useWatchlist();
const addToWatchlist = useAddToWatchlist();
const removeFromWatchlist = useRemoveFromWatchlist();
const onWatchlist = useMemo(
() => (watchlist.data ?? []).some((e) => e.symbol.toUpperCase() === symbol.toUpperCase()),
[watchlist.data, symbol],
);
const watchlistBusy = addToWatchlist.isPending || removeFromWatchlist.isPending;
// Status labels: is there an open paper trade on this ticker, and is it the
// current top pick (same ranking the dashboard highlights)?
const openTrades = usePaperTrades('open');
// Full history for chart entry/exit arrows (open + closed on this symbol).
const paperTradeHistory = usePaperTrades();
const allTrades = useTrades();
const activation = useActivation();
const hasOpenTrade = useMemo(
() => (openTrades.data ?? []).some((t) => t.symbol.toUpperCase() === symbol.toUpperCase()),
[openTrades.data, symbol],
);
const symbolPaperTrades = useMemo(
() =>
(paperTradeHistory.data ?? []).filter(
(t) => t.symbol.toUpperCase() === symbol.toUpperCase(),
),
[paperTradeHistory.data, symbol],
);
const isTopPick = useMemo(
() => topPickSymbol(allTrades.data, activation.data)?.toUpperCase() === symbol.toUpperCase(),
[allTrades.data, activation.data, symbol],
);
const [activeTab, setActiveTab] = useState<DetailTab>('Analysis');
const [refreshingLabel, setRefreshingLabel] = useState<string | null>(null);
const dataStatus: DataStatusItem[] = useMemo(() => [
{
label: 'OHLCV',
// Market age of the latest bar (session date), not DB insert time —
// created_at stays frozen when the provider returns no new sessions.
available: !!ohlcv.data && ohlcv.data.length > 0,
timestamp: ohlcv.data?.[ohlcv.data.length - 1]?.date,
selector: ['ohlcv'] as FetchSelector,
paid: true,
},
{
label: 'Sentiment',
available: !!sentiment.data && sentiment.data.count > 0,
timestamp: sentiment.data?.scores?.[0]?.timestamp,
selector: ['sentiment'] as FetchSelector,
paid: true,
},
{
label: 'Fundamentals',
available: !!fundamentals.data && fundamentals.data.fetched_at !== null,
timestamp: fundamentals.data?.fetched_at,
selector: ['fundamentals'] as FetchSelector,
paid: true,
},
{
label: 'S/R Levels',
available: !!srLevels.data && srLevels.data.count > 0,
timestamp: srLevels.data?.levels?.[0]?.created_at,
selector: 'recompute' as FetchSelector,
},
{
label: 'Scores',
available: !!scores.data && scores.data.composite_score !== null,
timestamp: scores.data?.computed_at,
selector: 'recompute' as FetchSelector,
},
], [ohlcv.data, sentiment.data, fundamentals.data, srLevels.data, scores.data]);
const handleRefresh = (item: DataStatusItem) => {
setRefreshingLabel(item.label);
ingestion.mutate(
{ symbol, sources: item.selector },
{ onSettled: () => setRefreshingLabel(null) },
);
};
// Log trades API errors but don't disrupt the page
useEffect(() => {
if (trades.error) {
console.error('Failed to fetch trade setups:', trades.error);
}
}, [trades.error]);
const setupsForSymbol: TradeSetup[] = useMemo(() => {
if (trades.error || !trades.data) return [];
return trades.data.filter((t) => t.symbol.toUpperCase() === symbol.toUpperCase());
}, [trades.data, trades.error, symbol]);
const longSetup = useMemo(
() => setupsForSymbol?.find((s) => s.direction === 'long'),
[setupsForSymbol],
);
const shortSetup = useMemo(
() => setupsForSymbol?.find((s) => s.direction === 'short'),
[setupsForSymbol],
);
// Standing matrix: this ticker's residual momentum percentile + long confidence (from its
// setup), the field (every ticker's composite × momentum) for the cloud, and
// whether it qualifies / is the top pick.
const myMomentum = longSetup?.momentum_percentile ?? shortSetup?.momentum_percentile ?? null;
const myConfidence = longSetup?.confidence_score ?? null;
const standingField = useMemo<FieldPoint[]>(() => {
const seen = new Set<string>();
const out: FieldPoint[] = [];
for (const t of allTrades.data ?? []) {
const s = t.symbol.toUpperCase();
if (seen.has(s) || t.momentum_percentile == null) continue;
seen.add(s);
out.push({ symbol: s, composite: t.composite_score, momentum: t.momentum_percentile });
}
return out;
}, [allTrades.data]);
const standingStatus: 'top-pick' | 'qualified' | 'none' = useMemo(() => {
if (isTopPick) return 'top-pick';
if (longSetup && activation.data && qualifiesSetup(longSetup, activation.data)) return 'qualified';
return 'none';
}, [isTopPick, longSetup, activation.data]);
const gateMomentum = activation.data?.min_momentum_percentile ?? 80;
// Current price = latest close, with day-over-day change
const priceInfo = useMemo(() => {
const bars = ohlcv.data;
if (!bars || bars.length === 0) return null;
const last = bars[bars.length - 1];
const prev = bars.length > 1 ? bars[bars.length - 2] : null;
const change = prev && prev.close ? ((last.close - prev.close) / prev.close) * 100 : null;
return { price: last.close, date: last.date, change };
}, [ohlcv.data]);
// Which setup the chart overlays. 'auto' = the ticker's preferred direction.
const [overlayChoice, setOverlayChoice] = useState<'auto' | 'long' | 'short' | 'none'>('auto');
// Target chosen in the recommendation ladder, per direction — the chart
// overlay follows it.
const [chosenTargets, setChosenTargets] = useState<{ long: number | null; short: number | null }>({
long: null,
short: null,
});
const action = (longSetup ?? shortSetup)?.recommended_action ?? null;
const overlaySetup: TradeSetup | undefined = useMemo(() => {
if (overlayChoice === 'none') return undefined;
if (overlayChoice === 'long') return longSetup;
if (overlayChoice === 'short') return shortSetup;
// auto: preferred direction's setup, else the highest-confidence available
if (action?.startsWith('LONG') && longSetup) return longSetup;
if (action?.startsWith('SHORT') && shortSetup) return shortSetup;
const candidates = [longSetup, shortSetup].filter(Boolean) as TradeSetup[];
return candidates.sort((a, b) => (b.confidence_score ?? 0) - (a.confidence_score ?? 0))[0];
}, [overlayChoice, longSetup, shortSetup, action]);
// Chart overlay with the ladder-selected target applied (R:R from the ladder).
const overlayWithTarget: TradeSetup | undefined = useMemo(() => {
if (!overlaySetup) return undefined;
const chosen = chosenTargets[overlaySetup.direction as 'long' | 'short'];
if (chosen == null || chosen === overlaySetup.target) return overlaySetup;
const ladder = overlaySetup.targets?.find((t) => t.price === chosen);
return { ...overlaySetup, target: chosen, rr_ratio: ladder?.rr_ratio ?? overlaySetup.rr_ratio };
}, [overlaySetup, chosenTargets]);
// Sort visible S/R levels by strength for the table (only levels within chart zones)
const sortedLevels = useMemo(() => {
if (!srLevels.data?.visible_levels) return [];
return [...srLevels.data.visible_levels].sort((a, b) => b.strength - a.strength);
}, [srLevels.data]);
return (
<div className="space-y-6 animate-slide-up">
{/* Drill-down header — identity + chips left, score fingerprint right */}
<section className="glass overflow-hidden">
{/* Data freshness — the very first row: how fresh is what I'm looking at */}
<div className="border-b border-white/[0.06] px-6 py-2.5 sm:px-7">
<DataFreshnessBar
items={dataStatus}
onRefresh={handleRefresh}
pendingLabel={refreshingLabel}
busy={ingestion.isPending}
/>
</div>
<div className="p-6 pb-5 sm:p-7 sm:pb-5">
<div className="flex flex-wrap items-start justify-between gap-x-8 gap-y-5">
<div className="min-w-0">
<p className="num text-[10.5px] uppercase tracking-[0.28em] text-gray-500">Ticker</p>
<div className="mt-1.5 flex flex-wrap items-baseline gap-3">
<h1 className="font-display text-4xl font-bold tracking-tight text-gray-100">
{symbol.toUpperCase()}
</h1>
{companyName && (
<span className="max-w-[260px] truncate text-sm text-gray-400">{companyName}</span>
)}
</div>
{priceInfo && (
<div className="mt-2 flex flex-wrap items-baseline gap-2.5">
<span className="num text-2xl font-semibold text-gray-100">{formatPrice(priceInfo.price)}</span>
{priceInfo.change !== null && (
<span className={`num text-sm font-medium ${priceInfo.change >= 0 ? 'text-emerald-300' : 'text-red-300'}`}>
{priceInfo.change >= 0 ? '+' : ''}{priceInfo.change.toFixed(2)}%
</span>
)}
<span className="text-xs text-gray-500">last close · {timeAgo(priceInfo.date)}</span>
</div>
)}
<div className="mt-4 flex flex-wrap items-center gap-2">
{scores.data?.composite_score != null && (
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11.5px] text-gray-400">
composite score {Math.round(scores.data.composite_score)}
</span>
)}
{fundamentals.data?.next_earnings_date && (
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11.5px] text-gray-400">
earnings {new Date(fundamentals.data.next_earnings_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
)}
{isTopPick && (
<StatusPill
tone="blue"
label="★ Top Pick"
title="Current top pick - highest production-ranked qualified setup right now"
/>
)}
{hasOpenTrade && (
<StatusPill
tone="emerald"
label="● Open Trade"
title="You have an open paper trade on this ticker"
/>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-2">
<Button
variant="ghost"
onClick={() =>
onWatchlist
? removeFromWatchlist.mutate(symbol)
: addToWatchlist.mutate(symbol)
}
loading={watchlistBusy}
disabled={watchlist.isLoading}
title={onWatchlist ? 'Remove from watchlist' : 'Add to watchlist'}
className={onWatchlist ? '!text-amber-300' : ''}
>
{onWatchlist ? '★ Watching' : '☆ Add to watchlist'}
</Button>
<Button
onClick={() => { setRefreshingLabel(null); ingestion.mutate(symbol); }}
loading={ingestion.isPending}
>
{ingestion.isPending ? 'Fetching…' : 'Fetch All'}
</Button>
</div>
{scores.data && scores.data.dimensions.length >= 3 && (
<RadarChart axes={radarAxesFromDimensions(scores.data.dimensions)} size={112} />
)}
</div>
</div>
</div>
{/* Tab row — inline, hairline, overlay pills ride along on Analysis */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 border-b border-white/[0.06] px-6 sm:px-7" role="tablist" aria-label="Ticker sections">
{detailTabs.map((t) => (
<button
key={t}
role="tab"
aria-selected={activeTab === t}
onClick={() => setActiveTab(t)}
className={`-mb-px border-b pb-2.5 text-[13px] font-medium transition-colors ${
activeTab === t
? 'border-blue-500 text-gray-100'
: 'border-transparent text-gray-400 hover:text-gray-200'
}`}
>
{t}
</button>
))}
{activeTab === 'Analysis' && (longSetup || shortSetup) && (
<span className="ml-auto flex items-center gap-1.5 pb-2 text-[11px]">
<span className="text-gray-500">overlay</span>
{([
{ key: 'auto', label: 'Auto', show: true },
{ key: 'long', label: 'Long', show: !!longSetup },
{ key: 'short', label: 'Short', show: !!shortSetup },
{ key: 'none', label: 'None', show: true },
] as const).filter((o) => o.show).map((o) => (
<button
key={o.key}
onClick={() => setOverlayChoice(o.key)}
className={`rounded px-2 py-0.5 transition-colors ${
overlayChoice === o.key
? 'bg-blue-400/15 text-blue-200'
: 'text-gray-500 hover:text-gray-300'
}`}
>
{o.label}
</button>
))}
</span>
)}
</div>
{/* Tab body */}
<div className="p-6 pt-5 sm:p-7 sm:pt-5">
{activeTab === 'Analysis' && (
<div className="animate-fade-in">
{ohlcv.isLoading && <SkeletonCard className="h-[440px]" />}
{ohlcv.isError && (
<SectionError
message={ohlcv.error instanceof Error ? ohlcv.error.message : 'Failed to load OHLCV data'}
onRetry={() => ohlcv.refetch()}
/>
)}
{ohlcv.data && (
<>
<CandlestickChart
data={ohlcv.data}
srLevels={srLevels.data?.levels}
zones={srLevels.data?.zones}
gateTargetLevels={gateTargetLadder.data?.levels}
gateTargetLookbackBars={gateTargetLadder.data?.lookback_bars}
gateTargetLoading={gateTargetLadder.isLoading && showGateTraffic}
gateTargetError={gateTargetLadder.isError}
showGateTraffic={showGateTraffic}
onShowGateTrafficChange={setShowGateTraffic}
tradeSetup={overlayWithTarget}
currentPrice={priceInfo?.price}
paperTrades={symbolPaperTrades}
/>
<p className="mt-2 text-[11px] text-gray-500">
Only the nearest support &amp; resistance are drawn. Full list in the S/R Levels tab.
{srLevels.isError && ' S/R levels unavailable.'}
{gateTargetLadder.isError && ' GTL diagnostic unavailable.'}
{symbolPaperTrades.length > 0 && (
<>
{' '}
<span className="text-emerald-400/90"></span> paper entry
{' · '}
<span className="text-red-400/90"></span> paper exit
</>
)}
</p>
<ProductionRankStrip
setup={longSetup ?? shortSetup}
momentumGate={gateMomentum}
/>
</>
)}
{(longSetup || shortSetup) && (
<div className="mt-8 border-t border-white/[0.06] pt-7">
<RecommendationPanel
frameless
symbol={symbol}
longSetup={longSetup}
shortSetup={shortSetup}
currentPrice={priceInfo?.price}
nextEarningsDate={fundamentals.data?.next_earnings_date}
selectedTargets={chosenTargets}
onSelectTarget={(direction, price) =>
setChosenTargets((s) => ({ ...s, [direction]: price }))
}
/>
</div>
)}
</div>
)}
{activeTab === 'Standing' && (
<div className="hz-frameless animate-fade-in">
{scores.isLoading && <SkeletonCard className="h-80" />}
{scores.isError && (
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
)}
{scores.data && (
<>
<Suspense fallback={<SkeletonCard className="h-80" />}>
<StandingMatrix
symbol={symbol}
composite={scores.data.composite_score}
momentum={myMomentum}
field={standingField}
gateMomentum={gateMomentum}
status={standingStatus}
confidence={myConfidence}
/>
</Suspense>
{(() => {
const cb = scores.data?.composite_breakdown;
const adj = cb?.sentiment_adjustment;
const base = cb?.base_score;
if (adj == null || base == null || Math.abs(adj) < 0.05) return null;
const composite = scores.data?.composite_score ?? base + adj;
return (
<p className="mt-3 text-center text-[11px] text-gray-500">
Composite{' '}
<span className="font-semibold text-gray-300">{Math.round(composite)}</span>
{' '}= Base {Math.round(base)}{' '}
{adj >= 0 ? '+' : ''} Sentiment{' '}
<span className={adj >= 0 ? 'text-emerald-300/80' : 'text-red-300/80'}>
{Math.abs(adj).toFixed(1)}
</span>
</p>
);
})()}
</>
)}
</div>
)}
{activeTab === 'Dimensions' && (
<div className="hz-frameless animate-fade-in">
{scores.isLoading && <SkeletonCard />}
{scores.isError && (
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
)}
{scores.data && (
<ScoreCard showComposite={false} showRadar={false} compositeScore={scores.data.composite_score} dimensions={scores.data.dimensions} compositeBreakdown={scores.data.composite_breakdown} />
)}
</div>
)}
{activeTab === 'Indicators' && (
<div className="hz-frameless animate-fade-in">
<IndicatorSelector symbol={symbol} currentPrice={priceInfo?.price} />
</div>
)}
{activeTab === 'S/R Levels' && (
<div className="animate-fade-in">
{sortedLevels.length === 0 ? (
<Callout variant="empty">No S/R levels detected for this ticker yet.</Callout>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-3">Type</th>
<th className="px-4 py-3">Price Level</th>
<th className="px-4 py-3">Strength</th>
<th className="px-4 py-3">Method</th>
</tr>
</thead>
<tbody>
{sortedLevels.map((level) => (
<tr key={level.id} className="border-b border-white/[0.04] transition-colors duration-150 hover:bg-white/[0.03]">
<td className="px-4 py-3">
<span className={level.type === 'support' ? 'text-emerald-300' : 'text-gray-300'}>{level.type}</span>
</td>
<td className="px-4 py-3 text-gray-200 font-mono">{formatPrice(level.price_level)}</td>
<td className="px-4 py-3 text-gray-200">{level.strength}</td>
<td className="px-4 py-3 text-gray-400">{level.detection_method}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{activeTab === 'Sentiment' && (
<div className="hz-frameless animate-fade-in">
{sentiment.isLoading && <SkeletonCard />}
{sentiment.isError && (
<SectionError message={sentiment.error instanceof Error ? sentiment.error.message : 'Failed to load sentiment'} onRetry={() => sentiment.refetch()} />
)}
{sentiment.data && <SentimentPanel data={sentiment.data} />}
</div>
)}
{activeTab === 'Fundamentals' && (
<div className="hz-frameless animate-fade-in">
{fundamentals.isLoading && <SkeletonCard />}
{fundamentals.isError && (
<SectionError message={fundamentals.error instanceof Error ? fundamentals.error.message : 'Failed to load fundamentals'} onRetry={() => fundamentals.refetch()} />
)}
{fundamentals.data && <FundamentalsPanel data={fundamentals.data} />}
</div>
)}
</div>
</section>
</div>
);
}