szabadpalya/SPEC.md

20 KiB
Raw Blame History

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.

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_usage so 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

  1. Header — category chip, title, creator avatar + name, Kedvencekhez adás
  2. Media gallery — carousel, image counter 1/10, video support
  3. Funding bar — Cél: X Ft · Összegyűlt: Y Ft · N% progress · N támogató · N nap van hátra · status badge
  4. Sticky CTA — Beleadom ebbe a projektbe, Megosztás, Megfigyelés
  5. Tabs — Sztori · Frissítések · Hozzászólások
  6. Story — rich content (Összefoglalás, Bemutatás), A csapat bios
  7. Reward tiers — see 4.2
  8. 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 Ft reference 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_at must 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_delivery on physical tiers must be after ends_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.yaml has a migrate service (Dockerfile target migrator, the same image running server -migrate) that runs once and exits; app waits 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

  1. Optional "cover the transaction cost" checkbox at checkout — yes or no?
  2. Category taxonomy — reuse the conventional set (Kultúra & Művészet, …) or define our own?
  3. Does the placeholder logo stay, or does a designer take it over before launch?
  4. 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).