Cleaner landing page

This commit is contained in:
Kovács Attila 2026-09-06 02:50:12 -07:00
parent 9635c44993
commit 89900c81c8
10 changed files with 89 additions and 71 deletions

View File

@ -1,6 +1,6 @@
# Szabadpálya
Community funding for Hungarian civil projects, with no platform fee.
Community funding for Hungarian public projects, with no platform fee.
**Backend** Go + Gin + pgx, Postgres 17 · **Frontend** React 19 + React Router +
TypeScript + Vite + Tailwind 4 · **Deployment** Docker Compose, self-hosted.

View File

@ -8,7 +8,7 @@
## 1. Positioning
Curated, invite-only crowdfunding platform for civil projects: helyi szervezetek,
Curated, invite-only crowdfunding platform for public projects: helyi szervezetek,
Lepke-típusú kulturális egyesületek, szövetséges NGO-k.
**Party-adjacent, not party-branded.** The platform has its own name, identity and
@ -38,7 +38,7 @@ registrar and register before any design work ships)
**Tagline:** *Szabad a pálya.* ("the track is clear")
From the living idiom "szabad a pálya!" — the coast is clear, go ahead, nothing is
in your way. Exactly what the platform says to a civil project waiting for a
in your way. Exactly what the platform says to a public project waiting for a
permission it never actually needed. `Pálya` also reads as track / field / career:
young, forward-moving, unideological.
@ -348,7 +348,7 @@ These are load-bearing for **money**, not just uptime:
### Server placement
Host in the **EU but outside Hungary** — Frankfurt or Helsinki. The database holds a
private list of who funded which civil project; for a party-adjacent platform that is
private list of who funded which public project; for a party-adjacent platform that is
politically sensitive data, and Hungarian-jurisdiction infrastructure is more exposed
to Hungarian pressure than German-hosted infrastructure is. This is a threat-model
decision, not only a GDPR one. Full-disk encryption on the box, encrypted backups.

View File

@ -13,8 +13,8 @@ import (
)
const (
siteTitle = "Szabadpálya — közösségi finanszírozás civil projekteknek"
siteDesc = "Szabad a pálya. Közösségi finanszírozás magyar civil projekteknek, platformdíj nélkül."
siteTitle = "Szabadpálya — közösségi finanszírozás közéleti projekteknek"
siteDesc = "Szabad a pálya. Közösségi finanszírozás magyar közéleti projekteknek, platformdíj nélkül."
)
func pageTitle(s string) string { return s + " · Szabadpálya" }
@ -46,11 +46,6 @@ func (s *Server) document(c *gin.Context) {
switch {
case path == "/":
campaigns, err := s.store.ListCampaigns(ctx)
if err != nil {
s.failDocument(c, err)
return
}
stats, err := s.store.PlatformStats(ctx)
if err != nil {
s.failDocument(c, err)
@ -61,7 +56,7 @@ func (s *Server) document(c *gin.Context) {
Description: siteDesc,
Canonical: s.shell.Abs("/"),
OG: shell.OpenGraph{URL: s.shell.Abs("/")},
}, bootstrap{Route: "home", Data: gin.H{"campaigns": campaigns, "stats": stats}})
}, bootstrap{Route: "home", Data: gin.H{"stats": stats}})
case path == "/kampanyok":
campaigns, err := s.store.ListCampaigns(ctx)

View File

@ -238,6 +238,24 @@ assert payload["data"]["slug"] == "'"${SLUG}"'", payload["data"]["slug"]
}
step "bootstrap payload inlined" check_bootstrap
# The landing page shows the headline numbers only; the campaigns themselves
# live one click away, on /kampanyok. If the list creeps back into the home
# payload the page is loading rows it does not render.
check_home_has_no_list() {
curl -s "http://localhost:${APP_PORT}/" \
| python3 -c '
import re, sys, json
html = sys.stdin.read()
m = re.search(r"<script type=\"application/json\" id=\"__DATA__\">(.*?)</script>", html, re.S)
if not m: print("no __DATA__ payload"); raise SystemExit(1)
payload = json.loads(m.group(1))
assert payload["route"] == "home", payload["route"]
assert "stats" in payload["data"], "the home payload lost its stats"
assert "campaigns" not in payload["data"], "the home payload still carries the campaign list"
'
}
step "landing page carries no campaign list" check_home_has_no_list
check_health_ok() {
local body code
body=$(curl -s -w '\n%{http_code}' "http://localhost:${APP_PORT}/api/health")

View File

@ -8,7 +8,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Szabadpálya — közösségi finanszírozás civil projekteknek</title>
<title>Szabadpálya — közösségi finanszírozás közéleti projekteknek</title>
<script>try{var t=localStorage.getItem('szp-theme');if(t==='light'||t==='dark'){document.documentElement.setAttribute('data-theme',t)}}catch(e){}</script>
</head>
<body class="flex min-h-full flex-col">

BIN
web/src/assets/kincso.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

View File

@ -8,7 +8,7 @@ export function SiteFooter() {
<div className="sm:col-span-2 lg:col-span-1">
<Logo size="sm" />
<p className="mt-3 max-w-xs text-sm text-muted">
Közösségi finanszírozás civil projekteknek. Platformdíj nélkül.
Közösségi finanszírozás közéleti projekteknek. Platformdíj nélkül.
</p>
</div>
<FooterCol

View File

@ -13,8 +13,8 @@ import NotFound from "./routes/NotFound";
import AdminHome from "./routes/admin/AdminHome";
import EditCampaign from "./routes/admin/EditCampaign";
/** The landing page shows the headline numbers, not the campaigns themselves. */
export interface HomeData {
campaigns: CampaignSummary[];
stats: PlatformStats;
}
@ -29,12 +29,8 @@ export const router = createBrowserRouter([
{
path: "/",
element: <Home />,
loader: async (): Promise<HomeData> => {
const inlined = takeBootstrap<HomeData>("home");
if (inlined) return inlined;
const [campaigns, stats] = await Promise.all([api.campaigns(), api.stats()]);
return { campaigns, stats };
},
loader: async (): Promise<HomeData> =>
takeBootstrap<HomeData>("home") ?? { stats: await api.stats() },
},
{
path: "/kampanyok",

View File

@ -12,8 +12,8 @@ export default function Campaigns() {
<p className="kicker text-electric-strong">Projektek</p>
<h1 className="mt-2 font-display text-4xl">Amin most dolgozunk</h1>
<p className="mt-3 max-w-2xl text-muted">
Minden kampányt kézzel hagyunk jóvá. Pártot, kampányt és jelöltet nem
finanszírozunk — civil projekteket igen.
Minden projektet kézzel hagyunk jóvá. Kampányt és jelöltet nem
finanszírozunk — közéleti kezdeményezéseket igen.
</p>
<div className="mt-8 flex flex-wrap gap-2">

View File

@ -1,72 +1,81 @@
import { Link, useLoaderData } from "react-router";
import { CampaignCard } from "@/components/CampaignCard";
import { useTitle } from "@/lib/title";
import type { HomeData } from "@/router";
import kincso from "@/assets/kincso.jpg";
/**
* Copy on the left, one illustration on the right. Both sit in the site's
* max-w-6xl container, so the headline starts on the same left edge as the
* header logo and the footer; the measure is set on the text itself.
*
* The portrait is imported rather than dropped in web/public: Vite hashes an
* imported asset into dist/assets, which is the one directory the Go server
* serves as static files. A file in public/ would land at the dist root and be
* answered with the HTML document instead.
*/
export default function Home() {
const { campaigns, stats } = useLoaderData() as HomeData;
const active = campaigns.filter((c) => c.status !== "lezart");
const { stats } = useLoaderData() as HomeData;
useTitle("");
return (
<>
<section className="border-b border-line bg-surface-2">
<div className="mx-auto grid max-w-6xl gap-10 px-5 py-16 lg:grid-cols-[1.1fr_1fr] lg:items-center lg:py-24">
<div>
<section className="mx-auto max-w-6xl px-5 pt-20 pb-12">
<div className="grid items-center gap-12 lg:grid-cols-[1.05fr_0.95fr] lg:gap-16">
<div className="flex flex-col">
<p className="kicker text-electric-strong">Közösségi finanszírozás</p>
<h1 className="mt-3 font-display text-5xl font-extrabold sm:text-6xl">
<h1 className="mt-4 font-display text-5xl font-extrabold sm:text-6xl">
Szabad a pálya{" "}
<span className="text-lime" aria-hidden>
→
</span>
</h1>
<p className="mt-5 max-w-xl text-lg text-muted">
Civil projektek finanszírozása a közösségtől. Nem kérünk platformdíjat:
<p className="mt-6 max-w-xl text-lg leading-relaxed text-muted">
Közéleti kezdeményezések finanszírozása a közösségtől. Nem kérünk platformdíjat:
ami összegyűlik, az a projekté.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link
to="/kampanyok"
className="rounded-lg bg-electric px-6 py-3.5 font-semibold text-on-electric transition hover:brightness-110"
className="group mt-10 inline-flex items-center gap-3 self-start rounded-xl bg-electric px-8 py-4 text-lg font-semibold text-on-electric shadow-[0_0_0_4px_var(--bg),0_0_0_6px_var(--electric)] transition hover:brightness-110 hover:shadow-[0_0_0_2px_var(--bg),0_0_0_4px_var(--electric)]"
>
{/* The ring is a box-shadow, not an outline: outline is what
:focus-visible uses in styles.css, and a permanent one there
would leave keyboard focus with nothing distinct to show. */}
Nézd meg, mi épül
<span aria-hidden className="transition-transform group-hover:translate-x-1">
→
</span>
</Link>
</div>
</div>
<dl className="grid grid-cols-3 gap-4 lg:gap-6">
<dl className="mt-14 flex flex-wrap gap-x-12 gap-y-8 sm:gap-x-16">
<Stat value="0%" label="platformdíj" />
<Stat value={String(stats.activeCount)} label="futó projekt" />
<Stat value={stats.backers.toLocaleString("hu-HU")} label="támogató" />
</dl>
</div>
</section>
<section className="mx-auto max-w-6xl px-5 py-16">
<div className="mb-8 flex items-baseline justify-between gap-4">
<div>
<p className="kicker text-electric-strong">Most fut</p>
<h2 className="mt-1.5 font-display text-3xl">Aktív kampányok</h2>
<div className="overflow-hidden rounded-card shadow-card">
{/* Square source in a landscape box, so the crop is biased upwards to
keep the face off the bottom edge. */}
<img
src={kincso}
alt="Szilas Kincső"
width={1000}
height={1000}
className="block aspect-[3/2] w-full object-cover object-[50%_28%]"
/>
</div>
<Link to="/kampanyok" className="text-sm font-semibold text-electric-strong hover:underline">
Összes projekt →
</Link>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{active.map((c) => (
<CampaignCard key={c.slug} campaign={c} />
))}
</div>
</section>
</>
);
}
function Stat({ value, label }: { value: string; label: string }) {
return (
<div className="rounded-card border border-line bg-surface p-5">
<dt className="num font-display text-3xl font-extrabold text-electric">{value}</dt>
<dd className="mt-1 text-xs text-muted">{label}</dd>
<div>
<dt className="num font-display text-4xl font-extrabold text-electric">{value}</dt>
<dd className="kicker mt-2 text-muted">{label}</dd>
</div>
);
}