|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import Link from "next/link"; |
| 4 | +import { useState } from "react"; |
| 5 | +import { Globe } from "lucide-react"; |
| 6 | +import TokenAvatar from "./TokenAvatar"; |
| 7 | +import type { LaunchRow as L } from "@/lib/launchpad/queries"; |
| 8 | +import { ago } from "@/lib/launchpad/time"; |
| 9 | +import ChangeChip from "./ChangeChip"; |
| 10 | +import { QuoteBrandBadge } from "./MuseworldBadge"; |
| 11 | +import UnlistedPairBadge from "./UnlistedPairBadge"; |
| 12 | +import { capDisplay } from "@/lib/launchpad/market-cap"; |
| 13 | +import { addressHue } from "@/lib/launchpad/tint"; |
| 14 | +import type { ChipParts } from "@/lib/launchpad/ranking"; |
| 15 | +import { MorphAvatar, MorphName } from "./TokenMorph"; |
| 16 | +import { ChainLogo } from "./ChainLogo"; |
| 17 | +import { setPendingToken } from "@/lib/launchpad/token-transition"; |
| 18 | +import { safeSocials } from "@/lib/launchpad/socials"; |
| 19 | +import { XMark } from "./BrandMarks"; |
| 20 | +import { MagicCard } from "@/components/vendor/magic-card"; |
| 21 | +import { BorderBeam } from "@/components/vendor/border-beam"; |
| 22 | +import type { RowHighlight } from "./LaunchRow"; |
| 23 | + |
| 24 | +/** |
| 25 | + * The market list as cards: a banner, the token's mark over it, then only what decides a click (name, market cap, |
| 26 | + * ticker, change, live activity) and a buys/sells bar along the bottom edge. Volume, holders and fees stay in the |
| 27 | + * row view and on the token page. The name is the card's link and stretches over the whole card, so the social |
| 28 | + * links on the banner can be links of their own. `preview` draws the same card for the launch form: no link, no |
| 29 | + * page transition, no hover lift, and "now" for its age. |
| 30 | + */ |
| 31 | +export default function LaunchCard({ l, rank, hl = null, now, pop = false, chip = null, preview = false }: { l: L; rank?: number; hl?: RowHighlight; now: number; pop?: boolean; chip?: ChipParts | null; preview?: boolean }) { |
| 32 | + // one string: the compact form carries the mark for a quote with no dollar price |
| 33 | + const cap = capDisplay(l.fdv_quote, l.quote_usd, { key: l.quote_key, symbol: l.quote_symbol, decimals: l.quote_decimals }).compact; |
| 34 | + const trades = l.buys + l.sells; |
| 35 | + const { x, site } = safeSocials(l); |
| 36 | + const activity = chip ?? (hl?.kind === "new" ? { tier: "new" as const, count: null, label: "just launched", when: null, more: null } : null); |
| 37 | + |
| 38 | + const avatar = <TokenAvatar chain={l.chain} token={l.token} symbol={l.symbol} image={l.image_url} size={56} className="rounded-2xl" />; |
| 39 | + const body = ( |
| 40 | + <article className="group/card relative flex h-full flex-col overflow-hidden rounded-[inherit] bg-card" title={l.description || `${l.name} (${l.symbol})`}> |
| 41 | + <div className="relative h-24 shrink-0 overflow-hidden"> |
| 42 | + <Banner token={l.token} image={l.image_url} banner={l.banner_url} /> |
| 43 | + {/* the cover melts into the card, so the mark and the name read on one surface */} |
| 44 | + <span aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-2/3 bg-linear-to-b from-transparent via-card/45 to-card" /> |
| 45 | + <div className="absolute left-2 top-2 flex min-w-0 items-center gap-1 pr-10"> |
| 46 | + {rank !== undefined ? <span className={`grid h-6 min-w-6 shrink-0 place-items-center rounded-full px-1.5 text-xs font-bold tabular-nums ${rank <= 3 ? "bg-white text-black shadow-sm" : "bg-black/55 text-white ring-1 ring-white/15 backdrop-blur-md"}`}><span className="sr-only">Rank </span>{rank}</span> : null} |
| 47 | + <QuoteBrandBadge quoteKey={l.quote_key} /> |
| 48 | + {l.quote_key === "other" ? <UnlistedPairBadge symbol={l.quote_symbol} className="shrink-0" /> : null} |
| 49 | + </div> |
| 50 | + </div> |
| 51 | + <div className="flex flex-1 flex-col px-3.5 pb-3.5"> |
| 52 | + <div className="flex items-start gap-3"> |
| 53 | + <span className="relative -mt-8 shrink-0 rounded-[19px] bg-card p-[3px] shadow-[0_6px_16px_-8px_rgb(0_0_0/0.45)]"> |
| 54 | + {preview ? avatar : <MorphAvatar chain={l.chain} token={l.token}>{avatar}</MorphAvatar>} |
| 55 | + </span> |
| 56 | + <div className="min-w-0 flex-1 space-y-0.5 pt-1.5"> |
| 57 | + <div className="flex min-w-0 items-center gap-2"> |
| 58 | + {preview ? <span className="min-w-0 truncate text-[15px] font-semibold tracking-tight text-ink">{l.name}</span> : <Link |
| 59 | + href={`/t/${l.chain}/${l.token}`} |
| 60 | + // leaves a note for the token page's loading header, so the mark and name morph on the very first frame |
| 61 | + onClick={() => setPendingToken({ chain: l.chain, token: l.token, name: l.name, symbol: l.symbol, image: l.image_url })} |
| 62 | + className="min-w-0 truncate text-[15px] font-semibold tracking-tight text-ink outline-none after:absolute after:inset-0 after:z-[1] after:rounded-[inherit] focus-visible:after:ring-2 focus-visible:after:ring-brand" |
| 63 | + > |
| 64 | + <MorphName chain={l.chain} token={l.token}><span>{l.name}</span></MorphName> |
| 65 | + </Link>} |
| 66 | + {/* above the stretched name link, so each opens its own page */} |
| 67 | + {x || site ? ( |
| 68 | + <span className="relative z-[2] ml-auto flex shrink-0 items-center gap-0.5"> |
| 69 | + {x ? <a href={`https://x.com/${x}`} target="_blank" rel="noopener noreferrer nofollow" aria-label={`${l.name} on X (@${x})`} title={`@${x} on X`} className={social}><XMark className="size-3" /></a> : null} |
| 70 | + {site ? <a href={site} target="_blank" rel="noopener noreferrer nofollow" aria-label={`${l.name} website`} title={site} className={social}><Globe size={13} aria-hidden="true" /></a> : null} |
| 71 | + </span> |
| 72 | + ) : null} |
| 73 | + </div> |
| 74 | + <p className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted"> |
| 75 | + <ChainLogo chain={l.chain} size={13} /> |
| 76 | + <span className="truncate"><span className="font-mono text-body">{l.symbol}</span><span aria-hidden="true"> · </span>{preview ? "now" : <><span className="sr-only">launched </span><time dateTime={l.block_time} title={new Date(l.block_time).toUTCString()} suppressHydrationWarning>{ago(l.block_time, now)}</time><span className="sr-only"> ago</span></>}</span> |
| 77 | + </p> |
| 78 | + </div> |
| 79 | + </div> |
| 80 | + <div className="mt-3 flex items-center justify-between gap-2"> |
| 81 | + <span key={hl?.at ?? "rest"} className={`min-w-0 truncate font-mono text-lg font-bold tracking-tight text-ink tnum ${pop ? "bb-pop" : ""}`}><span className="sr-only">Market cap </span>{cap}</span> |
| 82 | + <span className="shrink-0"><span className="sr-only">Change since launch </span><ChangeChip v={l.change_from_launch} /></span> |
| 83 | + </div> |
| 84 | + {activity ? <Activity chip={activity} /> : null} |
| 85 | + </div> |
| 86 | + {/* buys against sells, flush with the bottom edge; no trades draws an empty track, never an even split */} |
| 87 | + <div className="mt-auto flex h-[3px] shrink-0 gap-px" title={trades ? `${l.buys.toLocaleString("en-US")} buys, ${l.sells.toLocaleString("en-US")} sells` : "No trades yet"}> |
| 88 | + {trades ? <><span className="bg-up" style={{ width: `${(l.buys / trades) * 100}%` }} /><span className="flex-1 bg-down" /></> : <span className="flex-1 bg-line" />} |
| 89 | + </div> |
| 90 | + </article> |
| 91 | + ); |
| 92 | + if (preview) return <div className="h-full rounded-2xl border border-line">{body}</div>; |
| 93 | + return ( |
| 94 | + <MagicCard className="h-full rounded-2xl transition-transform duration-200 hover:-translate-y-1 motion-reduce:transition-none motion-reduce:hover:translate-y-0"> |
| 95 | + {rank === 1 ? <BorderBeam size={110} duration={9} /> : null} |
| 96 | + {body} |
| 97 | + </MagicCard> |
| 98 | + ); |
| 99 | +} |
| 100 | + |
| 101 | +const social = "grid size-7 place-items-center rounded-full text-muted transition-colors hover:bg-ink/[0.06] hover:text-ink motion-reduce:transition-none"; |
| 102 | + |
| 103 | +/** Why the card sits where it sits: a live dot that pings, the wallet count in bold, and when the last outside trade was. */ |
| 104 | +function Activity({ chip }: { chip: ChipParts }) { |
| 105 | + const live = chip.tier === "live"; |
| 106 | + return ( |
| 107 | + <p className="mt-1.5 flex min-w-0 items-center gap-1.5 text-[11px] leading-4" suppressHydrationWarning> |
| 108 | + <span aria-hidden="true" className="relative flex size-1.5 shrink-0"> |
| 109 | + {live ? <span className="absolute inset-0 animate-ping rounded-full bg-up opacity-60 motion-reduce:hidden" /> : null} |
| 110 | + <span className={`relative size-1.5 rounded-full ${live ? "bg-up" : chip.tier === "new" ? "bg-ink" : "bg-line-strong"}`} /> |
| 111 | + </span> |
| 112 | + <span className={`truncate ${live ? "text-up" : chip.tier === "new" ? "text-ink" : "text-muted"}`}> |
| 113 | + {chip.count !== null ? <b className="font-semibold tabular-nums">{chip.count} </b> : null} |
| 114 | + {chip.label} |
| 115 | + {chip.when ? <span className="text-muted"> · {chip.when}</span> : null} |
| 116 | + {chip.more ? <span className="text-muted"> · {chip.more}</span> : null} |
| 117 | + </span> |
| 118 | + </p> |
| 119 | + ); |
| 120 | +} |
| 121 | + |
| 122 | +/** |
| 123 | + * The creator's banner when they uploaded one. Otherwise the banner is the logo again, blown up and blurred into a |
| 124 | + * soft wash of its own colours. No image at all (or a broken one) falls back to the token's address hue, the same |
| 125 | + * colour its default mark uses, as a quiet two-tone gradient. The token page's hero wears the same cover, so the card |
| 126 | + * you open and the page it opens look alike. |
| 127 | + */ |
| 128 | +export function Banner({ token, image, banner }: { token: string; image: string | null; banner: string | null }) { |
| 129 | + const [failed, setFailed] = useState<string | null>(null); |
| 130 | + const own = banner?.trim() || null; |
| 131 | + if (own && own !== failed) { |
| 132 | + // eslint-disable-next-line @next/next/no-img-element |
| 133 | + return <img src={own} alt="" aria-hidden="true" referrerPolicy="no-referrer" loading="lazy" decoding="async" onError={() => setFailed(own)} className="absolute inset-0 size-full object-cover transition-transform duration-700 ease-out group-hover/card:scale-[1.04] motion-reduce:transition-none" />; |
| 134 | + } |
| 135 | + const src = image?.trim() || null; |
| 136 | + return ( |
| 137 | + <span aria-hidden="true" className="absolute inset-0 block" style={{ background: `linear-gradient(135deg, hsl(${addressHue(token)} 55% 46%), hsl(${(addressHue(token) + 35) % 360} 50% 30%))` }}> |
| 138 | + {src && src !== failed ? ( |
| 139 | + // eslint-disable-next-line @next/next/no-img-element |
| 140 | + <img src={src} alt="" referrerPolicy="no-referrer" loading="lazy" decoding="async" onError={() => setFailed(src)} className="absolute inset-0 size-full scale-[2.2] object-cover blur-2xl saturate-[1.35] transition-transform duration-700 ease-out group-hover/card:scale-[2.4] motion-reduce:transition-none" /> |
| 141 | + ) : null} |
| 142 | + <span className="absolute inset-0 bg-black/15" /> |
| 143 | + </span> |
| 144 | + ); |
| 145 | +} |
0 commit comments