diff --git a/app/(site)/(directories)/launch-directories/[slug]/directory.module.css b/app/(site)/(directories)/launch-directories/[slug]/directory.module.css new file mode 100644 index 00000000..837c292e --- /dev/null +++ b/app/(site)/(directories)/launch-directories/[slug]/directory.module.css @@ -0,0 +1,143 @@ +/* One launch directory: hero, fact tiles, then short sections split by space. */ + +.mono { font-family: var(--font-mono); } +.muted { color: var(--color-text-muted); } + +/* ---------- Hero ---------- */ +.header h1 { display: flex; align-items: center; gap: var(--space-6); } +.header h1 > span:first-child { flex: none; } + +.meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2) var(--space-7); + color: var(--color-text-muted); + font: var(--text-small) / 1.4 var(--font-mono); +} + +.meta span { display: inline-flex; align-items: center; gap: var(--space-2); background: var(--color-bg); } +.meta svg { width: 14px; height: 14px; } +.status { color: var(--color-warning-text); } + +.actions { display: flex; flex-wrap: wrap; gap: var(--space-4); margin-top: var(--space-3); } + +.primary, .secondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-3); + min-height: var(--control-height-lg); + padding: 9px 16px; + border-radius: var(--radius); + font-weight: var(--weight-semibold); + text-decoration: none; + white-space: nowrap; +} + +.primary { background: var(--color-accent); border: 1px solid var(--color-accent); color: var(--color-accent-contrast); } +.primary:hover { filter: brightness(0.96); } +.secondary { background: var(--color-surface); border: 1px solid var(--color-border-strong); color: var(--color-text); } +.secondary:hover { background: var(--color-surface-2); } + +/* ---------- Body ---------- */ +.section { display: grid; gap: var(--space-7); min-width: 0; margin-top: var(--space-14); } +.section h2 { margin: 0; font: var(--weight-bold) var(--text-h2) / var(--line-heading) var(--font-display); letter-spacing: var(--tracking-display); } +.lede { margin: calc(-1 * var(--space-3)) 0 0; color: var(--color-text-muted); font-size: var(--text-small); } + +/* ---------- Fact tiles ---------- */ +.facts { display: grid; gap: var(--space-8); } +.tileGroup { display: grid; gap: var(--space-3); } +.tilesLabel { margin: 0; color: var(--color-text-muted); font: var(--text-small) var(--font-mono); } + +.tiles { + display: grid; + margin: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius); +} + +.tiles > div { display: grid; align-content: start; gap: var(--space-2); min-width: 0; padding: var(--space-8) var(--space-9); } +.tiles > div + div { border-left: 1px solid var(--color-border); } +.tiles dt { color: var(--color-text-muted); font: var(--text-small) var(--font-mono); } +.tiles dd { margin: 0; display: flex; align-items: center; gap: var(--space-4); font: var(--weight-semibold) 1.25rem / 1.25 var(--font-mono); overflow-wrap: anywhere; } +.tiles dd.tileNote { color: var(--color-text-muted); font: var(--weight-regular) var(--text-small) / 1.4 var(--font-mono); } + +.meter { display: inline-flex; gap: 3px; } +.meter i { width: 6px; height: 14px; border-radius: 1px; background: var(--color-surface-2); } +.meter i[data-on] { background: var(--color-text); } + +.gapLine { margin: 0; color: var(--color-text-muted); font: var(--text-small) / 1.5 var(--font-mono); } + +/* ---------- Pricing ---------- */ +.tableFrame { min-width: 0; overflow-x: auto; border: 1px solid var(--color-border); border-radius: var(--radius); } +.table { width: 100%; border-collapse: collapse; font-size: var(--text-small); } +.table th, .table td { padding: var(--space-6) var(--space-8); border-bottom: 1px solid var(--color-border); text-align: left; vertical-align: top; } +.table thead th { color: var(--color-text-muted); font: var(--weight-regular) 0.78rem var(--font-mono); background: var(--color-surface); } +.table tbody th { font-weight: var(--weight-semibold); white-space: nowrap; } +.table tbody tr:last-child > * { border-bottom: 0; } + +/* ---------- Who can submit ---------- */ +.rule, .needs { + display: grid; + gap: var(--space-3); + max-width: var(--measure-prose); + padding: var(--space-9) var(--space-10); + border: 1px solid var(--color-border); + border-radius: var(--radius); +} + +.ruleLabel { margin: 0; color: var(--color-text-muted); font: var(--text-small) var(--font-mono); } +.ruleText { margin: 0; font-size: 1.05rem; line-height: 1.45; } +.ruleNote { margin: 0; color: var(--color-text-muted); font-size: var(--text-small); } +.needs ul { display: grid; gap: var(--space-3); margin: 0; padding: 0; list-style: none; } +.needs li { display: flex; gap: var(--space-4); align-items: baseline; } +.needs li::before { content: ""; flex: none; width: 12px; height: 12px; border: 1px solid var(--color-border-strong); border-radius: var(--radius); transform: translateY(1px); } + +/* ---------- Steps ---------- */ +.steps { + counter-reset: step; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 190px), 1fr)); + gap: var(--space-9); + margin: 0; + padding: 0; + list-style: none; +} + +.steps li { counter-increment: step; display: grid; align-content: start; gap: var(--space-3); padding-top: var(--space-7); border-top: 1px solid var(--color-border-strong); min-width: 0; } +.steps li::before { content: counter(step, decimal-leading-zero); color: var(--color-text-muted); font: var(--text-small) var(--font-mono); } +.steps b { font: var(--weight-bold) 1.05rem / 1.25 var(--font-display); } +.steps p { margin: 0; color: var(--color-text-muted); font-size: var(--text-small); overflow-wrap: anywhere; } +.steps p b { color: var(--color-text); font: inherit; font-weight: var(--weight-semibold); } + +/* ---------- Link check ---------- */ +.linkCheck { display: grid; gap: var(--space-3); max-width: var(--measure-prose); } +.linkCheck p { margin: 0; } +.linkKind { font: var(--weight-bold) 1.4rem / 1.2 var(--font-mono); } +.linkCheck a, .dated a, .fix a, .sideMore a { color: var(--color-link); text-underline-offset: 3px; } + +/* ---------- Dated lists ---------- */ +.dated { display: grid; margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--color-border); } +.dated li { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: var(--space-8); padding: var(--space-6) 0; border-bottom: 1px solid var(--color-border); font-size: var(--text-small); } +.dated li > span { color: var(--color-text-muted); } +.dated p { margin: 0; overflow-wrap: anywhere; } +.dated p + p { margin-top: var(--space-2); } + +/* ---------- Sidebar ---------- */ +.sideLabel { margin: 0 0 var(--space-3); color: var(--color-text-muted); font: var(--text-small) var(--font-mono); } +.sideMore { margin: var(--space-3) 0 0; font: var(--text-small) var(--font-mono); } +.sideCredit { margin: var(--space-3) 0 0; color: var(--color-text-muted); font: 0.78rem / 1.4 var(--font-mono); } + +.fix { margin: var(--space-14) 0 0; padding-top: var(--space-10); border-top: 1px solid var(--color-border); color: var(--color-text-muted); font-size: var(--text-small); } + +@container (max-width: 700px) { + .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } + .tiles > div + div { border-left: 0; } + .tiles > div:nth-child(even) { border-left: 1px solid var(--color-border); } + .tiles > div:nth-child(n + 3) { border-top: 1px solid var(--color-border); } + .tiles > div:last-child:nth-child(odd) { grid-column: 1 / -1; } + .tiles > div { padding: var(--space-6) var(--space-7); } + .tiles dd { font-size: 1.05rem; } + .dated li { grid-template-columns: minmax(0, 1fr); gap: var(--space-1); } +} diff --git a/app/(site)/(directories)/launch-directories/[slug]/layout.tsx b/app/(site)/(directories)/launch-directories/[slug]/layout.tsx new file mode 100644 index 00000000..e579574c --- /dev/null +++ b/app/(site)/(directories)/launch-directories/[slug]/layout.tsx @@ -0,0 +1,7 @@ +import { registryLayout } from "@/lib/registry-layout"; + +const registry = registryLayout("/launch-directories/[slug]"); +export const generateMetadata = registry.generateMetadata; +export function generateStaticParams() { return []; } +export const revalidate = false; +export default registry.Layout; diff --git a/app/(site)/(directories)/launch-directories/[slug]/page.tsx b/app/(site)/(directories)/launch-directories/[slug]/page.tsx new file mode 100644 index 00000000..2a8c5542 --- /dev/null +++ b/app/(site)/(directories)/launch-directories/[slug]/page.tsx @@ -0,0 +1,317 @@ +import { ArrowRight, ExternalLink } from "lucide-react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import type { ReactNode } from "react"; +import { getBreadcrumbs } from "@/lib/breadcrumbs"; +import { getRequiredDirectoryPage } from "@/lib/directory-pages"; +import { getLaunchDirectorySnapshot, launchCategories, listLaunchDirectorySlugs, type LaunchDirectory } from "@/lib/launch-directories"; +import { launchDirectoryHasFreeOption, sortLaunchDirectories } from "@/lib/launch-directories-query"; +import { DirectoryPageTitle } from "@/app/ui/directories/directory-page-content"; +import { AppShell, SidebarLabel } from "@/app/ui/site/app-shell"; +import { CategoryIcon } from "@/app/ui/site/category-icon"; +import { EntryHero } from "@/app/ui/site/entry-hero"; +import { PageCanvas } from "@/app/ui/site/page-canvas"; +import { PageHeader } from "@/app/ui/site/page-header"; +import { SidebarList } from "@/app/ui/site/sidebar-list"; +import { sidebarPanelClassName, sidebarPanelHeadingClassName } from "@/app/ui/site/sidebar-panel"; +import { LaunchCard, LaunchGrid, launchHref } from "../launch-card"; +import { correctionEmail, linkOptions } from "../launch-directory-options"; +import { + costShort, domainOf, formatDate, formatPrice, formatShortDate, isSubmitReady, launchSectionNav, linkShort, + shortUrl, submitStatusText, trafficLabels, trafficSteps, waitShort, +} from "../launch-format"; +import { LaunchLogo } from "../launch-logo"; +import { AhrefsCredit } from "../ahrefs-credit"; +import styles from "./directory.module.css"; + +// Build every place during the release. A place without a published registry row still returns 404. +export async function generateStaticParams() { + return (await listLaunchDirectorySlugs()).map((slug) => ({ slug })); +} + +const categoryMap = Object.fromEntries(launchCategories.map((category) => [category.value, category])); + +const linkMeaning: Partial> = { + dofollow: "Nothing on the sampled link tells search engines to skip it.", + nofollow: "The link asks search engines not to follow it.", + ugc: "The link is marked as user-generated content, which search engines treat like nofollow.", + sponsored: "The link is marked as paid.", + multiple: "The sampled link carried more than one value, such as ugc and nofollow.", + varies: "Free, paid, or top-ranked listings get different links.", + estimated_nofollow: "We couldn't read a sampled link, but other evidence points to nofollow.", + none: "The sampled listing doesn't link to the maker's own site.", +}; + +const billingWords: Record = { once: "once", week: "a week", month: "a month", year: "a year", listing: "per listing" }; + +const observationMethods = { + public_page: "Their public page", + signed_in_flow: "Our submission", + email: "Email about our submission", +}; + +type Tile = { label: string; value: string | null; note?: ReactNode; meter?: number | null }; + +function Tiles({ label, tiles }: { label: string; tiles: Tile[] }) { + const known = tiles.filter((tile) => tile.value); + if (!known.length) return null; + return
+

{label}

+
+ {known.map((tile) =>
+
{tile.label}
+
+ {tile.meter ? : null} + {tile.value} +
+ {tile.note ?
{tile.note}
: null} +
)} +
+
; +} + +function Section({ children, id, lede, title }: { children: ReactNode; id: string; lede?: ReactNode; title: string }) { + return
+

{title}

+ {lede ?

{lede}

: null} + {children} +
; +} + +/** The last step in words that match what we actually found on a live listing. */ +function listingStep(directory: LaunchDirectory) { + const kind = directory.link_type_kind; + const text = kind === "dofollow" || kind === "nofollow" || kind === "ugc" || kind === "sponsored" + ? <>Your page stays up and links to your site with a {linkShort(directory)?.toLowerCase()} link. + : kind === "none" ? <>Your page stays up, but the listing we checked doesn't link to the maker's site. + : kind === "varies" ? <>Your page stays up. The link type depends on your plan. + : kind === "multiple" ? <>Your page stays up. The link we checked carried more than one value. + : kind === "estimated_nofollow" ? <>Your page stays up. The link is likely nofollow; we couldn't read it. + : null; + return text ? { title: "Your listing", text } : null; +} + +function requirementsOf(directory: LaunchDirectory) { + const details = directory.submission_details; + if (!details || !launchDirectoryHasFreeOption(directory)) return []; + const labels = [...details.requirements]; + if (!labels.some((label) => /backlink/i.test(label))) { + if (details.backlink === "required") labels.unshift("Backlink required"); + else if (details.backlink === "optional") labels.unshift("Backlink optional"); + else if (details.backlink === "not_required") labels.unshift("No backlink needed"); + } + return labels; +} + +export default async function LaunchDirectoryPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const [{ directories }, pagedSlugs] = await Promise.all([getLaunchDirectorySnapshot(), listLaunchDirectorySlugs()]); + const paged = new Set(pagedSlugs); + const directory = directories.find((item) => item.slug === slug); + if (!directory) notFound(); + const [directoryPage, breadcrumbs] = await Promise.all([ + getRequiredDirectoryPage(`launch-directories/${slug}`), + getBreadcrumbs(`launch-directories/${slug}`), + ]); + + const category = categoryMap[directory.categories[0] ?? ""]; + const ready = isSubmitReady(directory); + const details = directory.submission_details; + const checked = formatDate(directory.checked_on); + + /* ---------- Facts ---------- */ + const listing: Tile[] = [ + { + label: "Cost", + value: directory.cost_kind === "free_and_paid" ? "Free" : costShort(directory), + note: directory.cost_kind === "free_and_paid" + ? directory.lowest_paid_price_usd ? `Paid options from ${formatPrice(directory.lowest_paid_price_usd)}` : "Paid options too" + : directory.cost_note, + }, + { label: "Review time", value: waitShort(directory), note: launchDirectoryHasFreeOption(directory) ? "Free path" : null }, + { label: "Link type", value: linkShort(directory), note: directory.link_type_kind === "varies" ? "Depends on the plan" : null }, + ]; + const site: Tile[] = [ + { label: "Domain rating", value: directory.domain_rating === null ? null : String(directory.domain_rating), note: <>{directory.domain_rating_checked_on ? `, ${formatShortDate(directory.domain_rating_checked_on)}` : null} }, + { label: "Popularity", value: directory.traffic_bucket ? trafficLabels[directory.traffic_bucket] : null, note: "Tranco list", meter: directory.traffic_bucket ? trafficSteps[directory.traffic_bucket] : null }, + { label: "Listing pages indexed", value: directory.listing_indexed === null ? null : directory.listing_indexed ? "Yes" : "No", note: "In Google" }, + ]; + const unstated = listing.filter((tile) => !tile.value).map((tile) => tile.label.toLowerCase()); + const unchecked = site.filter((tile) => !tile.value).map((tile) => tile.label.toLowerCase()); + + /* ---------- Pricing ---------- */ + const tiers = directory.price_tiers; + const showPricing = tiers.length > 1 || tiers.some((tier) => (tier.price_usd ?? 0) > 0); + const tierLinks = tiers.some((tier) => tier.link_type); + const linkLabels = new Map(linkOptions.map((option) => [option.value, option.card])); + + /* ---------- Who can submit ---------- */ + const requirements = requirementsOf(directory); + + /* ---------- How a launch works ---------- */ + const reviewText = details?.review_time ?? (directory.review_time_label && !/^not stated/i.test(directory.review_time_label) ? directory.review_time_label : null); + const steps = [ + ready ? { title: "Submit", text: <>Fill in the form on {domainOf(directory.submit_url ?? directory.website_url)}. } : null, + reviewText ? { title: "Review", text: <>{reviewText} } : null, + details?.launch_queue ? { title: "Launch slot", text: <>{details.launch_queue} } : null, + listingStep(directory), + ].filter((step) => step !== null); + + /* ---------- Similar places ---------- */ + // Only places with a published page of their own, so nothing here links to a 404. + const others = directories.filter((item) => item.slug !== slug && paged.has(item.slug) && category && item.categories.includes(category.value)); + const byStrength = sortLaunchDirectories(others, "dr"); + const similar = byStrength.slice(0, 3); + const more = byStrength.slice(3, 9); + + const sources = directory.source_links; + const subject = encodeURIComponent(`Correction: ${directory.name} on launch directories`); + + const jumps = [ + { id: "facts", label: "Key facts" }, + showPricing ? { id: "pricing", label: "Pricing" } : null, + { id: "who-can-submit", label: "Who can submit" }, + steps.length > 1 ? { id: "how-it-works", label: "How it works" } : null, + { id: "link", label: "Link check" }, + details?.observations.length ? { id: "what-we-saw", label: "What we saw" } : null, + { id: "sources", label: "Sources" }, + similar.length ? { id: "similar", label: "Similar places" } : null, + ].filter((jump) => jump !== null); + + const sidebar =
+
{directory.name}
+ + {category && more.length ? : null} +
; + + return + + +

+

{directory.summary}

+
+ {category ? {category.label} : null} + {checked ? Checked {checked} : null} + {!ready ? {submitStatusText(directory)} : null} +
+ +
+
+ + +
+ + + {unstated.length ?

Not stated on their site: {unstated.join(", ")}.

: null} + {unchecked.length ?

Not checked yet: {unchecked.join(", ")}.

: null} +
+ + {showPricing ?
+
+ + {tierLinks ? : null} + + {tiers.map((tier) => + + + + {tierLinks ? : null} + )} + +
PlanPriceWhat you getLink
{tier.name}{tier.price_usd === null ? Not stated : tier.price_usd === 0 ? "Free" : <>{formatPrice(tier.price_usd)}{tier.billing ? {billingWords[tier.billing]} : null}}{tier.gets ?? Not stated}{tier.link_type ? linkLabels.get(tier.link_type) ?? tier.link_type : Not stated}
+
+
: null} + +
+
+

Their rule

+

{directory.eligibility_label}

+

Best for: {directory.best_for}

+
+ {requirements.length ?
+

The free plan asks for

+
    {requirements.map((requirement) =>
  • {requirement}
  • )}
+
: null} +
+ + {steps.length > 1 ?
+
    + {steps.map((step) =>
  1. {step.title}

    {step.text}

  2. )} +
+
: null} + + + + {details?.observations.length ?
+
    + {details.observations.map((observation, index) =>
  • + {formatShortDate(observation.checked_at)} +
    +

    {observation.text}

    +

    {observationMethods[observation.method]}{observation.source_url ? <> · {shortUrl(observation.source_url)} : null}

    +
    +
  • )} +
+
: null} + +
+ {sources.length || directory.example_listing_url ? :

No public source links for this place yet.

} +
+ + {similar.length ?
+ + {similar.map((item) => )} + +
: null} + +

+ {"See something wrong or out of date? "} + Tell us + {" and we'll check it again. "} + Compare {directory.name} with others. +

+
+
; +} diff --git a/app/(site)/(directories)/launch-directories/ahrefs-credit.tsx b/app/(site)/(directories)/launch-directories/ahrefs-credit.tsx new file mode 100644 index 00000000..1bd77c63 --- /dev/null +++ b/app/(site)/(directories)/launch-directories/ahrefs-credit.tsx @@ -0,0 +1,11 @@ +import styles from "./launch-directories.module.css"; + +/** + * The credit Ahrefs' Domain Rating license requires next to every place DR is shown: + * the words "Domain Rating by Ahrefs", with "Ahrefs" linking to ahrefs.com. + */ +export function AhrefsCredit({ prefix }: { prefix?: string }) { + return + {prefix ? `${prefix} ` : null}Domain Rating by Ahrefs + ; +} diff --git a/app/(site)/(directories)/launch-directories/compare/compare-view.tsx b/app/(site)/(directories)/launch-directories/compare/compare-view.tsx new file mode 100644 index 00000000..13a4215f --- /dev/null +++ b/app/(site)/(directories)/launch-directories/compare/compare-view.tsx @@ -0,0 +1,428 @@ +"use client"; + +import { ArrowRight, Plus, Search, X } from "lucide-react"; +import Link from "next/link"; +import { createContext, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { UiCopyAction, UiSwitch } from "@/app/ui/controls"; +import { sidebarPanelClassName, sidebarPanelHeadingClassName, sidebarPanelTitleClassName } from "@/app/ui/site/sidebar-panel"; +import type { LaunchDirectory } from "@/lib/launch-directories"; +import { LaunchCard, LaunchGrid, launchHref, type LaunchCategory } from "../launch-card"; +import { costShort, formatDate, formatPrice, isSubmitReady, linkShort, shortUrl, submitStatusText, trafficLabels, trafficSteps, waitShort } from "../launch-format"; +import { LaunchLogo } from "../launch-logo"; +import { AhrefsCredit } from "../ahrefs-credit"; +import styles from "./compare.module.css"; + +export type CompareRecord = Pick & { + /** False when the place has no published page yet. */ + has_page: boolean; +}; + +const billingWords: Record = { once: "once", week: "a week", month: "a month", year: "a year", listing: "per listing" }; + +/** + * Paid prices can be ranked only when each one states the same billing period. A missing period + * counts as unknown, never as a match. + */ +function sharedBilling(records: CompareRecord[]) { + const billings = records.map(lowestBilling); + return billings.length && billings.every((billing) => billing && billing === billings[0]) ? billings[0] : null; +} + +/** The billing period of the lowest paid price, when a pricing tier with that price states one. */ +function lowestBilling(record: CompareRecord) { + const price = record.lowest_paid_price_usd; + if (price === null) return null; + return record.price_tiers.find((tier) => tier.price_usd === price)?.billing ?? null; +} + +const MAX = 3; +const route = "/launch-directories/compare"; +// Shown in the empty state, in this order, when they aren't picked yet. +const suggested = ["product-hunt", "uneed", "devhunt", "betalist", "peerlist-launchpad", "microlaunch", "fazier"]; + +type PicksState = { + records: CompareRecord[]; + bySlug: Map; + picks: string[]; + toggle: (slug: string) => void; +}; + +const PicksContext = createContext(null); + +function usePicks() { + const state = useContext(PicksContext); + if (!state) throw new Error("usePicks needs a ComparePicksProvider."); + return state; +} + +function readPicks(bySlug: Map) { + const raw = new URLSearchParams(window.location.search).get("d") ?? ""; + return [...new Set(raw.split(",").map((slug) => slug.trim()).filter((slug) => bySlug.has(slug)))].slice(0, MAX); +} + +/** + * The page is static and opens empty. Picks come from `?d=a,b,c` after hydration, and each change + * rewrites that address in place so the comparison can be shared. These URLs stay noindex. + */ +export function ComparePicksProvider({ children, records }: { children: ReactNode; records: CompareRecord[] }) { + const bySlug = useMemo(() => new Map(records.map((record) => [record.slug, record])), [records]); + const [picks, setPicks] = useState([]); + // Set by a pick or removal, so the address updates after that render. Reading the URL on load + // or on back and forward doesn't write it again. + const changedByUser = useRef(false); + + useEffect(() => { + const sync = () => setPicks(readPicks(bySlug)); + sync(); + window.addEventListener("popstate", sync); + return () => window.removeEventListener("popstate", sync); + }, [bySlug]); + + useEffect(() => { + if (!changedByUser.current) return; + changedByUser.current = false; + window.history.replaceState(window.history.state, "", picks.length ? `${route}?d=${picks.join(",")}` : route); + }, [picks]); + + const value = useMemo(() => ({ + records, bySlug, picks, + toggle: (slug) => { + changedByUser.current = true; + setPicks((current) => current.includes(slug) ? current.filter((item) => item !== slug) : current.length < MAX ? [...current, slug] : current); + }, + }), [records, bySlug, picks]); + + return {children}; +} + +/* ---------- Sidebar ---------- */ + +export function ComparePicker({ categories }: { categories: LaunchCategory[] }) { + const { bySlug, picks, records, toggle } = usePicks(); + const [query, setQuery] = useState(""); + const search = query.trim().toLowerCase(); + const full = picks.length >= MAX; + const groups = categories.map((category) => ({ + category, + items: records + .filter((record) => record.categories[0] === category.value) + .filter((record) => !search || `${record.name} ${record.slug}`.toLowerCase().includes(search)) + .sort((left, right) => left.name.localeCompare(right.name, "en", { sensitivity: "base" })), + })).filter((group) => group.items.length); + + return
+
+

Compare

+
+
+

Picked {picks.length} of {MAX}

+ {picks.length ?
    + {picks.map((slug) => { + const record = bySlug.get(slug); + return record ?
  • + + {record.name} + +
  • : null; + })} +
:

Nothing picked yet.

} +
+ +
+ {records.some((record) => record.domain_rating !== null) ?

Numbers are

: null} + {groups.length ? groups.map(({ category, items }) =>
+ {category.label} + {items.map((record) => { + const on = picks.includes(record.slug); + return ; + })} +
) :

No place matches that.

} +
+
; +} + +/* ---------- Facts ---------- */ + +type Row = { + id: string; + label: string; + note?: ReactNode; + /** What decides "same" for the differences toggle. */ + key: (record: CompareRecord) => string; + cell: (record: CompareRecord, context: Context) => ReactNode; +}; + +/** `comparable` is false when the picked prices don't share one known billing period: no bars, no highlight. */ +type Context = { comparable: boolean; maxPrice: number; lowPrice: number | null; maxDr: number }; + +const Unknown = ({ children = "Not stated" }: { children?: ReactNode }) => {children}; + +function Bar({ share }: { share: number }) { + return ; +} + +const groups: Array<{ title: string; rows: Row[] }> = [ + { + title: "Cost", + rows: [ + { + id: "cost", label: "Cost", + key: (record) => costShort(record) ?? "", + cell: (record) => costShort(record) ? <>{costShort(record)}{record.cost_label.trim().toLowerCase() !== costShort(record)?.toLowerCase() ? {record.cost_label} : null} : , + }, + { + id: "price", label: "Cheapest paid option", + key: (record) => `${record.lowest_paid_price_usd ?? (record.cost_kind === "free" ? "none" : "")} ${lowestBilling(record) ?? ""}`, + cell: (record, context) => record.lowest_paid_price_usd !== null + ? <>{formatPrice(record.lowest_paid_price_usd)}{lowestBilling(record) ? {billingWords[lowestBilling(record)!]} : null}{context.comparable ? : null} + : record.cost_kind === "free" ? No paid option : , + }, + ], + }, + { + title: "Getting listed", + rows: [ + { + id: "review", label: "Review time", note: "free path", + key: (record) => waitShort(record) ?? "", + cell: (record) => waitShort(record) ? <>{waitShort(record)}{record.review_time_label.trim().toLowerCase() !== waitShort(record)?.toLowerCase() ? {record.review_time_label} : null} : , + }, + { + id: "eligibility", label: "Who can submit", + key: (record) => record.eligibility_label, + cell: (record) => {record.eligibility_label}, + }, + { + id: "submit", label: "Submit page", + key: (record) => String(isSubmitReady(record)), + cell: (record) => isSubmitReady(record) + ? {shortUrl(record.submit_url ?? "")} + : {submitStatusText(record)}, + }, + ], + }, + { + title: "What you get", + rows: [ + { + id: "link", label: "Link type", + key: (record) => record.link_type_kind, + cell: (record) => linkShort(record) ? {linkShort(record)} : Couldn't verify, + }, + { + id: "dr", label: "Domain rating", note: , + key: (record) => String(record.domain_rating ?? ""), + cell: (record, context) => record.domain_rating !== null + ? <>{record.domain_rating} / 100 + : Not checked yet, + }, + { + id: "popularity", label: "Popularity", note: "Tranco list", + key: (record) => record.traffic_bucket ?? "", + cell: (record) => record.traffic_bucket + ? <>{trafficLabels[record.traffic_bucket]} + : Not checked yet, + }, + { + id: "indexed", label: "Listing pages indexed", note: "in Google", + key: (record) => String(record.listing_indexed), + cell: (record) => record.listing_indexed === null ? Not checked yet : {record.listing_indexed ? "Yes" : "No"}, + }, + { + id: "checked", label: "Last checked", + key: (record) => record.checked_on ?? "", + cell: (record) => record.checked_on ? {formatDate(record.checked_on)} : , + }, + ], + }, +]; + +/* ---------- Quick read ---------- */ + +function Names({ records }: { records: CompareRecord[] }) { + return <>{records.map((record, index) => + {index ? and : null} + {record.name} + )}; +} + +function QuickRead({ records }: { records: CompareRecord[] }) { + const total = records.length; + const missing = (count: number, what: string) => count ? ` ${count} of ${total} ${what}.` : ""; + + // Free is a stated fact, so any free way in wins outright. Paid prices win only when every place + // states a price with the same known billing period. Unknown costs are counted on their own. + const free = records.filter((record) => record.cost_kind === "free" || record.cost_kind === "free_and_paid"); + const paidOnly = records.filter((record) => record.cost_kind === "paid").length; + const unknownCost = records.filter((record) => record.cost_kind === "unknown").length; + const priced = records.filter((record) => record.lowest_paid_price_usd !== null); + const billing = sharedBilling(priced); + let cheapest: Winner | null = null; + if (free.length) { + cheapest = { + records: free, + text: free.length === total ? `All ${total} start free` : null, + why: `Free to submit.${missing(paidOnly, "charge for every listing")}${missing(unknownCost, "don't state a cost")}`, + }; + } else if (priced.length === total && billing) { + const lowest = Math.min(...priced.map((record) => record.lowest_paid_price_usd ?? Infinity)); + cheapest = { + records: priced.filter((record) => record.lowest_paid_price_usd === lowest), + text: null, + why: `Lowest stated price, ${formatPrice(lowest)} ${billingWords[billing]}.`, + }; + } else if (priced.length) { + cheapest = { + records: [], + text: "Can't call it", + why: priced.length < total ? `${total - priced.length} of ${total} don't state a price.` + : "Their prices don't share a stated billing period. See the rows below.", + }; + } + + const timed = records.filter((record) => record.review_wait?.max_days !== null && record.review_wait?.max_days !== undefined); + const fastestDays = timed.length ? Math.min(...timed.map((record) => record.review_wait?.max_days ?? Infinity)) : null; + const fastestRecords = timed.filter((record) => record.review_wait?.max_days === fastestDays); + const fastest = fastestDays === null ? null : { + records: fastestRecords, + text: null, + why: `${fastestRecords[0]?.review_wait?.label}, as stated on their site.${missing(total - timed.length, "state no time")}`, + }; + + const ranked = records.filter((record) => record.traffic_bucket); + const top = ranked.length ? Math.max(...ranked.map((record) => trafficSteps[record.traffic_bucket!])) : null; + const topRecords = ranked.filter((record) => trafficSteps[record.traffic_bucket!] === top); + const popular = top === null ? null : { + records: topRecords, + text: null, + why: `${trafficLabels[topRecords[0]!.traffic_bucket!]} by traffic.${missing(total - ranked.length, "aren't checked yet")}`, + }; + + const cell = (label: string, pick: Winner | null) =>
+
{label}
+ {pick ? <>
{pick.text ?? }
{pick.why}
:
None of them state it
} +
; + + return
+ {cell("Cheapest", cheapest)} + {cell("Fastest stated review", fastest)} + {cell("Most visited, of those checked", popular)} +
; +} + +type Winner = { records: CompareRecord[]; text: string | null; why: string }; + +/* ---------- Main view ---------- */ + +export function CompareView({ categories }: { categories: LaunchCategory[] }) { + const { bySlug, picks, records, toggle } = usePicks(); + const [onlyDifferent, setOnlyDifferent] = useState(false); + const [origin, setOrigin] = useState(""); + const picked = picks.map((slug) => bySlug.get(slug)).filter((record): record is CompareRecord => Boolean(record)); + const categoryMap = Object.fromEntries(categories.map((category) => [category.value, category])); + + useEffect(() => { setOrigin(window.location.origin); }, []); + const href = origin ? `${origin}${route}?d=${picks.join(",")}` : ""; + + if (picked.length < 2) { + const ideas = suggested.filter((slug) => bySlug.has(slug) && !picks.includes(slug)).slice(0, 4).map((slug) => bySlug.get(slug)!); + return
+

{picked.length ? `Pick one more to compare with ${picked[0]?.name}` : "Pick two or three to compare"}

+

Choose from the list, or start with one of these.

+
+ {ideas.map((record) => )} +
+
; + } + + const prices = picked.map((record) => record.lowest_paid_price_usd).filter((price): price is number => price !== null); + const pricedPicks = picked.filter((record) => record.lowest_paid_price_usd !== null); + const context: Context = { + comparable: pricedPicks.length > 1 && sharedBilling(pricedPicks) !== null, + maxPrice: Math.max(1, ...prices), + lowPrice: prices.length > 1 ? Math.min(...prices) : null, + maxDr: 100, + }; + const differs = (row: Row) => new Set(picked.map(row.key)).size > 1; + const firstCategory = picked[0]?.categories[0] ?? ""; + const more = records.filter((record) => !picks.includes(record.slug) && record.categories.includes(firstCategory)) + .sort((left, right) => (right.domain_rating ?? -1) - (left.domain_rating ?? -1)).slice(0, 3); + const columns = { gridTemplateColumns: `minmax(150px, 0.9fr) repeat(${picked.length}, minmax(0, 1fr))`, "--picked": picked.length } as CSSProperties; + + return
+
+ Only show differences + {href ? : null} +
+ + + +
record.name).join(", ")}`}> +
+
{picked.length} of {MAX} picked
+ {picked.map((record) =>
+ +
+ {record.has_page ? {record.name} : {record.name}} + {categoryMap[record.categories[0] ?? ""]?.label} + {isSubmitReady(record) ? Submit : null} +
+ +
)} +
+ {groups.map((group) => { + const rows = group.rows.filter((row) => !onlyDifferent || differs(row)); + if (!rows.length) return null; + return
+
{group.title}
+ {rows.map((row) =>
+
+ {row.label}{row.note ? {row.note} : null} + {differs(row) ? ≠ : null} +
+ {picked.map((record) =>
{row.cell(record, context)}
)} +
)} +
; + })} +
+ + {more.length ?
+
+

Add one more

+ All {categoryMap[firstCategory]?.label.toLowerCase() ?? "places"}
+

+ {picks.length >= MAX ? `You can compare ${MAX} at a time, so picking one swaps out ${picked.at(-1)?.name}.` : `More like ${picked[0]?.name}, strongest sites first.`} +

+ + {more.map((record) => { + if (picks.length >= MAX) toggle(picks[picks.length - 1]!); + toggle(record.slug); + }} type="button"> +
: null} + +

Facts come from each place's own pages, with the dates on its page.

+
; +} diff --git a/app/(site)/(directories)/launch-directories/compare/compare.module.css b/app/(site)/(directories)/launch-directories/compare/compare.module.css new file mode 100644 index 00000000..d1c0bd5d --- /dev/null +++ b/app/(site)/(directories)/launch-directories/compare/compare.module.css @@ -0,0 +1,204 @@ +/* Compare up to three places: sidebar picker, quick read, sticky heads, and grouped fact rows. */ + +.unknown { color: var(--color-text-muted); font-weight: var(--weight-regular); } + +/* ---------- Sidebar ---------- */ +.sideLabel { display: flex; justify-content: space-between; margin: 0 0 var(--space-3); color: var(--color-text-muted); font: var(--text-small) var(--font-mono); } +.sideEmpty { margin: 0; color: var(--color-text-muted); font-size: var(--text-small); } +.picked ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; } + +.picked li { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: var(--space-4); + align-items: center; + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius); + font-size: var(--text-small); + font-weight: var(--weight-semibold); +} + +.picked li > span:nth-child(2) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.picked button, .remove { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; background: none; border: 0; border-radius: var(--radius); color: var(--color-text-muted); cursor: pointer; } +.picked button:hover, .remove:hover { background: var(--color-surface-2); color: var(--color-text); } + +.pickList { display: grid; gap: var(--space-8); } +.pickList fieldset { display: grid; gap: var(--space-1); margin: 0; padding: 0; border: 0; min-width: 0; } +.pickList legend { margin-bottom: var(--space-3); padding: 0; color: var(--color-text-muted); font: var(--text-small) var(--font-mono); } + +.pickList label { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: var(--space-4); + align-items: center; + padding: var(--space-2) var(--space-4); + border-radius: var(--radius); + font-size: var(--text-small); + line-height: var(--line-ui); + cursor: pointer; +} + +.pickList label:hover { background: var(--color-surface-2); } +.pickList label[data-on] { background: var(--color-surface-2); font-weight: var(--weight-semibold); } +.pickList label:has(input:disabled) { color: var(--color-text-muted); cursor: not-allowed; } +.pickList label > span:nth-child(2) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pickList input { width: 15px; height: 15px; margin: 0; accent-color: var(--color-accent-text); } +.pickDr { color: var(--color-text-muted); font: var(--text-small) var(--font-mono); } + +/* ---------- Empty ---------- */ +.empty { + display: grid; + justify-items: start; + gap: var(--space-5); + padding: var(--space-13) var(--space-12); + border: 1px dashed var(--color-border-strong); + border-radius: var(--radius); +} + +.empty h2 { margin: 0; font: var(--weight-bold) var(--text-h2) / var(--line-heading) var(--font-display); letter-spacing: var(--tracking-display); } +.empty p { margin: 0; color: var(--color-text-muted); } +.chips { display: flex; flex-wrap: wrap; gap: var(--space-3); margin-top: var(--space-3); } + +.chips button { + display: inline-flex; + align-items: center; + gap: var(--space-3); + height: 36px; + padding: 0 var(--space-5); + background: var(--color-surface); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius); + color: var(--color-text); + font: var(--weight-semibold) var(--text-small) var(--font-sans); + cursor: pointer; +} + +.chips button:hover { background: var(--color-surface-2); } + +/* ---------- View ---------- */ +.view { display: grid; gap: var(--space-9); min-width: 0; } +.toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-5); } + +.quick { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius); +} + +.quick > div { display: grid; align-content: start; gap: var(--space-3); min-width: 0; padding: var(--space-8) var(--space-9); } +.quick > div + div { border-left: 1px solid var(--color-border); } +.quick dt { color: var(--color-text-muted); font: var(--text-small) var(--font-mono); } +.quick dd { margin: 0; font-weight: var(--weight-semibold); } +.quick dd.why { color: var(--color-text-muted); font-size: var(--text-small); font-weight: var(--weight-regular); } +.name { display: inline-flex; align-items: center; gap: var(--space-3); } +.and { margin: 0 var(--space-3); color: var(--color-text-muted); font-weight: var(--weight-regular); } + +/* ---------- Table ---------- */ +.table { min-width: 0; border: 1px solid var(--color-border); border-radius: var(--radius); } + +.heads, .row { display: grid; } + +.heads { + position: sticky; + top: var(--sticky-top, 0px); + z-index: 2; + background: var(--color-bg); + border-bottom: 1px solid var(--color-border-strong); +} + +.headLabel { align-self: end; padding: var(--space-7) var(--space-8); color: var(--color-text-muted); font: var(--text-small) var(--font-mono); } + +.head { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-4); + align-items: start; + min-width: 0; + padding: var(--space-7) var(--space-8); + border-left: 1px solid var(--color-border); +} + +.head > div { display: grid; gap: 2px; min-width: 0; padding-right: var(--space-9); } +.headName { color: var(--color-text); font: var(--weight-bold) 1rem / 1.2 var(--font-display); text-decoration: none; overflow-wrap: anywhere; } +.headName:hover { color: var(--color-link); } +.headCategory { color: var(--color-text-muted); font: 0.78rem var(--font-mono); } +.headSubmit { display: inline-flex; align-items: center; gap: 4px; margin-top: var(--space-2); color: var(--color-link); font: var(--weight-semibold) 0.8rem var(--font-mono); text-decoration: none; } +.remove { position: absolute; top: var(--space-4); right: var(--space-4); } + +.group { padding: var(--space-8) var(--space-8) var(--space-3); background: var(--color-surface); border-bottom: 1px solid var(--color-border); font: var(--weight-semibold) var(--text-small) var(--font-sans); } +.row { border-bottom: 1px solid var(--color-border); } +.table > div:last-child .row:last-child { border-bottom: 0; } +.row:hover { background: var(--color-surface); } + +.rowLabel { + display: flex; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-7) var(--space-8); + color: var(--color-text-muted); + font: var(--text-small) / 1.35 var(--font-mono); +} + +.rowLabel small { display: block; font-size: 0.74rem; opacity: 0.85; } +.diff { flex: none; color: var(--color-accent-text); font-weight: var(--weight-semibold); } + +.cell { display: grid; align-content: start; gap: var(--space-2); min-width: 0; padding: var(--space-7) var(--space-8); border-left: 1px solid var(--color-border); overflow-wrap: anywhere; } +.cell b { font: var(--weight-semibold) 0.95rem / 1.3 var(--font-mono); } +.cell b[data-low] { color: var(--color-accent-text); } +.cell small { color: var(--color-text-muted); font-size: 0.8rem; line-height: 1.4; } +.cell a { color: var(--color-link); font: 0.85rem var(--font-mono); text-underline-offset: 3px; } +.text { font-size: var(--text-small); line-height: 1.45; } +.cell .unknown { font-size: var(--text-small); line-height: 1.4; } +.unit { color: var(--color-text-muted); font-weight: var(--weight-regular); } + +.bar { display: block; height: 4px; max-width: 140px; margin-top: var(--space-2); background: var(--color-surface-2); border-radius: 1px; } +.bar i { display: block; height: 100%; background: var(--color-text-muted); border-radius: 1px; } +.meter { display: inline-flex; gap: 3px; margin-top: var(--space-2); } +.meter i { width: 12px; height: 4px; border-radius: 1px; background: var(--color-surface-2); } +.meter i[data-on] { background: var(--color-text-muted); } + +/* ---------- Add one more ---------- */ +.more { display: grid; gap: var(--space-5); margin-top: var(--space-10); } +.moreHead { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: var(--space-5); } +.moreHead h2 { margin: 0; font: var(--weight-bold) var(--text-h2) / var(--line-heading) var(--font-display); letter-spacing: var(--tracking-display); } +.moreHead a { display: inline-flex; align-items: center; gap: 4px; color: var(--color-link); font: var(--text-small) var(--font-mono); text-decoration: none; } +.moreNote { margin: 0 0 var(--space-3); color: var(--color-text-muted); font-size: var(--text-small); } + +.addButton { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: 0; + background: none; + border: 0; + color: var(--color-text-muted); + font: var(--text-small) var(--font-mono); + cursor: pointer; +} + +.addButton:hover { color: var(--color-text); } +.footnote { margin: 0; color: var(--color-text-muted); font: var(--text-small) / 1.5 var(--font-mono); } + +@container (max-width: 760px) { + .quick { grid-template-columns: minmax(0, 1fr); } + .quick > div + div { border-left: 0; border-top: 1px solid var(--color-border); } + .empty { padding: var(--space-10) var(--space-8); } +} + +/* On phones every fact name sits above its values, so all three columns stay on screen. */ +@container (max-width: 700px) { + .heads, .row { grid-template-columns: repeat(var(--picked, 3), minmax(0, 1fr)) !important; } + .heads .headLabel { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } + .head { grid-template-columns: minmax(0, 1fr); padding: var(--space-5) var(--space-4); } + .heads > .head:nth-child(2), .row > .cell:nth-child(2) { border-left: 0; } + .head > div { padding-right: var(--space-7); } + .headCategory { display: none; } + .rowLabel { grid-column: 1 / -1; padding: var(--space-5) var(--space-4) 0; } + .cell { padding: var(--space-3) var(--space-4) var(--space-6); } + .cell b { font-size: 0.85rem; } + .cell small { display: none; } +} diff --git a/app/(site)/(directories)/launch-directories/compare/content/intro.mdx b/app/(site)/(directories)/launch-directories/compare/content/intro.mdx new file mode 100644 index 00000000..e61c5d8e --- /dev/null +++ b/app/(site)/(directories)/launch-directories/compare/content/intro.mdx @@ -0,0 +1 @@ +Pick up to three places and see them side by side: what each costs, how long review takes, who can submit, and what link you get. Your picks stay in the address, so you can share the comparison. diff --git a/app/(site)/(directories)/launch-directories/compare/layout.tsx b/app/(site)/(directories)/launch-directories/compare/layout.tsx new file mode 100644 index 00000000..13ece7c1 --- /dev/null +++ b/app/(site)/(directories)/launch-directories/compare/layout.tsx @@ -0,0 +1,6 @@ +import { registryLayout } from "@/lib/registry-layout"; + +const registry = registryLayout("/launch-directories/compare"); +export const generateMetadata = registry.generateMetadata; +export const revalidate = false; +export default registry.Layout; diff --git a/app/(site)/(directories)/launch-directories/compare/page.tsx b/app/(site)/(directories)/launch-directories/compare/page.tsx new file mode 100644 index 00000000..68c2382a --- /dev/null +++ b/app/(site)/(directories)/launch-directories/compare/page.tsx @@ -0,0 +1,70 @@ +import { getBreadcrumbs } from "@/lib/breadcrumbs"; +import { getRequiredDirectoryPage } from "@/lib/directory-pages"; +import { getLaunchDirectorySnapshot, launchCategories, listLaunchDirectorySlugs } from "@/lib/launch-directories"; +import { sortLaunchDirectories } from "@/lib/launch-directories-query"; +import { DirectoryPageTitle } from "@/app/ui/directories/directory-page-content"; +import { AppShell } from "@/app/ui/site/app-shell"; +import { EntryHero } from "@/app/ui/site/entry-hero"; +import { PageCanvas } from "@/app/ui/site/page-canvas"; +import { PageHeader } from "@/app/ui/site/page-header"; +import { launchSectionNav } from "../launch-format"; +import Intro from "./content/intro.mdx"; +import { ComparePicker, ComparePicksProvider, CompareView, type CompareRecord } from "./compare-view"; + +export default async function CompareLaunchDirectoriesPage() { + const [directoryPage, breadcrumbs, { directories }, pagedSlugs] = await Promise.all([ + getRequiredDirectoryPage("launch-directories/compare"), + getBreadcrumbs("launch-directories/compare"), + getLaunchDirectorySnapshot(), + listLaunchDirectorySlugs(), + ]); + const paged = new Set(pagedSlugs); + + // Only the fields the comparison shows ship to the browser. + const records: CompareRecord[] = sortLaunchDirectories(directories, "recommended").map((directory) => ({ + slug: directory.slug, + name: directory.name, + icon_url: directory.icon_url, + website_url: directory.website_url, + submit_url: directory.submit_url, + submission_status: directory.submission_status, + categories: directory.categories, + cost_kind: directory.cost_kind, + cost_label: directory.cost_label, + lowest_paid_price_usd: directory.lowest_paid_price_usd, + price_tiers: directory.price_tiers, + has_page: paged.has(directory.slug), + review_wait: directory.review_wait, + review_time_label: directory.review_time_label, + eligibility_label: directory.eligibility_label, + link_type_kind: directory.link_type_kind, + link_type_label: directory.link_type_label, + domain_rating: directory.domain_rating, + traffic_bucket: directory.traffic_bucket, + listing_indexed: directory.listing_indexed, + checked_on: directory.checked_on, + summary: directory.summary, + best_for: directory.best_for, + })); + const categories = launchCategories.map(({ value, label, iconKey }) => ({ value, label, iconKey })); + + return + } + sidebarLabel="Pick places to compare" + > + + +

+ +
+
+ + + +
+
; +} diff --git a/app/(site)/(directories)/launch-directories/content/corrections.mdx b/app/(site)/(directories)/launch-directories/content/corrections.mdx index b263538a..68631c4f 100644 --- a/app/(site)/(directories)/launch-directories/content/corrections.mdx +++ b/app/(site)/(directories)/launch-directories/content/corrections.mdx @@ -1,3 +1,3 @@ ## Found something wrong? -Email [stack127@proton.me](mailto:stack127@proton.me) with the site's name and the page that shows the right answer. Each place's details also have a correction link. +Email [stack127@proton.me](mailto:stack127@proton.me) with the site's name and the page that shows the right answer. Each place's page also has a correction link. diff --git a/app/(site)/(directories)/launch-directories/content/free-listings.mdx b/app/(site)/(directories)/launch-directories/content/free-listings.mdx index 2582a9b0..5469fa56 100644 --- a/app/(site)/(directories)/launch-directories/content/free-listings.mdx +++ b/app/(site)/(directories)/launch-directories/content/free-listings.mdx @@ -1,5 +1,5 @@ ## What does a free listing ask for? -Some free plans ask for something in return, such as a backlink to the directory, proof that you own the domain, or votes and comments on other launches. Where we checked the free flow, the card lists these under Free plan. +Some free plans ask for something in return, such as a backlink to the directory, proof that you own the domain, or votes and comments on other launches. Where we checked the free flow, the place's page lists them under Who can submit. -The Free without required backlink filter shows only free places where we checked the free flow and it doesn't ask for a backlink or badge. Places we haven't checked yet stay out of it, even if their price looks free. A place can still change its rules, so read the card before you submit. +The Free, no backlink needed pick shows only free places where we checked the free flow and it doesn't ask for a backlink or badge. Places we haven't checked yet stay out of it, even if their price looks free. A place can still change its rules, so read its page before you submit. diff --git a/app/(site)/(directories)/launch-directories/content/how-we-check.mdx b/app/(site)/(directories)/launch-directories/content/how-we-check.mdx index 774ba87f..c5284284 100644 --- a/app/(site)/(directories)/launch-directories/content/how-we-check.mdx +++ b/app/(site)/(directories)/launch-directories/content/how-we-check.mdx @@ -1,8 +1,8 @@ ## How do we check each place? -We read each site's own submission page, pricing, and rules, then open one of its published listings to see what link it gives the maker. The details link each page we used, with the date we checked it. Some sources are older than the record date, which is our latest check of anything on that place. +We read each site's own submission page, pricing, and rules, then open one of its published listings to see what link it gives the maker. Each place's page links every source we used, with the date we checked it. Some sources are older than the record date, which is our latest check of anything on that place. -Card prices show the lowest price we found, and "From" means higher plans exist. Prices, review times, and link rules change, so check the site's own page before you pay. +Card prices show the lowest price we found, and "From" means higher plans exist. Domain ratings are Domain Rating by [Ahrefs](https://ahrefs.com/), and popularity comes from the Tranco list of the most visited sites. Prices, review times, and link rules change, so check the site's own page before you pay. ### What do "Not stated on their site" and "Couldn't verify" mean? @@ -11,7 +11,7 @@ We don't have a checked answer for that detail, and we don't guess one. The site's own pages don't publish it. A page blocked us, needed a login, or didn't load. It doesn't mean the site has closed. -Two of the site's own pages disagree. The details link both. -We found a submission page, but it didn't load when we checked. -We couldn't find where submissions start. Look on the site itself before you give up on it. +Two of the site's own pages disagree. The place's page links both. +We found a submission page, but it didn't load when we checked. +We couldn't find where submissions start. Look on the site itself before you give up on it. diff --git a/app/(site)/(directories)/launch-directories/content/intro.mdx b/app/(site)/(directories)/launch-directories/content/intro.mdx index b34ce78d..015bf1ff 100644 --- a/app/(site)/(directories)/launch-directories/content/intro.mdx +++ b/app/(site)/(directories)/launch-directories/content/intro.mdx @@ -1 +1 @@ -Just built something and need people to see it? Here are 150+ places that take new products, from launch boards like Product Hunt to AI tool lists and software review sites. Filter by category, cost, or link type, then open a card to see who can submit, how long review takes, and every page we checked, with its date. +Just built something and need people to see it? Here are 150+ places that take new products, from launch boards like Product Hunt to AI tool lists and software review sites. Filter by cost, link type, or domain rating, then open any place to see who can submit, how long review takes, and every page we checked, with its date. diff --git a/app/(site)/(directories)/launch-directories/content/launch-kit.mdx b/app/(site)/(directories)/launch-directories/content/launch-kit.mdx new file mode 100644 index 00000000..9544757d --- /dev/null +++ b/app/(site)/(directories)/launch-directories/content/launch-kit.mdx @@ -0,0 +1,3 @@ +## What should you get ready first? + +Most places ask for the same few things. Make them once at Product Hunt's sizes and they fit almost everywhere else. diff --git a/app/(site)/(directories)/launch-directories/content/launch-timing.mdx b/app/(site)/(directories)/launch-directories/content/launch-timing.mdx index 82ff55ad..a3a36a53 100644 --- a/app/(site)/(directories)/launch-directories/content/launch-timing.mdx +++ b/app/(site)/(directories)/launch-directories/content/launch-timing.mdx @@ -1,5 +1,5 @@ ## How long until my product launches? -It depends on two waits: approval, then a launch slot. Some sites review within a day, while others publish free queues of several months. Where we checked both, the details list them separately. +It depends on two waits: approval, then a launch slot. Some sites review within a day, while others publish free queues of several months. Where we checked both, the place's page lists them separately. A date marked "Our submission" is the date we got for our own product. Yours may differ. diff --git a/app/(site)/(directories)/launch-directories/content/link-types.mdx b/app/(site)/(directories)/launch-directories/content/link-types.mdx index 00fe33f9..48651f54 100644 --- a/app/(site)/(directories)/launch-directories/content/link-types.mdx +++ b/app/(site)/(directories)/launch-directories/content/link-types.mdx @@ -10,7 +10,7 @@ Choose Dofollow under Link type to see them. Each result comes from one publishe The link is marked as user-generated content. The link is marked as paid. The link carried more than one value, such as ugc and nofollow. -Free, paid, or top-ranked listings get different links. The details say which. +Free, paid, or top-ranked listings get different links. The place's page says which. The sampled listing doesn't link to the maker's own site. diff --git a/app/(site)/(directories)/launch-directories/launch-card.tsx b/app/(site)/(directories)/launch-directories/launch-card.tsx new file mode 100644 index 00000000..187bfa68 --- /dev/null +++ b/app/(site)/(directories)/launch-directories/launch-card.tsx @@ -0,0 +1,76 @@ +import { ArrowRight } from "lucide-react"; +import Link from "next/link"; +import type { ReactNode } from "react"; +import { CategoryIcon } from "@/app/ui/site/category-icon"; +import type { LaunchDirectory } from "@/lib/launch-directories"; +import { LaunchLogo } from "./launch-logo"; +import { cardDescription, costShort, domainOf, isSubmitReady, linkShort, submitStatusText, waitShort } from "./launch-format"; +import styles from "./launch-directories.module.css"; + +export type LaunchCategory = { value: string; label: string; iconKey: string }; + +/** The record fields a card shows, so pages can ship a smaller record to the browser. */ +export type LaunchCardRecord = Pick; + +export function launchHref(slug: string) { + return `/launch-directories/${slug}`; +} + +function Fact({ label, value }: { label: string; value: string | null }) { + return
+
{label}
+
{value ?? "Not stated"}
+
; +} + +/** + * One place in a three-up grid: logo, name, category, and DR, a two-line description, then cost, + * link, and review wait. The name link stretches over the card; the site and Submit links sit above it. + */ +export function LaunchCard({ action, category, directory, eager = false, linked = true }: { + /** Replaces the site link at the bottom left, such as a Compare button. */ + action?: ReactNode; + category: LaunchCategory | undefined; + directory: LaunchCardRecord; + eager?: boolean; + /** False when the place has no published page yet, so the card never links to a 404. */ + linked?: boolean; +}) { + const extra = directory.categories.length - 1; + return
+
+ +
+

{linked ? {directory.name} : directory.name}

+ {category ? + {category.label}{extra > 0 ? +{extra} : null} + : null} +
+ {directory.domain_rating !== null ?
+ {directory.domain_rating} + Domain Rating
by Ahrefs
+
: null} +
+

{cardDescription(directory)}

+
+ + + +
+
+ {action ?? {domainOf(directory.website_url)}} + {isSubmitReady(directory) + ? + Submit to {directory.name} + : {submitStatusText(directory)}} +
+
; +} + +/** Cards three across, two on tablets, one on phones. */ +export function LaunchGrid({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/app/(site)/(directories)/launch-directories/launch-directories.module.css b/app/(site)/(directories)/launch-directories/launch-directories.module.css index b129c0f8..df37a5ea 100644 --- a/app/(site)/(directories)/launch-directories/launch-directories.module.css +++ b/app/(site)/(directories)/launch-directories/launch-directories.module.css @@ -1,212 +1,334 @@ -.results { +/* Launch directories: header stats, the three-up card, the results toolbar and table, the at-a-glance + charts, and the guide below. The directory and compare pages reuse the card and logo. */ + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +.mono { font-family: var(--font-mono); } +.unknown { color: var(--color-text-muted); font-weight: var(--weight-regular); } + +/* ---------- Header stat line ---------- */ +.statline { + display: flex; + flex-wrap: wrap; + gap: var(--space-3) var(--space-12); + margin: var(--space-5) 0 0; +} + +.statline > div { display: flex; flex-direction: column; gap: 2px; } +.statline dt { color: var(--color-text-muted); font: var(--text-small) var(--font-mono); background: var(--color-bg); width: fit-content; } +.statline dd { + order: -1; + margin: 0; + width: fit-content; + background: var(--color-bg); + font: var(--weight-bold) 1.9rem / 1 var(--font-display); + letter-spacing: var(--tracking-display); +} + +/* ---------- Logo ---------- */ +.logo { + flex: none; display: grid; - gap: var(--space-6); - min-width: 0; + place-items: center; + width: 36px; + height: 36px; + overflow: hidden; + background: #fff; + border: 1px solid var(--color-border); + border-radius: var(--radius); + color: #141414; + font: var(--weight-bold) 16px / 1 var(--font-display); +} + +.logo[data-size="row"] { width: 22px; height: 22px; font-size: 11px; } +.logo[data-size="title"] { width: 48px; height: 48px; font-size: 22px; } +.logo img { width: 100%; height: 100%; object-fit: cover; } + +/* ---------- Card ---------- */ +.grid { + display: grid; + gap: var(--space-7); + grid-template-columns: repeat(3, minmax(0, 1fr)); } -/* Up to three columns, but a card never drops below 340px, so Cost and Link stay on one line. - Doubled to win over the shared grid's container-query columns. */ -.grid.grid { grid-template-columns: repeat(auto-fill, minmax(min(100%, max(340px, calc((100% - 2 * var(--space-6)) / 3))), 1fr)); } +@container (max-width: 1000px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@container (max-width: 620px) { .grid { grid-template-columns: minmax(0, 1fr); } } -/* Big answers card: identity, cost and link in large type, then Details and Submit. */ .card { position: relative; display: grid; - grid-template-rows: auto auto 1fr; - gap: var(--space-6); + grid-template-rows: auto 1fr auto auto; + gap: var(--space-7); min-width: 0; - padding: var(--space-9) var(--space-9) 0; + padding: var(--space-9) var(--space-9) var(--space-7); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius); - overflow-wrap: anywhere; transition: border-color var(--motion-fast); } -.card:hover { border-color: var(--color-border-strong); } -.card:has(.more summary:focus-visible) { box-shadow: var(--focus-ring); } +.card:hover, .card:focus-within { border-color: var(--color-border-strong); } -.identity { display: flex; align-items: center; gap: var(--space-5); min-width: 0; } -.identityText { display: grid; gap: var(--space-1); min-width: 0; } +.cardTop { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--space-5); align-items: center; } +.cardIdentity { min-width: 0; } -.name { +.cardName { margin: 0; - color: var(--color-text); - font-family: var(--font-display); - font-size: var(--text-h3); - font-weight: var(--weight-bold); - line-height: var(--line-heading); - letter-spacing: var(--tracking-display); + font: var(--weight-bold) 1.06rem / 1.2 var(--font-display); + letter-spacing: -0.01em; + overflow-wrap: anywhere; } -/* Linked cards color their destination title. Direct children only, so the open panel keeps its own colors. */ -.card:hover > .identity .name { color: var(--color-link); } +.cardName a { color: var(--color-text); text-decoration: none; } +.cardName a::after { content: ""; position: absolute; inset: 0; } +.card:hover .cardName a { color: var(--color-link); } +.cardName a:focus-visible { outline: none; } +.card:has(.cardName a:focus-visible) { box-shadow: var(--focus-ring); } -.category { +.cardCategory { display: flex; - align-items: baseline; + align-items: center; gap: var(--space-2); + margin-top: 2px; color: var(--color-text-muted); - font-size: var(--text-small); - line-height: var(--line-body); + font: 0.8rem / 1.3 var(--font-mono); } -.category svg { flex: 0 0 14px; width: 14px; height: 14px; align-self: flex-start; margin-top: 4px; } -.extraCategories { white-space: nowrap; } +.cardCategory svg { flex: none; width: 13px; height: 13px; } -.siteIcon { - flex: none; +.cardDr { display: grid; - place-items: center; - width: 48px; - height: 48px; - border: 1px solid var(--color-border); - border-radius: var(--radius); - background: var(--color-surface-2); - overflow: hidden; + justify-items: end; + color: var(--color-text-muted); + font: 0.72rem / 1.1 var(--font-mono); } -/* Official logos sit on a light neutral plate in both themes, so dark and transparent marks stay visible. */ -.logoPlate { background: var(--color-surface); } -:global([data-theme="dark"]) .logoPlate { background: var(--color-text); border-color: transparent; } - -.siteIcon img { width: 40px; height: 40px; object-fit: contain; } -/* The letter fallback keeps the themed surface and text colors. */ -.siteIcon > span { color: var(--color-text); font-family: var(--font-display); font-size: var(--text-h3); font-weight: var(--weight-bold); } +.cardDr b { color: var(--color-text); font: var(--weight-bold) 1.25rem / 1 var(--font-display); } +.cardDr span { margin-top: 3px; font-size: 0.64rem; line-height: 1.25; text-align: right; } +.cardDr a { position: relative; z-index: 1; color: inherit; text-decoration: underline; text-underline-offset: 2px; } +.cardDr a:hover { color: var(--color-text); } -/* What the place is for, before the two answers. */ -.bestFor { margin: 0; font-size: var(--text-small); line-height: var(--line-body); } -.bestFor span { color: var(--color-text-muted); } +.cardDescription { + margin: 0; + align-self: start; + font-size: var(--text-small); + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} -.answers { - padding-top: var(--space-6); - border-top: 1px dashed var(--color-border); +.cardFacts { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - align-content: start; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-5); margin: 0; + padding-top: var(--space-6); + border-top: 1px dashed var(--color-border-strong); +} + +.cardFacts > div { min-width: 0; } +.cardFacts dt { color: var(--color-text-muted); font: 0.74rem var(--font-mono); } +.cardFacts dd { margin: 2px 0 0; font: var(--weight-semibold) 0.86rem / 1.3 var(--font-mono); overflow-wrap: anywhere; } +.cardFacts dd[data-unknown] { color: var(--color-text-muted); font-weight: var(--weight-regular); } + +.cardActions { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-5); + min-width: 0; + font: var(--text-small) var(--font-mono); } -.answers div { display: grid; align-content: start; gap: var(--space-2); min-width: 0; } -.answers dt { color: var(--color-text-muted); font-size: var(--text-small); } +.cardActions a { display: inline-flex; align-items: center; gap: var(--space-2); color: var(--color-text-muted); text-decoration: none; overflow-wrap: anywhere; } +.cardActions a:hover { color: var(--color-text); } +.cardActions .cardSubmit, .cardSubmit { flex: none; color: var(--color-link); font-family: var(--font-mono); font-weight: var(--weight-semibold); text-decoration: none; } +.cardMuted { flex: none; color: var(--color-text-muted); } -/* `.answers dd` below is the label; the note is a second, quieter dd under it. */ -.answers .answerNote { margin-top: calc(-1 * var(--space-1)); color: var(--color-text-muted); font-family: var(--font-sans); font-size: var(--text-small); font-weight: var(--weight-regular); line-height: var(--line-body); white-space: normal; } +/* ---------- Sidebar quick picks ---------- */ +.picks { display: grid; gap: var(--space-3); } +.sideLabel { margin: 0; color: var(--color-text-muted); font: var(--text-small) var(--font-mono); } +.picks ul { display: grid; gap: var(--space-1); margin: 0; padding: 0; list-style: none; } -/* Sized so the longest short label, such as "Couldn't verify", fits on one line in a card column. */ -.answers dd { - margin: 0; - font-family: var(--font-display); - font-size: var(--text-h3); - font-weight: var(--weight-bold); - line-height: var(--line-heading); - overflow-wrap: normal; +.picks a { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: var(--space-4); + align-items: center; + padding: var(--space-3) var(--space-4); + border-radius: var(--radius); + color: var(--color-text); + font-size: var(--text-small); + line-height: var(--line-ui); + text-decoration: none; } -.requirements { margin: 0; font-size: var(--text-small); line-height: var(--line-body); } -.requirements > span { color: var(--color-text-muted); margin-right: var(--space-2); } +.picks a:hover { background: var(--color-surface-2); } +.picks a[aria-current] { background: var(--color-surface-2); font-weight: var(--weight-semibold); } +.pickDot { width: 13px; height: 13px; border: 1px solid var(--color-border-strong); border-radius: 50%; display: grid; place-items: center; } +.picks a[aria-current] .pickDot { border-color: var(--color-accent-text); } +.picks a[aria-current] .pickDot::after { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--color-accent-text); } +.pickCount { color: var(--color-text-muted); font: var(--text-small) var(--font-mono); } +.picks a[aria-current] .pickCount { color: var(--color-accent-text); } -.cardFooter { +/* ---------- Results ---------- */ +.results { display: grid; gap: var(--space-7); min-width: 0; } + +.toolbar { display: flex; - justify-content: space-between; + flex-wrap: wrap; align-items: center; + justify-content: space-between; gap: var(--space-5); - margin: 0 calc(-1 * var(--space-9)); - padding: var(--space-2) var(--space-9); - border-top: 1px solid var(--color-border); - font-size: var(--text-small); } -/* The summary opens the side panel. While closed, its ::after covers the whole card. Without JavaScript the details expand inline, so the overlay drops and their links stay clickable. */ -.more summary { +.countLine { display: grid; gap: 2px; } +.count { margin: 0; color: var(--color-text-muted); font: var(--text-small) var(--font-mono); } +.ahrefsCredit { color: var(--color-text-muted); font: 0.78rem / 1.4 var(--font-mono); } +.ahrefsCredit a { color: inherit; text-decoration: underline; text-underline-offset: 3px; } +.ahrefsCredit a:hover { color: var(--color-text); } +.count b { color: var(--color-text); font-weight: var(--weight-semibold); } +.toolbarControls { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); } +.toolbarControls :global(.directory-select) { display: flex; align-items: center; gap: var(--space-4); } +.toolbarControls :global(.directory-select .field-label-line) { color: var(--color-text-muted); font: var(--text-small) var(--font-mono); } +.toolbarControls :global(.directory-select .field-label-line::after) { display: none; } +.toolbarControls :global(.directory-select .custom-select) { position: relative; flex: none; width: 190px; } +.toolbarControls :global(.directory-select .field-label-line) { flex: none; width: auto; } +.toolbarControls :global(.directory-select [data-custom-select-label]) { white-space: nowrap; } +.toolbarControls :global(.directory-select .custom-select-trigger) { width: 100%; min-height: 36px; padding: 6px var(--space-5); font-size: var(--text-small); } + +.viewToggle { display: inline-flex; border: 1px solid var(--color-border-strong); border-radius: var(--radius); overflow: hidden; } + +.viewToggle a { display: inline-flex; align-items: center; - min-height: 40px; + gap: var(--space-2); + padding: 7px var(--space-5); color: var(--color-text-muted); - cursor: pointer; - list-style: none; + font: var(--text-small) var(--font-mono); + text-decoration: none; } -.more summary::-webkit-details-marker { display: none; } -.more:not([open]) summary::after { content: ""; position: absolute; inset: 0; } -.more summary:focus-visible { outline: none; } -.card:hover > .cardFooter .more summary { color: var(--color-text); } -.more[open] > .details { margin-top: var(--space-5); } +.viewToggle a + a { border-left: 1px solid var(--color-border-strong); } +.viewToggle a[aria-current] { background: var(--color-surface-2); color: var(--color-text); } -.submit { - position: relative; - z-index: 1; - display: inline-flex; - align-items: center; - gap: var(--space-1); - min-height: 40px; - color: var(--color-link); - font-weight: var(--weight-semibold); +.tableFrame { min-width: 0; overflow-x: auto; border: 1px solid var(--color-border); border-radius: var(--radius); } +.table { width: 100%; border-collapse: collapse; font-size: var(--text-small); } + +.table th, .table td { + padding: var(--space-5) var(--space-7); + border-bottom: 1px solid var(--color-border); + text-align: left; + vertical-align: middle; white-space: nowrap; } -.submit:hover { text-decoration: underline; } -.submit:focus-visible { outline: none; box-shadow: var(--focus-ring); } +.table thead th { color: var(--color-text-muted); font: var(--weight-regular) 0.78rem var(--font-mono); background: var(--color-surface); } +.table tbody tr:last-child > * { border-bottom: 0; } +.table tbody tr:hover > * { background: var(--color-surface); } +.table tbody th a { display: inline-flex; align-items: center; gap: var(--space-4); color: var(--color-text); font-weight: var(--weight-semibold); text-decoration: none; } +.table tbody th a:hover { color: var(--color-link); } +.tableName { display: inline-flex; align-items: center; gap: var(--space-4); font-weight: var(--weight-semibold); } -.submissionNote { - position: relative; - z-index: 1; - margin: 0; - color: var(--color-text-muted); - font-size: var(--text-small); - text-align: right; -} - -/* Side panel content. The panel frame comes from the shared DetailDialog. */ -.panel { display: grid; gap: var(--space-8); } -.panelHeading { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-5); } -.panelHeading .name { font-size: var(--text-h2); } +/* ---------- At a glance ---------- */ +.glance { display: grid; gap: var(--space-10); margin-top: var(--space-14); } +.glance h2 { margin: 0; font: var(--weight-bold) var(--text-h2) / var(--line-heading) var(--font-display); letter-spacing: var(--tracking-display); } +.glanceGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-7); } +@container (max-width: 760px) { .glanceGrid { grid-template-columns: minmax(0, 1fr); } } -.details { display: grid; gap: var(--space-7); } -.details h3 { margin: 0 0 var(--space-3); font-family: var(--font-display); font-size: var(--text-h3); } -.details .submissionNote { text-align: left; } -.detailsSummary { margin: 0; } +.glanceBlock { + display: grid; + align-content: start; + gap: var(--space-4); + min-width: 0; + padding: var(--space-9) var(--space-10); + border: 1px solid var(--color-border); + border-radius: var(--radius); +} -.facts { margin: 0; border-top: 1px solid var(--color-border); } -.facts div { display: grid; gap: var(--space-1); padding: var(--space-4) 0; border-bottom: 1px solid var(--color-border); } -.facts dt { display: inline-flex; align-items: center; gap: var(--space-2); color: var(--color-text-muted); font-size: var(--text-small); } -.facts dd { margin: 0; font-size: var(--text-small); line-height: var(--line-body); } +.glanceBlock h3 { margin: 0; font: var(--weight-semibold) 1.02rem / 1.3 var(--font-display); } +.glanceBlock > p { margin: 0 0 var(--space-4); color: var(--color-text-muted); font: var(--text-small) / 1.4 var(--font-mono); } -.sources { display: grid; gap: var(--space-3); font-size: var(--text-small); line-height: var(--line-body); } -.sources h3 { margin-bottom: 0; } -.sources p { margin: 0; } -.sources ul { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; } -.sources li { display: flex; flex-wrap: wrap; gap: 0 var(--space-3); min-width: 0; } -.sources li a { min-width: 0; font-family: var(--font-mono); overflow-wrap: anywhere; } -.sources > a { justify-self: start; } -.sourceLabel { color: var(--color-text-muted); } -.checked { color: var(--color-text-muted); font-family: var(--font-mono); } +/* ---------- Compare callout ---------- */ +.callout { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: var(--space-7); + align-items: center; + margin-top: var(--space-13); + padding: var(--space-9) var(--space-10); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius); + color: var(--color-text); + text-decoration: none; +} -.observations { font-size: var(--text-small); line-height: var(--line-body); } -.observations p { margin: 0; } -.observations ul { display: grid; gap: var(--space-4); margin: var(--space-4) 0 0; padding: 0; list-style: none; } -.observations li { display: grid; gap: var(--space-1); } +.callout:hover { background: var(--color-surface); } +.callout > span:nth-child(2) { display: grid; gap: 2px; } +.callout b { font: var(--weight-semibold) 1.05rem / 1.3 var(--font-display); } +.callout > span:nth-child(2) > span { color: var(--color-text-muted); font-size: var(--text-small); } +.calloutIcon { display: grid; place-items: center; width: 40px; height: 40px; border: 1px solid var(--color-border); border-radius: var(--radius); color: var(--color-text-muted); } +.calloutArrow { color: var(--color-text-muted); } +.callout:hover .calloutArrow { color: var(--color-link); } -.resultsIntro { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-2) var(--space-5); flex-wrap: wrap; } -.resultsIntro p { margin: 0; color: var(--color-text-muted); font-size: var(--text-small); } -.resultsIntro .count { color: var(--color-text); font-weight: var(--weight-semibold); } +/* ---------- Guide ---------- */ +.guide { display: grid; gap: var(--space-14); margin-top: var(--space-14); } +.kitSection { display: grid; gap: var(--space-10); } -.guide { - max-width: var(--measure-prose); - border-top: 1px solid var(--color-border); - padding-top: var(--space-10); +.kit { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--space-7); + align-items: end; + margin: 0; + padding: 0; + list-style: none; } -.srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } +@container (max-width: 900px) { .kit { grid-template-columns: repeat(3, minmax(0, 1fr)); } } +@container (max-width: 520px) { .kit { grid-template-columns: repeat(2, minmax(0, 1fr)); } } + +.kit li { display: grid; gap: var(--space-2); min-width: 0; } +.kit b { margin-top: var(--space-3); font-size: var(--text-small); font-weight: var(--weight-semibold); } +.kit li > span:last-child { color: var(--color-text-muted); font: 0.8rem / 1.4 var(--font-mono); } -@media (max-width: 400px) { - .answers dd { font-size: var(--text-body); } - .card { padding-inline: var(--space-7); } - .cardFooter { margin-inline: calc(-1 * var(--space-7)); padding-inline: var(--space-7); } +.kitFrame { + display: grid; + align-content: center; + gap: 6px; + padding: var(--space-5); + border: 1px dashed var(--color-border-strong); + border-radius: var(--radius); } -/* Phones stack Cost and Link, so each label and its note keep one line. */ -@media (max-width: 480px) { - .answers { grid-template-columns: 1fr; } +.kitFrame[data-shape="square"] { aspect-ratio: 1; width: 62%; } +.kitFrame[data-shape="wide"] { aspect-ratio: 1270 / 760; } +.kitFrame[data-shape="social"] { aspect-ratio: 1200 / 630; } +.kitFrame[data-shape="line"], .kitFrame[data-shape="lines"] { aspect-ratio: 4 / 3; } +.kitFrame i { display: block; height: 6px; background: var(--color-surface-2); border-radius: 1px; } +.kitFrame[data-shape="line"] i:nth-child(n + 2) { display: none; } +.kitFrame[data-shape="line"] i { width: 80%; } +.kitFrame[data-shape="lines"] i:nth-child(1) { width: 92%; } +.kitFrame[data-shape="lines"] i:nth-child(2) { width: 86%; } +.kitFrame[data-shape="lines"] i:nth-child(3) { width: 60%; } + +@container (max-width: 620px) { + .statline { gap: var(--space-3) var(--space-10); } + .statline dd { font-size: 1.6rem; } + .toolbarControls { width: 100%; } + .toolbarControls :global(.directory-select) { flex: 1; min-width: 0; } + .toolbarControls :global(.directory-select .custom-select) { flex: 1; width: auto; } + .callout { grid-template-columns: minmax(0, 1fr) auto; } + .calloutIcon { display: none; } } diff --git a/app/(site)/(directories)/launch-directories/launch-directory-guide.tsx b/app/(site)/(directories)/launch-directories/launch-directory-guide.tsx index b89b50d1..b321565f 100644 --- a/app/(site)/(directories)/launch-directories/launch-directory-guide.tsx +++ b/app/(site)/(directories)/launch-directories/launch-directory-guide.tsx @@ -1,15 +1,18 @@ +import { ArrowRight, Columns3 } from "lucide-react"; +import Link from "next/link"; import type { ComponentPropsWithoutRef } from "react"; import { NewTabContentLink } from "@/app/ui/content-elements"; import { proseClassName, proseFlowClassName } from "@/app/ui/site/prose"; import Corrections from "./content/corrections.mdx"; import FreeListings from "./content/free-listings.mdx"; import HowWeCheck from "./content/how-we-check.mdx"; +import LaunchKit from "./content/launch-kit.mdx"; import LaunchTiming from "./content/launch-timing.mdx"; import LinkTypes from "./content/link-types.mdx"; import RecommendedOrder from "./content/recommended-order.mdx"; import styles from "./launch-directories.module.css"; -// The results header links to these two headings, so each file's h2 keeps a fixed id. +// Other pages link to these headings, so each file's h2 keeps a fixed id. function headingWithId(id: string) { return function Heading(props: ComponentPropsWithoutRef<"h2">) { return

; @@ -19,13 +22,49 @@ function headingWithId(id: string) { // Source links open in a new tab, so visitors keep their filtered list. const a = NewTabContentLink; +/** Product Hunt's published sizes, which most other places accept. */ +const kit = [ + { name: "Square logo", spec: "240 × 240", note: "PNG or SVG", shape: "square" }, + { name: "Screenshots", spec: "1270 × 760", note: "3 to 5", shape: "wide" }, + { name: "Social image", spec: "1200 × 630", note: "For link previews", shape: "social" }, + { name: "Tagline", spec: "60 characters", note: "What it does", shape: "line" }, + { name: "Description", spec: "260 characters", note: "Who it's for", shape: "lines" }, +] as const; + +export function CompareCallout() { + return + + + Compare up to three places + Cost, review time, link type, and domain rating side by side. + +