Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/components/common/CreatorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import CreatorListRowDivider from '@/components/common/CreatorListRowDivider';
import BuyActionHelperText from '@/components/common/BuyActionHelperText';
import CreatorLabeledStatRow from '@/components/common/CreatorLabeledStatRow';
import { useTransactionTelemetry } from '@/hooks/useTransactionTelemetry';
import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils';

interface CreatorCardProps {
creator: Course;
Expand Down Expand Up @@ -116,7 +117,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
<TrendingUp className="size-3 text-emerald-400" />
<span className="text-xs font-bold text-white/90">
{creator.volume24h > 0
? `${creator.volume24h} ETH`
? `${formatCompactNumber(creator.volume24h)} ETH`
: 'New'}
</span>
</div>
Expand Down Expand Up @@ -157,7 +158,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
</div>

<div className="mt-3 flex flex-wrap gap-2">
<MiniStatChip label="Price" value={`${creator.price} ETH`} />
<MiniStatChip label="Price" value={`${formatNumber(creator.price)} ETH`} />
<MiniStatChip
label="Category"
value={creator.category || 'General'}
Expand All @@ -170,7 +171,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
label="Creator Share Supply"
value={
creator.creatorShareSupply
? `${creator.creatorShareSupply} shares`
? `${formatCompactNumber(creator.creatorShareSupply)} shares`
: 'Supply pending'
}
className="px-3 py-3"
Expand Down Expand Up @@ -202,7 +203,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
/>
<CardMetaRow
label="Key Price"
value={`${creator.price} ETH`}
value={`${formatNumber(creator.price)} ETH`}
truncateValue={false}
valueClassName="font-grotesque text-base font-black text-amber-400"
/>
Expand Down
15 changes: 3 additions & 12 deletions src/components/common/KeySupplyBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Key } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Tooltip } from '@/components/ui/tooltip';
import { formatRelativeTime, type TooltipContent } from '@/utils/keyPrice.utils';
import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils';

interface KeySupplyBadgeProps {
/** Total key supply. Undefined or null renders a graceful placeholder. */
Expand All @@ -11,16 +12,6 @@ interface KeySupplyBadgeProps {
tooltipContent?: TooltipContent;
}

function formatSupply(supply: number): string {
if (supply >= 1_000_000) {
return `${(supply / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
}
if (supply >= 1_000) {
return `${(supply / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
}
return supply.toString();
}

function KeyPriceTooltipContent({ lastUpdated, quoteSource }: TooltipContent) {
const timeLabel = formatRelativeTime(lastUpdated);
const sourceLabel = quoteSource?.trim() ? `Source: ${quoteSource}` : 'Source: N/A';
Expand All @@ -45,10 +36,10 @@ const KeySupplyBadge: React.FC<KeySupplyBadgeProps> = ({ supply, className, tool
: 'border-white/10 bg-white/[0.06] text-white/40',
className
)}
title={hasData ? `${supply} keys available` : 'Supply not available'}
title={hasData ? `${formatNumber(supply)} keys available` : 'Supply not available'}
>
<Key className="size-3" aria-hidden="true" />
<span>{hasData ? formatSupply(supply!) : '—'}</span>
<span>{hasData ? formatCompactNumber(supply!) : '—'}</span>
</span>
);

Expand Down
129 changes: 129 additions & 0 deletions src/components/common/TradeDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import { formatNumber } from '@/utils/numberFormat.utils';

export type TradeSide = 'buy' | 'sell';

export interface TradeDialogProps {
open: boolean;
side: TradeSide;
creatorName: string;
availableHoldings: number;
onOpenChange: (open: boolean) => void;
onConfirm: (amount: number) => Promise<void> | void;
isSubmitting?: boolean;
}

const TradeDialog: React.FC<TradeDialogProps> = ({
open,
side,
creatorName,
availableHoldings,
onOpenChange,
onConfirm,
isSubmitting = false,
}) => {
const [amountText, setAmountText] = useState('1');
const amountInputRef = useRef<HTMLInputElement | null>(null);

useEffect(() => {
if (open) setAmountText('1');
}, [open]);

const parsedAmount = useMemo(() => {
const normalized = amountText.trim();
if (!normalized) return NaN;
return Number(normalized);
}, [amountText]);

const amountValid =
Number.isFinite(parsedAmount) &&
parsedAmount > 0 &&
(side !== 'sell' || parsedAmount <= availableHoldings);

const title = side === 'buy' ? 'Buy keys' : 'Sell keys';
const confirmLabel = side === 'buy' ? 'Confirm buy' : 'Confirm sell';

return (
<Dialog open={open} onOpenChange={next => !isSubmitting && onOpenChange(next)}>
<DialogContent
className="max-w-md"
showCloseButton={!isSubmitting}
onOpenAutoFocus={event => {
event.preventDefault();
amountInputRef.current?.focus();
}}
onEscapeKeyDown={event => {
if (isSubmitting) event.preventDefault();
}}
onInteractOutside={event => {
if (isSubmitting) event.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{side === 'buy'
? `Purchase creator keys for ${creatorName}.`
: `Sell creator keys for ${creatorName}.`}
</DialogDescription>
</DialogHeader>

<div className="space-y-2">
<div className="text-sm text-white/70">Amount</div>
<input
ref={amountInputRef}
inputMode="decimal"
value={amountText}
onChange={event => setAmountText(event.target.value)}
disabled={isSubmitting}
className={cn(
'w-full rounded-xl border bg-white/[0.04] px-3 py-2 text-white outline-none transition-colors',
'border-white/10 focus:border-amber-500/50 focus:ring-2 focus:ring-amber-500/15',
!amountValid && amountText.trim() ? 'border-red-500/40' : ''
)}
aria-label="Trade amount"
/>
<div className="text-xs text-white/45">
Holdings: {formatNumber(availableHoldings)} keys
</div>
{side === 'sell' && parsedAmount > availableHoldings && (
<div className="text-xs text-red-300">
You can’t sell more than your current holdings.
</div>
)}
</div>

<DialogFooter className="sm:justify-between">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="button"
onClick={() => onConfirm(parsedAmount)}
disabled={!amountValid || isSubmitting}
>
{isSubmitting ? 'Submitting…' : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

export default TradeDialog;

120 changes: 117 additions & 3 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import CreatorBreadcrumb from '@/components/common/CreatorBreadcrumb';
import CreatorProfileHeader from '@/components/common/CreatorProfileHeader';
import TransactionRetryNotice from '@/components/common/TransactionRetryNotice';
import EmptyTransactionTimelineState from '@/components/common/EmptyTransactionTimelineState';
import TradeDialog, { type TradeSide } from '@/components/common/TradeDialog';
import PendingTxModal from '@/components/common/PendingTxModal';
import showToast from '@/utils/toast.util';
import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils';

const FEATURED_CREATOR_FACTS = [
{ label: 'Membership', value: 'Collectors Circle' },
Expand Down Expand Up @@ -120,6 +124,11 @@ function LandingPage() {
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [activeProfileTab, setActiveProfileTab] = useState('overview');
const [featuredHoldings, setFeaturedHoldings] = useState(3);
const [tradeSide, setTradeSide] = useState<TradeSide>('buy');
const [tradeDialogOpen, setTradeDialogOpen] = useState(false);
const [tradeSubmitting, setTradeSubmitting] = useState(false);
const [pendingTxOpen, setPendingTxOpen] = useState(false);
const [sortOption, setSortOption] = useState<SortOption>(() => {
if (typeof window === 'undefined') return 'featured';
const saved = window.localStorage.getItem(CREATOR_SORT_KEY) as SortOption | null;
Expand Down Expand Up @@ -215,8 +224,49 @@ function LandingPage() {

const handleResetSearch = () => setSearchQuery('');

const openTradeDialog = (side: TradeSide) => {
setTradeSide(side);
setTradeDialogOpen(true);
};

const handleConfirmTrade = async (amount: number) => {
const previousHoldings = featuredHoldings;
setTradeSubmitting(true);
setPendingTxOpen(true);

try {
showToast.loading(
tradeSide === 'buy'
? `Submitting buy for ${amount} key${amount === 1 ? '' : 's'}...`
: `Submitting sell for ${amount} key${amount === 1 ? '' : 's'}...`
);

await new Promise<void>(resolve => window.setTimeout(resolve, 900));

setFeaturedHoldings(current =>
tradeSide === 'buy' ? current + amount : Math.max(0, current - amount)
);

await new Promise<void>(resolve => window.setTimeout(resolve, 250));

showToast.transactionSuccess(
'Trade confirmed',
tradeSide === 'buy'
? `Holdings refreshed: +${formatNumber(amount)} keys.`
: `Holdings refreshed: -${formatNumber(amount)} keys.`
);
setTradeDialogOpen(false);
} catch {
setFeaturedHoldings(previousHoldings);
showToast.error('Trade failed. Holdings have been restored.');
} finally {
setTradeSubmitting(false);
setPendingTxOpen(false);
}
};

return (
<main className="relative min-h-screen overflow-x-hidden bg-[linear-gradient(160deg,#08111f_0%,#10213b_45%,#f0b14d_160%)] px-6 py-12 md:px-12">
<main className="relative min-h-screen overflow-x-hidden bg-[linear-gradient(160deg,#08111f_0%,#10213b_45%,#f0b14d_160%)] px-6 pt-12 pb-28 md:px-12 md:pb-12">
<div className="absolute left-[-4rem] top-[10%] size-72 rounded-full bg-amber-300/20 blur-[100px]" />
<div className="absolute bottom-[8%] right-[-3rem] size-72 rounded-full bg-emerald-300/15 blur-[100px]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,186,73,0.1),transparent_40%),radial-gradient(circle_at_bottom_left,rgba(74,222,128,0.08),transparent_35%)]" />
Expand Down Expand Up @@ -382,19 +432,83 @@ function LandingPage() {
</div>
</div>
<div className="space-y-3">
<CreatorProfileInfoGrid items={FEATURED_CREATOR_FACTS} />
<CreatorProfileInfoGrid
items={[
...FEATURED_CREATOR_FACTS,
{
label: 'Your holdings',
value: `${formatNumber(featuredHoldings)} keys`,
},
]}
/>
<CreatorLabeledStatRow
label="Creator Share Supply"
value="250 shares available"
value={`${formatCompactNumber(250)} shares available`}
/>
<div className="hidden md:flex items-center gap-3">
<Button className="rounded-xl" onClick={() => openTradeDialog('buy')}>
Buy
</Button>
<Button
className="rounded-xl"
variant="outline"
onClick={() => openTradeDialog('sell')}
>
Sell
</Button>
</div>
</div>
</MarketplaceSection>

<div className="fixed inset-x-0 bottom-0 z-40 border-t border-white/10 bg-slate-950/85 backdrop-blur-md md:hidden">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-3 px-6 py-3">
<div className="min-w-0">
<div className="text-xs font-bold uppercase tracking-[0.22em] text-white/40">
Your holdings
</div>
<div className="truncate font-jakarta text-sm font-bold text-white/85">
{formatNumber(featuredHoldings)} keys
</div>
</div>
<div className="flex items-center gap-2">
<Button className="rounded-xl" size="sm" onClick={() => openTradeDialog('buy')}>
Buy
</Button>
<Button
className="rounded-xl"
size="sm"
variant="outline"
onClick={() => openTradeDialog('sell')}
>
Sell
</Button>
</div>
</div>
</div>

<SectionDivider title="Transaction timeline pattern" spacing="relaxed" />
<MarketplaceSection spacing="relaxed">
<EmptyTransactionTimelineState />
</MarketplaceSection>
</div>

<TradeDialog
open={tradeDialogOpen}
side={tradeSide}
creatorName="Alex Rivers"
availableHoldings={featuredHoldings}
isSubmitting={tradeSubmitting}
onOpenChange={setTradeDialogOpen}
onConfirm={handleConfirmTrade}
/>
<PendingTxModal
open={pendingTxOpen}
onOpenChange={setPendingTxOpen}
isLoading={true}
blockDismissal={true}
title="Confirming trade"
description="Waiting for Stellar confirmation, then refreshing holdings."
/>
</main>
);
}
Expand Down
Loading
Loading