405 lines
20 KiB
Markdown
405 lines
20 KiB
Markdown
# 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_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).
|