Cleaner landing page
This commit is contained in:
parent
9635c44993
commit
89900c81c8
|
|
@ -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.
|
||||
|
|
|
|||
6
SPEC.md
6
SPEC.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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">
|
||||
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:
|
||||
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"
|
||||
>
|
||||
Nézd meg, mi épül
|
||||
</Link>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<dl className="grid grid-cols-3 gap-4 lg:gap-6">
|
||||
<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-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>
|
||||
|
||||
<Link
|
||||
to="/kampanyok"
|
||||
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>
|
||||
|
||||
<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>
|
||||
<Link to="/kampanyok" className="text-sm font-semibold text-electric-strong hover:underline">
|
||||
Összes projekt →
|
||||
</Link>
|
||||
<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>
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{active.map((c) => (
|
||||
<CampaignCard key={c.slug} campaign={c} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue