20 KiB
Szabadpálya — spec overview
Status: draft v0.7 — 2026-08-22 Working name: Szabadpálya · szabadpalya.hu Reference UX: reward-based crowdfunding, Kickstarter pattern
1. Positioning
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 operating entity. Momentum's name, logo and colours do not appear in the product, the domain, or the legal footer. The connection is in who gets approved and who founded it — not in the branding. Practical consequences:
- Separate legal operator: a new egyesület, to be founded (see 1c).
- Own name and mark (see 1b). The visual world is shared with the Momentum rendszerbontó identity — palette, typography, motif — but the Momentum mark and wordmark never appear. Shared language, separate signature.
- Impressum names the egyesület. No "a Momentum kezdeményezése" line.
No campaign may fund a party, a party campaign, or a candidate. That boundary is what keeps the platform outside párttörvény (2007. évi CLXXXI. tv.) reporting and donation-cap rules, and it is enforced as an explicit check in the approval queue, not as a policy sentence.
Campaign creation is closed: creators apply, an admin approves. Main structural departure from the usual reward-crowdfunding model, which is open self-serve.
1b. Brand
Name: Szabadpálya · szabadpalya.hu (no DNS as of 2026-08-22 — verify at a
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 public project waiting for a
permission it never actually needed. Pálya also reads as track / field / career:
young, forward-moving, unideological.
The name carries szabad openly. We are not concealing the political lineage — the platform is party-adjacent and that is fine — we are simply not dressing it in Momentum's identity. No Momentum logo, colours, typography or naming conventions. Szabadpálya has its own mark.
Visual basis: the Momentum rendszerbontó brand edition
(../arculat/arculat3_assets/; the handbook PDF sits in brand/, gitignored).
| Token | Value | Role |
|---|---|---|
| Electric purple | #7360F9 |
primary accent, CTA, progress lane |
| Lime | #B9D440 |
overshoot, emphasis, the arrow |
| Lavender | #BC9BF3 |
secondary chips |
| Cream paper | #FFF1EB |
ground |
| Ink | #241C3B |
text |
| Heritage purple | #8E6FCE |
sparing; ties back to the older identity |
Ratio per the handbook: cream + ink ~55%, electric ~25%, lime + lavender ~15%. Typography: Playfair Display (display serif) + Poppins (body). The handbook has no dark mode — the ink-grounded variant is our extension.
Logo: placeholder, in brand/out/. Three lanes, the middle one open, running
into a lime arrow. Text is converted to paths, so it renders without the fonts
installed. At 32 px the dimmed lanes wash out — a small-size variant is needed if
this mark survives.
Voice (Hungarian UI copy): second person singular, active verbs, no NGO
passive. Indíts kampányt, not Kampány indítása. Beleadom, not Támogatás.
Primary CTA vocabulary:
| Context | String |
|---|---|
| Pledge button (tier) | Beleadom |
| Main campaign CTA | Beleadom ebbe a projektbe |
| Launch a campaign | Indíts kampányt |
| Browse | Nézd meg, mi épül |
| Hero | Szabad a pálya. |
1c. Operator
A new egyesület, founded for this purpose. Not Momentum, not an existing organisation. Consequences to plan around:
- The domain, the mark and the Stripe platform account are all registered to the egyesület — not to a private person.
- Hungarian egyesület registration (törvényszék) takes weeks to months and needs 10 founding members, statutes, and a registered seat. Build can proceed in parallel; Stripe test mode needs nothing, live mode needs the registered entity, tax number and bank account. That is the real launch gate, not the code.
- Creators onboard as their own Connect accounts, so the egyesület never holds campaign money — it only operates the platform.
- With a 0% platform fee the egyesület has running costs (hosting, domain, mail) and no revenue from campaigns. Budget for it, or the "ingyenes" promise is the first thing to break.
2. Funding model
Keep-what-you-raise. Contributions are kept regardless of whether the goal is reached. The progress bar is motivational, not binding.
Fulfillment guard (mitigation)
Physical rewards + immediate charge = shipping debt if the goal misses. Charging is therefore decided per tier type:
| Tier type | Charge timing | Rationale |
|---|---|---|
donation (symbolic, no good) |
immediate | Nothing owed in return |
digital (PDF, credits listing) |
immediate | Zero marginal cost to deliver |
physical (book, print, bundle) |
on goal reached, else released at close | Never owe a good you can't produce |
If a campaign with physical tiers ends under goal, physical pledges are released
uncaptured and the backer is notified; donation and digital money is kept. Config
flag campaign.physical_tier_policy = guard | immediate.
Legal consequence
Charging for a shippable good is távollévők közötti szerződés under 45/2014. (II. 26.) Korm. rendelet — 14-day withdrawal right, ÁFA due at charge, mandatory pre-contract disclosures at checkout. Physical tiers must carry: seller identity, delivery deadline, total price incl. ÁFA and shipping, withdrawal notice. Donation tiers do not.
3. Payments and fees
No platform fee. 0%. application_fee_amount = 0 on every charge. The operator
entity absorbs hosting and running costs; this is cost-recovery-free by design and
is a headline selling point against the 4.5–14.9% the commercial platforms charge.
Stripe Connect (HUF), platform-as-facilitator:
- Each approved creator org onboards as a connected account (Express). KYC/AML is Stripe's responsibility — important given who stands behind the operator.
- Backer pays via Payment Intent with
setup_future_usageso the card is vaulted; guarded physical tiers use manual capture, re-authorized at close. - Money never touches the operator's bank account. Creator is merchant of record.
- Payout on Stripe's standard schedule, min. net 50 000 Ft threshold.
- Phase 2: bank transfer (átutalás) with manual reconciliation for large donors.
Stripe's own processing fee is unavoidable and is deducted from the creator's
payout — we cannot zero that out, only our own cut. The /dijszabas page must state
this plainly so "ingyenes" is not read as "the creator receives 100%". Verify current
Stripe HU card rates before publishing that page; do not hardcode a number in the
product from memory.
Open: offer backers an optional "fedezem a tranzakciós költséget" checkbox at checkout? Common on NGO donation flows, keeps creator payout whole. Not MVP-blocking.
4. Page inventory
4.1 Campaign detail — /kampanyok/:slug
- Header — category chip, title, creator avatar + name,
Kedvencekhez adás - Media gallery — carousel, image counter
1/10, video support - Funding bar —
Cél: X Ft·Összegyűlt: Y Ft·N%progress ·N támogató·N nap van hátra· status badge - Sticky CTA —
Beleadom ebbe a projektbe,Megosztás,Megfigyelés - Tabs —
Sztori·Frissítések·Hozzászólások - Story — rich content (
Összefoglalás,Bemutatás),A csapatbios - Reward tiers — see 4.2
- Footer
Desktop: story left, sticky tier rail right. Mobile: single column, funding bar and CTA pinned to viewport bottom.
No Támogatók tab. The commercial platforms publish a backer list; we do not. Backer
identities are never public — not by name, not by avatar, not opt-in. Consequences:
- The aggregate count stays (
164 támogató) — non-identifying and load-bearing for social proof. - Full backer list is visible only to the campaign creator in
/vezerlopult, and only for fulfillment. Never exportable to a general-purpose mailing list. - The
100 000 Ftreference tier's "name printed in the credits" perk still works — that is the creator's own publication, not platform-published data.
Rationale: a donor list attached to a party-adjacent platform is both a GDPR exposure and a political liability. Not collecting public consent at all is cleaner than managing it.
4.2 Reward tier card
[ -20% ] [ Legnépszerűbb ]
12 000 Ft ~~15 000 Ft~~
Könyv és művészi nyomat
· 1 db könyv
· 1 db A4 művészi nyomat
14 támogató · 20 db-ra limitálva
Várható szállítás: 2026-11-30
[ Beleadom ]
Fields: price, list_price?, title, includes[], backer_count,
inventory_limit?, estimated_delivery, is_featured, type
(donation | digital | physical), requires_shipping.
4.3 Other pages
| Route | Purpose |
|---|---|
/ |
Landing: featured campaigns, how-it-works, social proof |
/kampanyok |
Browse — filters Összes / Aktív / Legnépszerűbb / by category |
/kampanyok/:slug/tamogatas |
Checkout: tier → qty → shipping → Stripe → confirm |
/sikersztorik |
Completed campaigns |
/hogyan-mukodik |
Explainer |
/dijszabas |
Zero platform fee + honest note on Stripe processing costs |
/belepes, /regisztracio |
Auth |
/vezerlopult |
Creator dashboard: editor, backers (private), updates, payouts |
/admin |
Approval queue, moderation, political-content screening |
5. Domain model
User ──< Campaign ──< RewardTier ──< Pledge >── User (backer)
│ │
├──< Update └──< ShippingAddress?
├──< Comment
└──< Payout
Campaign: slug, title, category, creator_org, goal_amount, currency=HUF,
starts_at, ends_at, status (draft → beadva → aktiv → finanszirozott /
lezart / elutasitva), story (rich), media[], team[], physical_tier_policy.
Pledge: backer, tier, quantity, amount, stripe_payment_intent_id, status
(authorized | captured | released | refunded), shipping_address?.
No is_anonymous field — every pledge is private, so there is nothing to toggle.
Derived, never stored: raised_amount, backer_count, progress_pct, days_left. Computed from captured + authorized pledges so the bar cannot drift from reality.
6. Campaign duration
The creator picks a concrete end date, not a length in days. It is shown as a
date everywhere in the creator UI (A kampány eddig tart: 2026-11-30) and rendered
as a countdown only on the public page (9 nap van hátra).
- Bounded:
ends_atmust fall within 90 days of launch. Configurable ceiling, enforced server-side at submit and at launch approval. - Campaigns close at 23:59:59 Europe/Budapest on the chosen date, not at the wall-clock instant of launch — a Hungarian creator picking "november 30" means the whole day.
- Extension after launch: admin-only, and never past the 90-day ceiling. Creators cannot quietly move their own deadline once money is in.
estimated_deliveryon physical tiers must be afterends_at. Validated.
7. Key flows
Back a project — tier select → qty → shipping (if requires_shipping) → account
or guest email → Stripe Payment Element → confirm → receipt email. No public trace.
Campaign close — scheduled job at ends_at: evaluate goal → capture guarded
physical pledges or release them → notify backers → notify creator → open the
fulfillment view in /vezerlopult.
Creator apply — form → admin review (incl. explicit political-content check) → Stripe Connect onboarding → editor unlocked → submit for launch approval.
8. Stack
Self-hosted on our own managed server. Everything below runs on one box under Docker Compose; nothing depends on a PaaS.
Two languages, on purpose. The stack was TypeScript end to end until the backend was rewritten in Go; the original argument (one language, one dependency tree) was sound and is now overridden by a deliberate decision. What it costs is recorded honestly below, because the reasoning that produced this table should not have to be reconstructed from a diff later.
Application
| Layer | Choice | Why |
|---|---|---|
| Backend | Go + Gin, single static binary | One process serves the API, the HTML document, the share images and the built assets. 49 MB image, nothing to install. |
| Frontend | React 19 + React Router + Vite | The campaign pages were already components; only their data source changed. |
| HTML document | Go html/template |
Per-campaign og: tags without a JS runtime — see below. |
| DB | Postgres 17, same host | Tiny dataset. No managed service needed at this scale. |
| Data access | pgx + hand-written SQL | The three queries that matter are aggregates over pledges; SQL states them more plainly than an ORM did. Migrations are plain .sql applied by a ~60-line runner. |
| Auth | Own magic links (api/internal/auth) |
Go has no better-auth. ~200 lines: hashed tokens, single-use redemption, cookie sessions. |
| Share images | Drawn in Go (api/internal/ogimage) |
Go has no Satori. Rectangles and text on a 1200×630 canvas. |
| Editor | Tiptap | Campaign story rich text. Not built yet. |
| UI | Tailwind 4, rendszerbontó tokens | See 1b. |
| Proxy/TLS | Caddy | Automatic Let's Encrypt, two lines of config. |
| Deploy | Docker Compose on the box | db, migrate, app. Nothing depends on a PaaS. |
What the second language costs. Money code loses Stripe's TypeScript types, which this document previously called the best guardrail available for it — Go's Stripe library is typed but less expressive about the shapes Stripe actually returns. Payments are still unbuilt (section 2 of TODO.md), so that cost lands in full on the work that has not started yet. Nothing about the rewrite makes it easier; it is a price paid for the rest of the stack.
Why the server renders HTML: the commercial Hungarian platforms are client-rendered SPAs — fetching a campaign page returns a ~3 KB empty shell, and the real content only exists after JavaScript runs. Every campaign shared to Facebook therefore shows the same generic site-wide card instead of the project's own photo, title and progress. For a platform whose growth is sharing, that is a permanent self-inflicted wound and the cheapest place for us to be strictly better.
Go renders the document, React renders the page. The seam is sharp: Go owns
<head> — title, description, og: tags, the theme script, and the hashed
asset tags read out of Vite's manifest.json — and React owns everything inside
#root. Nothing is rendered in two places, so nothing can drift.
What this does and does not buy, stated plainly. It buys the share cards,
which is the harm named above: crawlers read og: tags without executing
JavaScript. It does not buy what full SSR bought — the campaign page no longer
ships ~56 KB of real HTML, and a reader with JavaScript off sees nothing. Most
of the practical difference is clawed back by inlining the data the server
already loaded to build the tags, in a <script type="application/json" id="__DATA__"> block the client reads once; the first paint still waits for
the bundle. If body text in the HTML ever turns out to matter for search, that
is the point at which to reconsider, and the honest answer will be a JS runtime,
not more Go templating.
What self-hosting makes our problem
These are load-bearing for money, not just uptime:
- Stripe webhooks need a reliably reachable HTTPS endpoint. Stripe retries, so
handlers must be idempotent (dedupe on
event.id) and must verify signatures. Extended downtime during a campaign close means captures do not happen. - The close job (
ends_at→ capture or release) runs as a systemd timer or a small worker container, not Vercel Cron. It must be idempotent and safe to re-run: never double-charge a pledge. - Backups are the real risk. Nightly
pg_dump, encrypted, shipped off the box, with a restore actually tested. The DB holds pledges, addresses and a private donor list — losing it is worse than downtime, leaking it is worse still. - Do not self-host SMTP. Use Resend, Postmark or SES for transactional mail. A small VPS sending receipts lands in spam, and payment receipts must arrive.
- Monitoring: uptime check (Uptime Kuma / healthchecks.io) plus Sentry. A dead-man switch on the close job matters more than CPU graphs.
- Media on a mounted volume, included in the backup set. Object storage (Hetzner/R2/MinIO) only if volume grows.
- Migrations run as their own step, not on app boot:
compose.yamlhas amigrateservice (Dockerfile targetmigrator, the same image runningserver -migrate) that runs once and exits;appwaits for it to succeed. The server refusing to migrate at boot is load-bearing, not tidiness — a process that exits when Postgres is unreachable can never report that Postgres is unreachable, and one blip would take the site down instead of degrading it.
Server placement
Host in the EU but outside Hungary — Frankfurt or Helsinki. The database holds a 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.
Known gotchas
HUF in Stripe does not follow the plain smallest-unit rule most currencies use.
Verify current Stripe behaviour for forint before writing any money code, and store
every amount as an integer with an explicit documented unit — never a float.
(In the schema goalAmount, price and amount are all Int, forint.)
The Vite manifest is a boot dependency. The Go server reads
web/.vite/manifest.json at startup to find the hashed entry files. build.manifest
must stay on in vite.config.ts; without it the server refuses to boot rather than
serving a document with no script tag.
Share tags are not exercised in development. Vite owns index.html there and
the Go document template only runs in the container, so a broken og: tag will
look fine locally. The CI container smoke test is the only thing that catches
it — that is why it asserts the tags, the PNG magic bytes and the inlined
payload rather than just a 200.
No live database at build time. The image build compiles Go and bundles the frontend; neither touches Postgres. The seed and the migrations are separate binaries run against a live database afterwards.
The seeder is not in the shipping image. It starts by deleting every
campaign, organisation and user, so it lives in api/test/ and is built only by
the seeder Dockerfile target, which --target runner never reaches. CI
asserts the runner image does not carry it.
Intl and Go disagree about spaces. Intl.NumberFormat("hu-HU") groups
digits with U+00A0, so api/internal/hu uses the same character. A plain space
there would make the share card's numbers differ from the page's.
9. Out of scope for MVP
- AI Kampány Varázsló — AI-generated campaign drafts. Post-MVP.
- Post-campaign marketplace — selling the product after the campaign. Not planned.
- Bank transfer payments (phase 2).
- English localization (scaffolded, not translated).
10. Open questions
- Optional "cover the transaction cost" checkbox at checkout — yes or no?
- Category taxonomy — reuse the conventional set (
Kultúra & Művészet, …) or define our own? - Does the placeholder logo stay, or does a designer take it over before launch?
- Who funds the egyesület's running costs, given the 0% fee?
Resolved: name (Szabadpálya), operator form (new egyesület), funding model (keep-what-you-raise + fulfillment guard), fees (0%), payments (Stripe Connect), backer privacy (never public), duration (creator-picked end date, 90-day ceiling), visual system (rendszerbontó arculat, own mark).