Landing page, waitlist, and research surveys for hulubul.com, a diaspora package transport platform connecting senders and transporters between Moldova and Western Europe.
This repository is one half of the platform. The backend lives at meaningfy-ws/strapi-cloud-template-blog-18c70c3ea8.
| Service | URL | Tier |
|---|---|---|
| Frontend | Hetzner VM (10.0.1.60) via infrastructure-stacks |
Staging |
| Strapi admin | Self-hosted on the same Hetzner VM (Docker, behind Traefik at api.hulubul.com) — not Strapi Cloud |
Staging |
| Auth (SSO) | Zitadel Cloud (free tier) (planned) | Free |
Not Strapi Cloud. Despite the backend repo's name
(strapi-cloud-template-blog-..., just its source template's name), the
Strapi instance actually serving hulubul.com is self-hosted: built from
that repo's source and run as a Docker container (hulubul-strapi) on the
same Hetzner VM as the frontend, alongside its own Postgres container —
see hulubul/docker-compose.yml in infrastructure-stacks for the exact
setup. A separate Strapi Cloud project (*.strapiapp.com) may exist for
local dev/sandboxing, but confirm with whoever set it up before treating it
as authoritative for anything — it is not what's live.
See docs/DEPLOYMENT.md for the full pipeline.
| Technology | Role |
|---|---|
| Next.js 15 (App Router) | Frontend framework, SSR, ISR, Route Handlers |
| TypeScript (strict) | Type safety across data layer + components |
| Zod | Runtime validation of Strapi responses and form payloads |
| React 19 | Server + Client Components |
Vanilla CSS + :root custom properties |
Styling — no framework, mirrors the original design tokens |
| next/font | Fraunces + Inter self-hosted via Google Fonts |
| react-markdown | Renders richtext answers (e.g. FAQ) |
| qs | Builds Strapi's deep populate query strings |
| Vitest + Testing Library + MSW | Unit / component / integration tests |
| Repo | Description |
|---|---|
meaningfy-ws/hulubul-front |
This repo — Next.js frontend, waitlist form, surveys, landing page |
| meaningfy-ws/strapi-cloud-template-blog-18c70c3ea8 | Strapi backend — content types, lifecycles, geocoding |
| Route | Type | Renders |
|---|---|---|
/ |
Static (ISR) | Landing page with 10 sections driven by Strapi's landing-page single type |
/api/waitlist |
Dynamic | POST-only Route Handler; Zod-validates then forwards to waitlist-submission |
/sondaj/expeditori |
Static (ISR) | Sender research survey (25-field form) |
/sondaj |
Redirect | → /sondaj/expeditori (transporter survey lands at /sondaj/transportatori later) |
/api/survey |
Dynamic | POST-only Route Handler; Zod-validates then forwards to survey-response |
/confidentialitate |
Static | Privacy policy (remember-me disclosure, GDPR basics) |
Nav and footer are hoisted into app/layout.tsx and appear on every route.
npm install
npm run dev # copies .env.cloud → .env.local on first run
# open http://localhost:3000.env.cloud holds real values (untracked). The predev script copies it to
.env.local if missing — both are gitignored.
NEXT_PUBLIC_STRAPI_URL # Base URL of the Strapi backend — production is https://api.hulubul.com
# (self-hosted on Hetzner, NOT Strapi Cloud); a *.strapiapp.com URL
# here means you're pointed at a separate Strapi Cloud sandbox, not prod.
STRAPI_API_TOKEN # Server-only token with read + waitlist/survey create permissions
.env.example is the committed template. Never commit real tokens.
| Command | What |
|---|---|
npm run dev |
Dev server on :3000 with HMR |
npm run build |
Production build (tolerates backend downtime — placeholder if Strapi 4xx/5xx) |
npm run start |
Serve the production build |
npm run typecheck |
tsc --noEmit |
npm test |
Vitest (one shot) |
npm run test:watch |
Vitest watch mode |
npm run lint |
next lint |
- App Router, Server Components for data fetching, Client Components for forms.
- Data layer —
lib/strapi.ts+lib/populate.ts+lib/survey.tswrap Strapi 5 REST. Populate tree is declared once and tested for completeness so schema drift surfaces in CI. - Forms — every client form POSTs to a Next.js Route Handler that Zod-validates before forwarding to Strapi with the server-side Bearer token. The browser never sees the token.
- Remember-me — purely client-side (localStorage, opt-in, v2 schema,
365-day TTL). See
design/epic-signup/remember-me.md. - Nav greeting —
<NavCta>reads remember-me after hydration and swaps the "Mă înscriu" button for "Bună, {firstName}" once identity is stored. - CSP + security headers configured in
next.config.ts(strict in prod, relaxed for HMR in dev).
Detailed specifications live in design/ and
docs/specs/:
design/elements.md— full element inventory of the landing pagedesign/EPIC-html-to-strapi-landing-page.md— landing-page reproduction EPICdesign/epic-signup/remember-me.md— remember-me EPIC (shipped)design/epic-signup/login.md— Zitadel auth EPIC (spec only)design/epic-survey/post-waitlist.md— sender survey EPIC (shipped)design/sso-provider-comparison.md— OSS IdP comparison driving the Zitadel choicedesign/testing-strategy.md— TDD rubric for this repodocs/specs/2026-04-23-hulubul-frontend-design.md— overall frontend architecture
Source HTML (for pixel-equivalent reproduction): design/hulubul-landing-v2.html.
npm test # runs all 103 tests, ~1.5 s
Structure follows design/testing-strategy.md:
- Unit — pure modules in
lib/(populate builder, Zod schemas, remember-me). - Integration — fetchers and route handlers with MSW-intercepted Strapi calls.
- Component —
SignupForm,SurveyForm,NavCta,SelectionCriteriaPicker,Faq,SplitTitle. - E2E (Playwright) — deferred until post-launch.
Actual pipeline (push to main → CI → cross-repo dispatch → Hetzner VM via
infrastructure-stacks): see docs/DEPLOYMENT.md.
There is no Vercel deployment despite what an earlier version of this section
said — the notes below about env vars and ISR behavior still apply to
whatever host runs the container.
- Set
NEXT_PUBLIC_STRAPI_URLandSTRAPI_API_TOKENin the hosting environment. - Pages are statically rendered with ISR (
revalidate = 300). Content changes in Strapi propagate within 5 minutes without a redeploy. - If Strapi is unreachable at build time, the build still succeeds — the page renders a "coming soon" placeholder and ISR retries on the next request cycle.
docker build \
--build-arg NEXT_PUBLIC_STRAPI_URL=https://api.hulubul.com \
-t hulubul-front .
docker run -e STRAPI_API_TOKEN=your-token -p 3000:3000 hulubul-frontNEXT_PUBLIC_STRAPI_URL— the self-hosted Strapi URL (production:https://api.hulubul.com, on the same Hetzner VM — not Strapi Cloud), or a local Strapi instance for dev. Baked in at build time for client bundles + ISR.STRAPI_API_TOKEN— runtime-only, used by server-side route handlers.- Build tolerates Strapi downtime — renders a placeholder, ISR retries at runtime.
- Zitadel SSO —
design/epic-signup/login.md. Email + Google + Facebook. - Transporter survey —
/sondaj/transportatori, separate field set focused on driver pains and route operations. - Playwright E2E smoke — one happy-path run for each critical flow.
- i18n — EN/FR after RO is polished.