Extract marketing site to ArnasDon/wacrm-site

Forks of this repo no longer inherit the wacrm.tech landing page
and docs — those move to the dedicated marketing-site repo at
https://github.com/ArnasDon/wacrm-site (deployed to wacrm.tech).

Changes here:
- Replace src/app/page.tsx with a redirect to /dashboard.
  Middleware already handles the unauth → /login bounce, so this
  works for both auth states.
- Drop src/app/{docs,opengraph-image.tsx,robots.ts,sitemap.ts}.
- Drop src/components/{landing,docs,seo} and src/lib/{seo,docs}.
- Drop the top-level docs/ markdown — moved to wacrm-site/docs.
- Slim src/app/layout.tsx metadata down to product chrome
  (no public OG cards, robots: noindex).
- Drop `marked` from dependencies; refresh package-lock.
- Refocus README on self-hosting; point readers at wacrm.tech/docs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-05-18 12:21:13 +03:00
parent a501b95a2b
commit 15a05eb280
No known key found for this signature in database
42 changed files with 29 additions and 3318 deletions

View File

@ -1,4 +1,4 @@
# CRM Template for WhatsApp
# wacrm — CRM Template for WhatsApp
> Self-hostable CRM template for WhatsApp® — shared inbox, contacts,
> sales pipelines, broadcasts, and no-code automations. Fork it, brand
@ -10,6 +10,11 @@
[![Supabase](https://img.shields.io/badge/Supabase-Postgres%20%2B%20Auth-3ecf8e?logo=supabase)](https://supabase.com)
[![Stars](https://img.shields.io/github/stars/ArnasDon/wacrm?style=social)](https://github.com/ArnasDon/wacrm/stargazers)
The marketing site and self-host docs live in a separate repo:
[ArnasDon/wacrm-site](https://github.com/ArnasDon/wacrm-site)
([wacrm.tech](https://wacrm.tech)). This repo is the product —
clone or fork it to run your own CRM.
## What you get out of the box
- **Shared inbox** on the official WhatsApp Business API — multiple
@ -56,27 +61,24 @@ cp .env.local.example .env.local # fill in Supabase + Meta creds
npm run dev
```
Open <http://localhost:3000>.
Full setup — Supabase migrations, WhatsApp Business API config, and
production deploy — is in [`docs/`](./docs/README.md).
Open <http://localhost:3000>. You'll be redirected to `/login` (or
`/dashboard` if already signed in).
## Documentation
**Setup**
- [Getting started](./docs/getting-started.md)
- [Supabase setup](./docs/supabase-setup.md)
- [WhatsApp setup](./docs/whatsapp-setup.md)
- [Environment variables](./docs/environment-variables.md)
Full self-host documentation — Supabase migrations, WhatsApp Business
API config, and production deploy — lives at
**[wacrm.tech/docs](https://wacrm.tech/docs)**
(source: [ArnasDon/wacrm-site](https://github.com/ArnasDon/wacrm-site)).
**Deploy**
- [Deploy on Hostinger](./docs/deployment-hostinger.md)
- [Automations cron](./docs/automations-and-cron.md)
**Reference**
- [Architecture](./docs/architecture.md) — stack, folder layout,
request lifecycle
- [Troubleshooting](./docs/troubleshooting.md)
Key pages:
- [Getting started](https://wacrm.tech/docs/getting-started)
- [Supabase setup](https://wacrm.tech/docs/supabase-setup)
- [WhatsApp setup](https://wacrm.tech/docs/whatsapp-setup)
- [Environment variables](https://wacrm.tech/docs/environment-variables)
- [Deploy on Hostinger](https://wacrm.tech/docs/deployment-hostinger)
- [Architecture](https://wacrm.tech/docs/architecture)
- [Troubleshooting](https://wacrm.tech/docs/troubleshooting)
## Stack

View File

@ -1,54 +0,0 @@
# Documentation
A self-hostable CRM template for WhatsApp® built on Next.js and Supabase.
These docs walk you from a freshly forked repo to a production deploy.
## Reading order
**Setup**
1. **[Getting started](./getting-started.md)** — fork the repo, install, run
locally.
2. **[Supabase setup](./supabase-setup.md)** — create the database, run the
migrations, grab the keys.
3. **[WhatsApp setup](./whatsapp-setup.md)** — create a Meta app, connect a
phone number, wire the webhook.
4. **[Environment variables](./environment-variables.md)** — the full reference.
**Deploy**
5. **[Deploy on Hostinger](./deployment-hostinger.md)** — Managed Node.js
Hosting walkthrough, the recommended way to ship the template.
6. **[Automations cron](./automations-and-cron.md)** — schedule the pending
executions drain so waits and delays fire.
**Reference**
7. **[Architecture](./architecture.md)** — stack, folder layout, request
lifecycle, where to change things.
8. **[Troubleshooting](./troubleshooting.md)** — the usual suspects.
## What you will need before starting
- A GitHub account (to fork the repo).
- A [Supabase](https://supabase.com) project on any paid or free tier.
- A [Meta for Developers](https://developers.facebook.com) account with a
WhatsApp Business app.
- A WhatsApp phone number that is **not** already tied to the regular
WhatsApp or WhatsApp Business mobile apps.
- A [Hostinger Managed Node.js Hosting](https://www.hostinger.com/web-apps-hosting)
plan (or any Node host that runs long-running Node.js 20+ processes).
## Stack at a glance
- **Frontend / API** — Next.js 16 (App Router), React 19, Tailwind v4.
- **Database + auth + storage** — Supabase (Postgres + RLS).
- **WhatsApp transport** — Meta Cloud API (official WhatsApp Business API).
- **Encryption** — AES-256-GCM for per-user WhatsApp access + verify
tokens at rest. Legacy CBC-encrypted rows self-heal on next use.
- **Scheduler** — cron (or any pinger) that hits `GET /api/automations/cron`.
## Getting help
If something is unclear or broken, open an issue against the source repo:
<https://github.com/ArnasDon/wacrm/issues>.

View File

@ -1,155 +0,0 @@
# Architecture
One-page tour of the codebase — so you know where to look when you
want to change something in your fork.
## Stack
| Layer | Tool | Why |
|---|---|---|
| Rendering | **Next.js 16** (App Router) | Server components for data-fetch pages; client components where interactivity's needed. React 19. |
| UI | **Tailwind v4** + **shadcn/base-ui** primitives | Tailwind's zero-runtime styling, shadcn patterns for composable, dark-theme-first components. |
| Data + Auth | **Supabase** | Postgres with Row-Level Security, built-in email/password auth, Storage for avatars, Realtime for the inbox. |
| WhatsApp® | **Meta Cloud API** | Official Business API. No third-party gateway. |
| Encryption | `node:crypto` AES-256-GCM | Per-user WhatsApp access + verify tokens at rest. |
| Scheduler | External HTTP pinger | Hits `GET /api/automations/cron` to drain Wait-step executions. |
No ORM, no GraphQL layer, no dedicated backend. The Next server-side
routes read and write Supabase directly via `@supabase/ssr`.
## Folder layout
```
wacrm/
├─ src/
│ ├─ app/ Next.js App Router routes
│ │ ├─ (auth)/ login, signup, forgot-password
│ │ ├─ (dashboard)/ authenticated UI
│ │ │ ├─ dashboard/ home / metrics / activity feed
│ │ │ ├─ inbox/ shared inbox
│ │ │ ├─ contacts/ contacts + tags
│ │ │ ├─ pipelines/ Kanban deals
│ │ │ ├─ broadcasts/ campaign list + builder
│ │ │ ├─ automations/ flow builder + logs
│ │ │ └─ settings/ Profile / WhatsApp / Templates / Tags
│ │ ├─ docs/ this documentation, rendered
│ │ ├─ api/ JSON endpoints (server-only)
│ │ │ ├─ whatsapp/webhook/ inbound from Meta
│ │ │ ├─ whatsapp/send/ outbound message
│ │ │ ├─ whatsapp/broadcast/ bulk dispatch
│ │ │ ├─ whatsapp/config/ settings read/write
│ │ │ ├─ whatsapp/templates/ template sync
│ │ │ ├─ whatsapp/media/[id]/ media relay
│ │ │ └─ automations/ engine + cron + CRUD
│ │ ├─ page.tsx marketing landing
│ │ ├─ layout.tsx root layout, metadata, icon
│ │ ├─ opengraph-image.tsx dynamic OG image
│ │ └─ icon.tsx dynamic favicon
│ ├─ components/
│ │ ├─ landing/ marketing page sections
│ │ ├─ dashboard/ dashboard widgets
│ │ ├─ inbox/ conversation list, thread, composer
│ │ ├─ contacts/, pipelines/, broadcasts/, automations/, settings/
│ │ ├─ docs/ docs viewer shell
│ │ ├─ seo/ JSON-LD helpers
│ │ ├─ layout/ header, sidebar, shell
│ │ └─ ui/ shadcn/base-ui primitives
│ ├─ hooks/ use-auth, use-realtime, etc.
│ ├─ lib/
│ │ ├─ supabase/ client, server, middleware factories
│ │ ├─ whatsapp/ Meta API client, encryption, phone utils
│ │ ├─ automations/ engine, steps, validation, templates
│ │ ├─ dashboard/ queries + date utils
│ │ ├─ docs/ markdown loader for /docs/
│ │ ├─ seo/ site config, FAQ data, structured data
│ │ └─ rate-limit.ts per-user token bucket
│ ├─ types/ shared TypeScript types
│ └─ middleware.ts session-refresh middleware
├─ supabase/
│ └─ migrations/ idempotent SQL files, run in order
├─ docs/ you are here
├─ public/ static assets
└─ next.config.ts Cache-Control + security headers
```
## Request lifecycle: inbound WhatsApp message
```
Meta Cloud API ──POST──▶ /api/whatsapp/webhook
│
├─ verifyMetaWebhookSignature (HMAC-SHA256)
│ └─ rejects 401 if META_APP_SECRET unset or wrong
│
├─ supabaseAdmin (service-role, lazy init)
│
├─ find contact by phone (create if new)
├─ find or create conversation
├─ insert message row
│
├─ fire runAutomationsForTrigger(...)
│ └─ matches automations on new_message / keyword
│
└─ 200 OK (Meta retries otherwise)
Realtime fan-out:
messages INSERT ──▶ supabase/realtime ──▶ inbox page subscription
└─ appends to thread without refetch
```
## Request lifecycle: outbound message from the inbox
```
Composer ──fetch──▶ /api/whatsapp/send
│
├─ createClient() — user-scoped Supabase client
├─ auth.getUser() — 401 if unauthed
├─ checkRateLimit('send:<uid>', 60/min)
│
├─ select whatsapp_config row (RLS-scoped)
├─ decrypt(access_token)
│ └─ self-heal: upgrade CBC → GCM (fire-and-forget)
│
├─ sendTextMessage / sendTemplateMessage (Meta API)
├─ retry with phone-number variants on "not allowed"
│
├─ insert message row with status = 'sent'
└─ 200 + wamid
UI:
Optimistic temp row shown immediately.
Real row arrives via Supabase realtime → replaces temp.
If 429 / 5xx: temp row flips to status = 'failed'.
```
## Security primitives
- **Row-Level Security** on every table. Users see only their own
rows via `auth.uid() = user_id`. Service-role bypasses RLS; only
server-only modules use it.
- **Encryption** (`src/lib/whatsapp/encryption.ts`) — AES-256-GCM for
WhatsApp tokens at rest. Backward-compatible decrypt for legacy
CBC rows; self-heals on first use.
- **Webhook signatures** (`src/lib/whatsapp/webhook-signature.ts`) —
HMAC-SHA256 with `META_APP_SECRET`. Fail-closed if the env var
isn't set.
- **Cron secret** — `GET /api/automations/cron` requires
`x-cron-secret: <AUTOMATION_CRON_SECRET>`. Constant-ish string
comparison.
- **Rate limiting** (`src/lib/rate-limit.ts`) — per-user fixed window,
process-local `Map`. Swap for Redis/Upstash at horizontal scale.
- **HTTP headers** (`next.config.ts`) — HSTS, nosniff,
X-Frame-Options: DENY, Referrer-Policy, Permissions-Policy, and a
report-only CSP.
## Where to change things
| Want to change… | Start here |
|---|---|
| Marketing copy / landing page | `src/app/page.tsx` + `src/components/landing/*` |
| Dashboard metrics | `src/lib/dashboard/queries.ts` + `src/components/dashboard/*` |
| Inbox behaviour | `src/app/(dashboard)/inbox/page.tsx` + `src/components/inbox/*` |
| Automation triggers / actions | `src/lib/automations/engine.ts`, `steps-tree.ts`, `meta-send.ts` |
| Add a DB column | new migration in `supabase/migrations/NNN_*.sql`, then update the matching `src/types/*.ts` |
| Change auth provider | `src/lib/supabase/{client,server}.ts` + `src/hooks/use-auth.tsx` + `middleware.ts` |
| Add a new API route | `src/app/api/<name>/route.ts`, pattern after existing routes |
| Tweak rate limits | `RATE_LIMITS` in `src/lib/rate-limit.ts` |

View File

@ -1,77 +0,0 @@
# Automations cron
The **Automations** module lets you build flows that react to WhatsApp®
events (new message, new contact, keyword match, schedule, etc.). Most
steps run inline — the exception is the **Wait** step, which parks the
execution in `automation_pending_executions` until its due time.
A cron job has to drain that table. If you skip this, Wait steps never
resume and any flow that uses them stalls.
## The endpoint
```
GET /api/automations/cron
Header: x-cron-secret: <AUTOMATION_CRON_SECRET>
```
- Returns `{ "processed": <n> }` with how many rows were claimed.
- Returns `503` if the env var is not set.
- Returns `401` if the header is missing or wrong.
The route claims up to 50 pending rows per call via a two-step
UPDATE-by-id so overlapping calls don't double-process.
## 1. Generate the secret
Any long random string works. One option:
```bash
openssl rand -hex 32
```
Copy the result into `AUTOMATION_CRON_SECRET` in your deployment env.
## 2. Schedule the pinger
### Option A — hPanel cron (Hostinger Managed Node.js)
hPanel → **Advanced → Cron Jobs** → add a new job set to every minute
(`* * * * *`). Command:
```
curl -s -H "x-cron-secret: <AUTOMATION_CRON_SECRET>" https://crm.example.com/api/automations/cron > /dev/null
```
Paste the literal secret — hPanel cron jobs don't inherit the Node app's
environment variables.
### Option B — external pinger
Any uptime monitor that supports custom headers works:
- [UptimeRobot](https://uptimerobot.com) — "Keyword Monitoring" with
custom HTTP headers.
- [Better Stack](https://betterstack.com).
- GitHub Actions with a `schedule:` trigger.
Hit the URL once per minute. Anything slower just means Wait-step
resolution is that much more delayed.
## 3. Verify
```bash
curl -s -H "x-cron-secret: <secret>" https://crm.example.com/api/automations/cron
# -> {"processed":0}
```
Then create a test automation with a short Wait step (e.g., 30 seconds)
and trigger it. Watch the automation's **Logs** tab — the execution
should resume and mark as completed within one cron interval of the wait
expiring.
## 4. What if you don't use Wait?
Skip this page. Trigger → inline steps → done flows do not need a cron —
they finish synchronously inside the webhook handler or from the UI. The
cron only matters for flows that park execution.

View File

@ -1,156 +0,0 @@
# Deploy on Hostinger
Hostinger's **[Managed Node.js Hosting](https://www.hostinger.com/web-apps-hosting)**
is the recommended way to run this template in production: Hostinger patches the
OS and the Node runtime, handles SSL, and gives you a Git-based deploy
flow from hPanel. You deploy from your fork without ever opening an SSH
session if you don't want to.
This walkthrough assumes you have already completed
[getting-started](./getting-started.md), [supabase-setup](./supabase-setup.md),
and [whatsapp-setup](./whatsapp-setup.md) — i.e., your fork builds locally
and you have your Supabase + Meta credentials ready.
## 1. Buy a Managed Node.js plan
1. Sign up at <https://www.hostinger.com/web-apps-hosting> and choose a
Node.js plan that gives you enough memory for `npm run build` (2 GB+
recommended).
2. During the onboarding wizard, pick the **Node.js** stack and the region
closest to your users.
3. Finish setup until you land in **hPanel**.
## 2. Create the app in hPanel
In hPanel, open the hosting plan and go to **Websites → Create / Manage**
and pick the **Node.js** application option, then:
- **Application name** — `wacrm`.
- **Application root** — accept the default (e.g., `domains/yourdomain/public_html`).
- **Application URL** — the domain or subdomain you want to use (e.g.,
`crm.example.com`). You can attach a domain now or later under
**Domains**.
- **Node.js version** — 20 or newer.
- **Application startup file / command** — Next.js uses `npm start` once
it is built, so set:
- Start command: `npm start`
- Alternatively: `node node_modules/next/dist/bin/next start -p $PORT`
Save the app. hPanel provisions a container and shows you the app's
control page.
## 3. Connect your GitHub fork
In hPanel → **Git**:
1. **Create repository** → paste your fork's HTTPS URL
(e.g., `https://github.com/<your-username>/wacrm.git`).
2. Pick the branch you want to deploy (usually `main`).
3. Set the deploy path to the **Application root** from step 2.
Alternative: if you prefer ZIP uploads, use **File Manager** instead —
upload the repo contents into the application root. Git-based deploy is
simpler because redeploying is one click.
## 4. Install dependencies and build
hPanel's Node.js app page exposes a **Run NPM install** button and a
terminal. Either works:
- **Button flow**:
1. Click **Run NPM install**. Wait for it to finish.
2. Click **Run NPM Build** (or execute `npm run build` from the app
terminal).
- **Terminal flow**:
```bash
npm ci
npm run build
```
> Next.js expects `NEXT_PUBLIC_*` variables to be present **at build
> time**, so set env vars (step 5) **before** running the build.
## 5. Configure environment variables
In hPanel → **Node.js app → Environment variables**, add every value from
[environment-variables.md](./environment-variables.md):
- `NEXT_PUBLIC_SUPABASE_URL`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- `SUPABASE_SERVICE_ROLE_KEY`
- `ENCRYPTION_KEY`
- `META_APP_SECRET`
- `NEXT_PUBLIC_SITE_URL` — set to `https://<your-domain>` (with the
scheme, without a trailing slash).
- `AUTOMATION_CRON_SECRET` — if you plan to use Wait steps
([automations-and-cron.md](./automations-and-cron.md)).
Save, then **re-run the build** so the new `NEXT_PUBLIC_*` values get
baked into the client bundle.
## 6. Start the app
From the app page, click **Restart application** (or run the equivalent
from the terminal). hPanel boots `npm start` behind its own reverse proxy
on the domain you configured. Hit the URL in a browser — you should land
on the marketing page.
SSL is provisioned automatically. If you used a subdomain, Hostinger's
AutoSSL usually takes a minute or two; until then the site may serve the
plain-HTTP version.
## 7. Update the Meta webhook
Back in **Meta for Developers → WhatsApp® → Configuration**, change the
callback URL to `https://<your-domain>/api/whatsapp/webhook` and re-verify.
## 8. Schedule the automations cron
If you use the Wait step in any automation, schedule the cron drain.
- **Inside hPanel** — open **Advanced → Cron Jobs** and add:
```
* * * * * curl -s -H "x-cron-secret: <AUTOMATION_CRON_SECRET>" https://<your-domain>/api/automations/cron > /dev/null
```
Paste the literal secret here (cron jobs in hPanel don't read app env
vars) or store it in a file the cron reads.
- **Outside** — any uptime monitor (UptimeRobot, Better Stack, GitHub
Actions) can hit the URL once a minute. See
[automations-and-cron.md](./automations-and-cron.md) for detail.
## 9. Deploying updates
Two options:
- **Click-deploy**: push to your fork, then hit **Pull** in hPanel → Git
and **Restart application**.
- **Terminal**:
```bash
cd <application-root>
git pull
npm ci
npm run build
# then restart from the Node.js app page
```
If the database schema changed, apply any new SQL files from
`supabase/migrations/` in the Supabase SQL editor first — migrations are
idempotent.
## When to reach for a VPS instead
Managed Node.js covers most deploys of this template. Consider
[Hostinger VPS](https://www.hostinger.com/vps-hosting) if you need:
- Long-running background workers beyond what the single Next.js process
gives you.
- System-level cron behaviour you can't replicate from hPanel.
- Custom binaries (e.g., ffmpeg) for media transforms.
Otherwise, Managed Node.js is the fast path.
## Where to go next
- [Automations cron →](./automations-and-cron.md) — must-do if you use
the Wait step in any automation.
- [Troubleshooting →](./troubleshooting.md) — common deploy issues.

View File

@ -1,63 +0,0 @@
# Environment variables
All runtime configuration lives in `.env.local` during development and in
your host's environment settings in production. `.env.local.example` is a
minimal template; the table below is the full reference.
## Required
| Variable | Where to find it | Notes |
| --------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase → Project Settings → API → **Project URL** | Public. Shipped to the browser. |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase → Project Settings → API → **anon / public** key | Public. Relies on RLS for safety. |
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase → Project Settings → API → **service_role** key | **Secret.** Bypasses RLS. Used by webhook + admin routes only. |
| `ENCRYPTION_KEY` | Generate: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` | 64 hex chars (32 bytes, AES-256-CBC). **Rotating breaks existing tokens.** |
| `META_APP_SECRET` | Meta → App Settings → Basic → **App Secret** | Verifies the `X-Hub-Signature-256` HMAC on every inbound webhook. **Without it the webhook rejects every request** — a public deploy cannot receive messages until this is set. |
## Recommended
| Variable | Purpose |
| ---------------------- | ---------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_SITE_URL` | Canonical public URL (e.g., `https://crm.example.com`). Used for absolute URLs, sitemap, OG images. |
## Optional
| Variable | Purpose |
| --------------------------- | -------------------------------------------------------------------------------------------------------- |
| `AUTOMATION_CRON_SECRET` | Shared secret that protects `GET /api/automations/cron`. Required if you schedule the automations drain. See [automations-and-cron.md](./automations-and-cron.md). |
## Sample `.env.local`
```bash
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://abcd1234.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOi...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...
# Meta App Secret — required for webhook signature verification
META_APP_SECRET=abcdef0123456789...
# Encryption — DO NOT change after first deploy
ENCRYPTION_KEY=3f9c0a7e4d8b2f1a6c5e8d4b9f0a2c6e8d4b9f0a2c6e8d4b9f0a2c6e8d4b9f0a
# Public URL
NEXT_PUBLIC_SITE_URL=https://crm.example.com
# Automation cron
AUTOMATION_CRON_SECRET=generate-a-long-random-string
```
## Security checklist
- Never commit `.env.local`. The repo already ignores it.
- On Hostinger Managed Node.js (and any other host), set env vars via the
platform's **Environment variables** panel rather than writing them
into a tracked file on disk.
- Rotate `SUPABASE_SERVICE_ROLE_KEY` if it leaks — Supabase lets you
regenerate it under Project Settings → API.
- Treat `ENCRYPTION_KEY` like a database master key. Losing it means
connected WhatsApp® accounts must reconnect; rotating it means the same.
## Next step
[Deploy on Hostinger →](./deployment-hostinger.md)

View File

@ -1,79 +0,0 @@
# Getting started
Run the template locally in about 15 minutes. This guide assumes Node.js 20+ and
npm are already installed.
## 1. Fork and clone
1. Go to <https://github.com/ArnasDon/wacrm> and click **Fork** (top right).
2. Clone your fork:
```bash
git clone https://github.com/<your-username>/wacrm.git
cd wacrm
```
## 2. Install dependencies
```bash
npm install
```
## 3. Create the environment file
Copy the template and fill in the values (full reference in
[environment-variables.md](./environment-variables.md)):
```bash
cp .env.local.example .env.local
```
You cannot run `npm run dev` until **at least** `NEXT_PUBLIC_SUPABASE_URL`
and `NEXT_PUBLIC_SUPABASE_ANON_KEY` are set. You need the full Supabase
setup before WhatsApp® features will work, so do that next.
## 4. Set up Supabase
Follow [supabase-setup.md](./supabase-setup.md) to create the project and
run the migrations. Come back here when the URL, anon key, and service-role
key are populated in `.env.local`.
## 5. Generate the encryption key
Access tokens for WhatsApp are encrypted at rest with AES-256-CBC. Generate
a 64-character hex key:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
Paste the output into `ENCRYPTION_KEY` in `.env.local`. **Do not change
this value later** — existing tokens are encrypted with whatever value was
set at the time, and rotating it will make them unreadable.
## 6. Run the dev server
```bash
npm run dev
```
Open <http://localhost:3000>. You should see the marketing landing page.
- Create an account at `/signup`.
- After sign-in you land on `/dashboard`.
- The inbox, contacts, pipelines, automations, and broadcasts modules are
all empty until you connect a WhatsApp number
([whatsapp-setup.md](./whatsapp-setup.md)).
## 7. Commands cheat sheet
| Command | Purpose |
| ---------------- | ---------------------------------------------- |
| `npm run dev` | Dev server with Turbopack HMR on `:3000`. |
| `npm run build` | Production build. |
| `npm start` | Run the production build. |
| `npm run lint` | ESLint across the project. |
## Next step
[Supabase setup →](./supabase-setup.md)

View File

@ -1,83 +0,0 @@
# Supabase setup
This template uses Supabase for Postgres, authentication, row-level security (RLS),
and (optionally) storage. You need one Supabase project per deployment.
## 1. Create the project
1. Sign in at <https://supabase.com> and create a **new project**.
2. Pick the region closest to your users. Save the database password shown
at creation time — you will not see it again.
3. Wait for the project to provision (about a minute).
## 2. Grab your keys
Open **Project Settings → API** in the Supabase dashboard. Copy:
- **Project URL** → `NEXT_PUBLIC_SUPABASE_URL`
- **anon / public** key → `NEXT_PUBLIC_SUPABASE_ANON_KEY`
- **service_role** key → `SUPABASE_SERVICE_ROLE_KEY`
Paste them into `.env.local`.
> **Do not commit the service-role key.** It bypasses RLS entirely. The
> repo's `.gitignore` already excludes `.env.local` — keep it that way.
## 3. Run the migrations
All schema changes live in `supabase/migrations/` as plain SQL and are
idempotent (safe to re-run). The simplest way to apply them:
### Option A — SQL Editor (quickest)
1. Open **SQL Editor** in the Supabase dashboard.
2. For each file in `supabase/migrations/`, in numeric order:
- `001_initial_schema.sql`
- `002_pipelines_enhancements.sql`
- `003_broadcast_recipient_wamid.sql`
- `004_contact_delete_set_null.sql`
- `005_broadcast_counts_incremental.sql`
- `006_automations.sql`
- `007_automations_increment_counter.sql`
3. Paste into the editor and run. Each file prints its own status.
### Option B — Supabase CLI
```bash
npm install --global supabase
supabase login
supabase link --project-ref <your-project-ref>
supabase db push
```
The CLI applies every file in `supabase/migrations/` on top of the linked
project.
## 4. Verify
Open **Table Editor** in the Supabase dashboard. You should see tables
including `profiles`, `contacts`, `conversations`, `messages`, `pipelines`,
`broadcasts`, `automations`, and `whatsapp_config`. If any are missing, a
migration failed — re-run it and check the SQL output.
## 5. Auth settings
Under **Authentication → Providers**, confirm:
- **Email** is enabled (default).
- **Confirm email** is on for production, off for local if you want
frictionless testing.
Under **Authentication → URL Configuration**, add your production URL
(e.g., `https://crm.example.com`) to the allow-list so password-reset
emails link back correctly.
## 6. (Optional) Storage
The app downloads WhatsApp® media through Meta's `/download` endpoint and
currently relays it on demand rather than caching it in Supabase Storage.
No bucket setup is required for a default install.
## Next step
[WhatsApp setup →](./whatsapp-setup.md)

View File

@ -1,129 +0,0 @@
# Troubleshooting
The greatest hits. If your problem is not here, open an issue at
<https://github.com/ArnasDon/wacrm/issues>.
## Build-time
### `Error: ENCRYPTION_KEY must be 64 hex chars`
`ENCRYPTION_KEY` is missing or not the right length. Generate one:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
Put the output into `.env.local` / your production env.
### `Error: supabaseUrl is required`
`NEXT_PUBLIC_SUPABASE_URL` or `NEXT_PUBLIC_SUPABASE_ANON_KEY` is not set
at build time. On Hostinger / any Node host, make sure the env vars are
exported **before** `npm run build` runs.
### Next.js complains about missing framework docs
The repo's `AGENTS.md` pins contributors to the docs bundled in
`node_modules/next/dist/docs/` — that notice is for humans editing the
code, it does not affect your build.
## Auth
### Sign-up succeeds but the confirmation email never arrives
In Supabase → **Authentication → Providers → Email**, either:
- Configure a custom SMTP provider (recommended for production), or
- Temporarily turn off **Confirm email** while testing.
### Password-reset links point at `localhost:3000` in production
Set `NEXT_PUBLIC_SITE_URL` **and** add the same origin to Supabase →
**Authentication → URL Configuration → Redirect URLs**.
## WhatsApp®
### Webhook verification fails in Meta
- The **verify token** in Meta must match exactly what you saved in
**Settings → WhatsApp** inside the app.
- The callback URL must be reachable without auth. Test it yourself:
`curl 'https://crm.example.com/api/whatsapp/webhook?hub.mode=subscribe&hub.verify_token=<token>&hub.challenge=test'`
should return `test`.
- `META_APP_SECRET` must be set on the deployment (it's required —
without it, every inbound POST gets a 401). If it's set but wrong,
you'll also see 401s for every POST while the initial subscribe
handshake (which doesn't use it) works. Double-check the value
matches Meta → App Settings → Basic → App Secret exactly.
### Messages go out but nothing comes in
1. Confirm the webhook is **Subscribed** to `messages` under Meta →
WhatsApp → Configuration.
2. Confirm the phone number is listed under **Recipients** during testing
(test-number apps only deliver to whitelisted testers).
3. Check server logs — inbound webhooks hit
`POST /api/whatsapp/webhook`. A 200 with no message row means the
payload parsed successfully but didn't match any known contact's
config — double-check `phone_number_id`.
### `Token decryption failed`
`ENCRYPTION_KEY` changed since the token was saved. Reconnect the
WhatsApp account from **Settings → WhatsApp** to re-encrypt with the
current key.
## Automations
### Wait steps never resume
The cron drain is not running. See
[automations-and-cron.md](./automations-and-cron.md).
Quick sanity check from your machine:
```bash
curl -s -H "x-cron-secret: <secret>" https://crm.example.com/api/automations/cron
```
If that returns `{"processed":N}` with N growing when a wait is due, the
endpoint is fine and the problem is just the schedule.
### An automation fires twice for the same message
Most likely two app instances share one Supabase project and both
receive the same webhook. The webhook handler deduplicates by Meta's
`wamid`, but only within a single deploy — split-brain setups can double
up. Either consolidate to one deploy or route the webhook to exactly one.
## Deploy
### `502 Bad Gateway` or "Application not running" from Hostinger
- Open the Node.js app page in hPanel and confirm the status is **Started**.
- Check the app log viewer for a crash on boot — almost always a missing
env var. Re-save your env vars, re-run the build, and restart.
- Confirm the **Application startup command** is `npm start` (or
equivalent) and that a build has actually run (`.next/` exists in the
application root).
### Env var changes don't take effect in the browser
`NEXT_PUBLIC_*` values are baked into the client bundle at build time.
After changing any of them, re-run `npm run build` (hPanel's **Run NPM
Build** button works) and restart the app.
### Meta webhook suddenly stops after a domain change
Hostinger provisions a new SSL certificate when you attach a new domain
or subdomain. Until it is issued, the webhook URL serves plain HTTP and
Meta rejects it. Wait for AutoSSL to complete (minutes), then re-verify
the webhook in Meta.
## Still stuck?
Open an issue with:
- Environment (local / Hostinger Managed Node.js / other).
- The exact error from the app log viewer in hPanel or Next's dev output.
- What you were trying to do when it happened.

View File

@ -1,106 +0,0 @@
# WhatsApp setup
This template talks to the official **WhatsApp® Business Cloud API** (Meta). Each
account stores its own `phone_number_id`, `waba_id`, and encrypted
`access_token` — there is no shared app-level token. This page walks
through getting those values and wiring the webhook.
## 1. Prerequisites
- A [Meta for Developers](https://developers.facebook.com) account.
- A Meta **Business Manager** account.
- A phone number that is **not** already in use on the regular WhatsApp or
WhatsApp Business mobile apps. Landlines work; the number must be able
to receive an SMS or voice call during verification.
## 2. Create the Meta app
1. In Meta for Developers, go to **My Apps → Create App**.
2. Pick the **Business** app type.
3. On the dashboard, under **Add products**, click **Set up** on
**WhatsApp**.
4. Connect your Business Manager and accept the terms.
Meta gives you a **test number** for free. You can use it for local
testing, but for production you need to add your own number under
**WhatsApp → API Setup → From**.
## 3. Collect the IDs and the token
On **WhatsApp → API Setup** you will see:
| Value | Goes into |
| --------------------- | ------------------------------------------------ |
| Phone number ID | `phone_number_id` (entered in Settings → Connect) |
| WhatsApp Business ID | `waba_id` (optional but recommended) |
| Temporary access token | `access_token` (short-lived — see next step) |
The temporary token expires after 24 hours. For production you must
generate a **System User** access token:
1. **Business Settings → Users → System users → Add**.
2. Name it `wacrm-system-user`, role **Admin**.
3. Click **Generate new token**, pick the app, and grant:
- `whatsapp_business_management`
- `whatsapp_business_messaging`
4. Copy the token. **This is shown only once.**
## 4. Connect it inside the app
1. Run the app locally (or open your deployed instance).
2. Sign in, then go to **Settings → WhatsApp**.
3. Fill in:
- **Phone number ID** — from step 3.
- **WhatsApp Business ID** — optional.
- **Access token** — the System User token from step 3.
- **Verify token** — any random string you make up; you will paste the
same value into Meta in the next step.
4. Save. The app encrypts the access token and verify token with your
`ENCRYPTION_KEY` before writing them to Supabase.
## 5. Configure the webhook in Meta
Under **WhatsApp → Configuration → Webhook**:
- **Callback URL**:
- Local dev: run `npx ngrok http 3000` (or similar) and paste
`https://<your-subdomain>.ngrok.app/api/whatsapp/webhook`.
- Production: `https://<your-domain>/api/whatsapp/webhook`.
- **Verify token**: the same string you entered in step 4.
- Click **Verify and save**. Meta sends a GET with
`hub.challenge` — the app's webhook route decrypts stored verify tokens
and echoes the challenge back. If verification fails, double-check the
verify token matches and the callback URL is publicly reachable.
### Subscribe to events
Under the webhook config, **Subscribe to** at minimum:
- `messages`
- `message_template_status_update`
`messages` delivers inbound messages and delivery / read statuses;
`message_template_status_update` keeps Meta-approved template statuses in
sync on the broadcast UI.
## 6. Signature verification (required)
Set `META_APP_SECRET` to your app's **App Secret** (Meta for Developers →
App Settings → Basic). The app validates the `X-Hub-Signature-256` HMAC
on every inbound webhook and **rejects every request if the env var is
not set** — anyone who knew the webhook URL could otherwise spoof
messages and status updates. Configure it before pointing the webhook
at a deployed instance.
## 7. Send a test message
1. From Meta's **API Setup** page, send a test message to a number that is
a registered tester.
2. Watch the Inbox — the message should appear within a second or two.
3. Reply from the app; the recipient gets a real WhatsApp message.
If nothing shows up, see [troubleshooting.md](./troubleshooting.md).
## Next step
[Environment variables →](./environment-variables.md)

13
package-lock.json generated
View File

@ -19,7 +19,6 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^1.8.0",
"marked": "^18.0.2",
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4",
@ -7078,18 +7077,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "18.0.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.2.tgz",
"integrity": "sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@ -5,7 +5,7 @@
"description": "Self-hostable CRM template for WhatsApp built on Next.js and Supabase — shared inbox, contacts, sales pipelines, broadcasts, and no-code automations.",
"license": "MIT",
"author": "Arnas Donauskas",
"homepage": "https://wacrm.tech",
"homepage": "https://github.com/ArnasDon/wacrm",
"repository": {
"type": "git",
"url": "git+https://github.com/ArnasDon/wacrm.git"
@ -47,7 +47,6 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^1.8.0",
"marked": "^18.0.2",
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4",

View File

@ -1,87 +0,0 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { listDocs, loadDoc } from '@/lib/docs/content'
interface DocPageProps {
params: Promise<{ slug: string }>
}
export async function generateStaticParams() {
const pages = await listDocs()
return pages.map((p) => ({ slug: p.slug }))
}
export async function generateMetadata({
params,
}: DocPageProps): Promise<Metadata> {
const { slug } = await params
const doc = await loadDoc(slug)
if (!doc) return {}
return {
title: doc.title,
description: doc.description,
}
}
export default async function DocPage({ params }: DocPageProps) {
const { slug } = await params
const doc = await loadDoc(slug)
if (!doc) notFound()
const pages = await listDocs()
const idx = pages.findIndex((p) => p.slug === slug)
const prev = idx > 0 ? pages[idx - 1] : null
const next = idx >= 0 && idx < pages.length - 1 ? pages[idx + 1] : null
return (
<article className="mx-auto w-full max-w-3xl">
<p className="text-xs font-semibold uppercase tracking-wider text-violet-400">
{doc.section}
</p>
<div
className="doc-prose mt-2"
dangerouslySetInnerHTML={{ __html: doc.html }}
/>
<nav
aria-label="Pagination"
className="mt-16 grid grid-cols-1 gap-3 border-t border-slate-800 pt-8 sm:grid-cols-2"
>
{prev ? (
<Link
href={`/docs/${prev.slug}`}
className="group flex flex-col gap-1 rounded-xl border border-slate-800 bg-slate-900/40 px-5 py-4 transition-colors hover:border-slate-700 hover:bg-slate-900/70"
>
<span className="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-slate-500">
<ArrowLeft className="h-3.5 w-3.5" />
Previous
</span>
<span className="text-sm font-semibold text-white">
{prev.title}
</span>
</Link>
) : (
<span />
)}
{next ? (
<Link
href={`/docs/${next.slug}`}
className="group flex flex-col items-end gap-1 rounded-xl border border-slate-800 bg-slate-900/40 px-5 py-4 text-right transition-colors hover:border-slate-700 hover:bg-slate-900/70"
>
<span className="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-slate-500">
Next
<ArrowRight className="h-3.5 w-3.5" />
</span>
<span className="text-sm font-semibold text-white">
{next.title}
</span>
</Link>
) : (
<span />
)}
</nav>
</article>
)
}

View File

@ -1,21 +0,0 @@
import type { Metadata } from 'next'
import { DocsShell } from '@/components/docs/shell'
import { listDocs } from '@/lib/docs/content'
export const metadata: Metadata = {
title: {
template: '%s — CRM Template for WhatsApp Docs',
default: 'CRM Template for WhatsApp Docs',
},
description:
'Setup, configuration, and deployment docs for the CRM Template for WhatsApp.',
}
export default async function DocsLayout({
children,
}: {
children: React.ReactNode
}) {
const pages = await listDocs()
return <DocsShell pages={pages}>{children}</DocsShell>
}

View File

@ -1,60 +0,0 @@
import Link from 'next/link'
import { ArrowRight } from 'lucide-react'
import { listDocs } from '@/lib/docs/content'
export default async function DocsIndexPage() {
const pages = await listDocs()
const sections: Record<string, typeof pages> = {}
for (const p of pages) {
sections[p.section] ??= []
sections[p.section].push(p)
}
return (
<article className="mx-auto w-full max-w-3xl">
<p className="text-xs font-semibold uppercase tracking-wider text-violet-400">
Documentation
</p>
<h1 className="mt-2 text-3xl font-bold tracking-tight text-white sm:text-4xl">
Self-host the CRM template for WhatsApp end to end
</h1>
<p className="mt-4 text-base leading-relaxed text-slate-400">
Everything you need to take the template from a fresh fork to a
production deploy. Work through the pages in order, or jump to the
one you need.
</p>
<div className="mt-10 flex flex-col gap-10">
{Object.entries(sections).map(([section, items]) => (
<section key={section}>
<h2 className="text-xs font-semibold uppercase tracking-wider text-slate-500">
{section}
</h2>
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
{items.map((p) => (
<Link
key={p.slug}
href={`/docs/${p.slug}`}
className="group flex flex-col rounded-xl border border-slate-800 bg-slate-900/40 p-5 transition-colors hover:border-slate-700 hover:bg-slate-900/70"
>
<h3 className="text-sm font-semibold text-white">
{p.title}
</h3>
{p.description && (
<p className="mt-1.5 text-sm leading-relaxed text-slate-400">
{p.description}
</p>
)}
<span className="mt-4 inline-flex items-center gap-1.5 text-xs font-medium text-violet-400 transition-colors group-hover:text-violet-300">
Read
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
</span>
</Link>
))}
</div>
</section>
))}
</div>
</article>
)
}

View File

@ -2,71 +2,21 @@ import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import { Toaster } from "sonner";
import "./globals.css";
import {
OG_IMAGE_ALT,
SITE_DESCRIPTION,
SITE_KEYWORDS,
SITE_NAME,
SITE_TAGLINE,
SITE_URL,
} from "@/lib/seo/site-config";
const inter = Inter({
variable: "--font-sans",
subsets: ["latin"],
});
// Consolidated site-wide metadata. Page-specific metadata (landing,
// auth pages, etc.) override the narrower fields (title, description,
// robots); defaults defined here are inherited everywhere else so we
// never ship a page without OpenGraph / Twitter coverage.
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: `${SITE_NAME} — ${SITE_TAGLINE}`,
template: `%s — ${SITE_NAME}`,
},
description: SITE_DESCRIPTION,
keywords: SITE_KEYWORDS,
applicationName: SITE_NAME,
authors: [{ name: SITE_NAME, url: SITE_URL }],
creator: SITE_NAME,
publisher: SITE_NAME,
category: "Business Software",
alternates: {
canonical: "/",
},
openGraph: {
type: "website",
siteName: SITE_NAME,
title: `${SITE_NAME} — ${SITE_TAGLINE}`,
description: SITE_DESCRIPTION,
url: SITE_URL,
locale: "en_US",
images: [
{
url: "/opengraph-image",
width: 1200,
height: 630,
alt: OG_IMAGE_ALT,
},
],
},
twitter: {
card: "summary_large_image",
title: `${SITE_NAME} — ${SITE_TAGLINE}`,
description: SITE_DESCRIPTION,
images: ["/opengraph-image"],
default: "wacrm",
template: "%s — wacrm",
},
description: "Self-hostable CRM template for WhatsApp.",
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
},
index: false,
follow: false,
},
icons: {
icon: [{ url: "/icon" }],
@ -78,7 +28,6 @@ export const metadata: Metadata = {
},
};
// Dark theme color for the mobile browser chrome.
export const viewport: Viewport = {
themeColor: "#020617",
colorScheme: "dark",

View File

@ -1,118 +0,0 @@
import { ImageResponse } from 'next/og'
import { SITE_NAME, SITE_TAGLINE } from '@/lib/seo/site-config'
/**
* Auto-generated OG image served at /opengraph-image.
* Next renders this JSX to a PNG at build time, caches it, and wires
* the URL into the default `<meta property="og:image">` for every
* page that inherits the root metadata.
*
* Facebook / LinkedIn / Slack / Twitter all fetch this when the link
* is shared, so keep it readable at thumbnail sizes (no tiny text,
* high contrast).
*/
export const alt = `${SITE_NAME} — ${SITE_TAGLINE}`
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image() {
// Satori (the renderer behind next/og) is strict about two things
// that the first version of this file violated at BUILD time —
// which meant `next build` failed on /opengraph-image during
// static generation and the whole production build blew up.
//
// 1. Every <div> with more than one child must explicitly declare
// display: flex / contents / none.
// 2. Any Unicode glyph outside the default font's coverage fails
// with "Failed to download dynamic font". ✓ was the worst
// offender — replaced below with plain text dot separators.
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: 80,
background:
'linear-gradient(135deg, #020617 0%, #0f172a 55%, #2e1065 100%)',
color: 'white',
fontFamily: 'sans-serif',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 18 }}>
<div
style={{
width: 64,
height: 64,
borderRadius: 14,
background: '#7c3aed',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: 34,
fontWeight: 700,
}}
>
W
</div>
<div style={{ display: 'flex', fontSize: 36, fontWeight: 700, letterSpacing: -0.5 }}>
{SITE_NAME}
</div>
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
marginTop: 60,
fontSize: 84,
fontWeight: 800,
lineHeight: 1.02,
letterSpacing: -2,
maxWidth: 1000,
}}
>
<span>Run your WhatsApp® business from&nbsp;</span>
<span style={{ color: '#a78bfa' }}>one inbox.</span>
</div>
<div
style={{
display: 'flex',
marginTop: 28,
fontSize: 28,
color: '#94a3b8',
maxWidth: 900,
lineHeight: 1.3,
}}
>
Shared inbox, sales pipelines, broadcasts, and no-code automations — built on the official WhatsApp Business API.
</div>
<div
style={{
marginTop: 56,
display: 'flex',
gap: 24,
fontSize: 22,
color: '#cbd5e1',
}}
>
<span>Shared inbox</span>
<span>·</span>
<span>Automations</span>
<span>·</span>
<span>Pipelines</span>
<span>·</span>
<span>Analytics</span>
</div>
</div>
),
size,
)
}

View File

@ -1,118 +1,5 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { LandingNav } from '@/components/landing/nav'
import { Hero } from '@/components/landing/hero'
import { FeaturesGrid } from '@/components/landing/features-grid'
import { HowItWorks } from '@/components/landing/how-it-works'
import { FeatureSpotlight } from '@/components/landing/feature-spotlight'
import { FAQ } from '@/components/landing/faq'
import { OpenSource } from '@/components/landing/open-source'
import { CtaBanner } from '@/components/landing/cta-banner'
import { Footer } from '@/components/landing/footer'
import { InboxMock } from '@/components/landing/mock/inbox-mock'
import { PipelineMock } from '@/components/landing/mock/pipeline-mock'
import { AutomationMock } from '@/components/landing/mock/automation-mock'
import { AnalyticsMock } from '@/components/landing/mock/analytics-mock'
import { JsonLd } from '@/components/seo/json-ld'
import { landingPageLd } from '@/lib/seo/structured-data'
import { SITE_DESCRIPTION, SITE_NAME, SITE_TAGLINE } from '@/lib/seo/site-config'
// Landing-specific metadata. Most fields are inherited from the root
// layout's metadata — we override title (so the hero copy shows in
// SERPs / tab titles) and set an explicit canonical for "/" to avoid
// trailing-slash duplicate-content signals.
export const metadata: Metadata = {
title: {
absolute: `${SITE_NAME} — ${SITE_TAGLINE}`,
},
description: SITE_DESCRIPTION,
alternates: {
canonical: '/',
},
}
// Marketing landing. Visible at / for everyone (auth redirect that
// used to send anonymous visitors to /login was removed). Authed users
// still see this page; the nav swaps its CTA to "Go to Dashboard".
export default function LandingPage() {
return (
<div className="bg-slate-950 text-slate-100">
{/* JSON-LD — WebSite, Organization, SoftwareApplication, FAQPage.
Emitted before any visible content so crawlers hit it first. */}
<JsonLd data={landingPageLd()} />
<LandingNav />
<main>
<Hero />
<FeaturesGrid />
<FeatureSpotlight
anchorId="inbox"
eyebrow="Shared inbox"
title="Never drop a WhatsApp conversation again"
body="Your whole team works from one inbox. Conversations can be assigned, tagged, and handed off without losing context. Real-time updates so two agents never reply to the same thread at the same time."
bullets={[
'Assign threads to specific agents or round-robin across the team',
'Internal notes that only your team sees',
'Unread indicators so urgent replies never slip through',
'Deep-link into any conversation from the dashboard',
]}
visual={<InboxMock />}
/>
<HowItWorks />
<FeatureSpotlight
anchorId="automations"
eyebrow="No-code automations"
title="Automate the repetitive, focus on the humans"
body="Build flows that react to WhatsApp events: welcome new contacts, chase unanswered replies, route leads by keyword. Conditions, waits, tags, deals — all with a visual builder that feels like Figma for workflows."
bullets={[
'Triggers for new messages, new contacts, tag changes, keywords, schedules',
'Actions: send message / template, add tag, create deal, webhook, and more',
'Conditional branches and wait steps for human-time delays',
'Per-run logs so you always know what ran and why',
]}
reverse
visual={<AutomationMock />}
/>
<FeatureSpotlight
anchorId="pipelines"
eyebrow="Sales pipelines"
title="Turn conversations into revenue"
body="Drag deals through custom stages, link them to contacts, and see exactly where revenue is getting stuck. Every deal keeps its WhatsApp thread one click away — so context never gets lost on a handoff."
bullets={[
'Unlimited pipelines and stages',
'Kanban board with drag-and-drop',
'Deal value totals per stage and pipeline-wide',
'Linked contacts, conversations, and notes per deal',
]}
visual={<PipelineMock />}
/>
<FeatureSpotlight
anchorId="analytics"
eyebrow="Real-time analytics"
title="See what is actually working"
body="Response times, daily volume, pipeline value, and a cross-module activity feed. The dashboard tells you where attention is needed without you building a single chart."
bullets={[
'Active conversations, new contacts, open deal value — live',
'Conversations over time for 7, 30, or 90 days',
'Average first-response time by weekday against your target',
'Activity feed merged across messages, deals, broadcasts, automations',
]}
reverse
visual={<AnalyticsMock />}
/>
<OpenSource />
<FAQ />
<CtaBanner />
</main>
<Footer />
</div>
)
export default function RootPage() {
redirect('/dashboard')
}

View File

@ -1,35 +0,0 @@
import type { MetadataRoute } from 'next'
import { SITE_URL } from '@/lib/seo/site-config'
/**
* robots.txt served at /robots.txt. We explicitly disallow every
* gated area so crawlers don't spend their budget on pages they'll
* bounce off. Auth pages are closed off too — no SEO value, and
* we don't want a "Sign in" page competing with the landing page
* in SERPs.
*/
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: [
'/api/',
'/dashboard',
'/inbox',
'/contacts',
'/pipelines',
'/broadcasts',
'/automations',
'/settings',
'/login',
'/signup',
'/forgot-password',
],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
}
}

View File

@ -1,20 +0,0 @@
import type { MetadataRoute } from 'next'
import { SITE_URL } from '@/lib/seo/site-config'
/**
* Sitemap served at /sitemap.xml. Only lists public, indexable pages
* — auth pages are explicitly noindex, and every route under /app is
* gated. The landing anchors (#features, #how-it-works, #faq) don't
* belong here; fragments aren't a sitemap concept.
*/
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date()
return [
{
url: `${SITE_URL}/`,
lastModified: now,
changeFrequency: 'weekly',
priority: 1,
},
]
}

View File

@ -1,108 +0,0 @@
"use client"
import { useState, type ReactNode } from 'react'
import Link from 'next/link'
import { Menu, X } from 'lucide-react'
import { DocsSidebar } from './sidebar'
import { GithubIcon } from '@/components/landing/github-icon'
import { MessageSquare } from 'lucide-react'
import type { DocPage } from '@/lib/docs/content'
const REPO_URL = 'https://github.com/ArnasDon/wacrm'
interface DocsShellProps {
pages: DocPage[]
children: ReactNode
}
export function DocsShell({ pages, children }: DocsShellProps) {
const [mobileOpen, setMobileOpen] = useState(false)
const closeMobile = () => setMobileOpen(false)
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
<header className="sticky top-0 z-40 border-b border-slate-800 bg-slate-950/80 backdrop-blur">
<div className="mx-auto flex h-16 w-full max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
<div className="flex items-center gap-2">
<button
type="button"
className="flex h-9 w-9 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-slate-800 hover:text-white lg:hidden"
aria-label={mobileOpen ? 'Close docs menu' : 'Open docs menu'}
onClick={() => setMobileOpen((v) => !v)}
>
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
<Link
href="/"
className="flex items-center gap-2"
aria-label="CRM Template for WhatsApp home"
>
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500">
<MessageSquare className="h-4 w-4 text-white" />
</span>
<span className="text-sm font-semibold text-white">
CRM Template for WhatsApp
</span>
<span className="hidden rounded-md border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-slate-400 sm:inline-block">
Docs
</span>
</Link>
</div>
<div className="flex items-center gap-1.5">
<Link
href="/"
className="hidden rounded-lg px-3 py-1.5 text-sm text-slate-300 transition-colors hover:bg-slate-800 hover:text-white sm:inline-flex"
>
Home
</Link>
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
aria-label="View source on GitHub"
className="flex h-9 w-9 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-slate-800 hover:text-white"
>
<GithubIcon className="h-4 w-4" />
</a>
<Link
href="/signup"
className="inline-flex items-center justify-center rounded-lg bg-violet-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-violet-400"
>
Get started
</Link>
</div>
</div>
</header>
<div className="mx-auto flex w-full max-w-7xl gap-8 px-4 sm:px-6">
<aside className="sticky top-16 hidden h-[calc(100vh-4rem)] w-64 flex-shrink-0 overflow-y-auto py-10 pr-2 lg:block">
<DocsSidebar pages={pages} />
</aside>
{mobileOpen && (
<div className="fixed inset-0 top-16 z-30 flex lg:hidden">
<div
className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm"
onClick={closeMobile}
aria-hidden
/>
<div className="relative ml-0 mr-auto flex h-full w-72 max-w-[85vw] flex-col overflow-y-auto border-r border-slate-800 bg-slate-950 px-4 py-6">
<DocsSidebar pages={pages} onNavigate={closeMobile} />
</div>
</div>
)}
<main className="min-w-0 flex-1 py-10">{children}</main>
</div>
<footer className="mt-10 border-t border-slate-800 bg-slate-950">
<div className="mx-auto w-full max-w-7xl px-4 py-6 text-xs leading-relaxed text-slate-600 sm:px-6">
WhatsApp® is a registered trademark of Meta Platforms, Inc.
Hostinger is not affiliated with, endorsed by, or sponsored by
Meta Platforms, Inc.
</div>
</footer>
</div>
)
}

View File

@ -1,68 +0,0 @@
"use client"
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/utils'
import type { DocPage } from '@/lib/docs/content'
interface DocsSidebarProps {
pages: DocPage[]
onNavigate?: () => void
}
export function DocsSidebar({ pages, onNavigate }: DocsSidebarProps) {
const pathname = usePathname()
const sections: Record<string, DocPage[]> = {}
for (const p of pages) {
sections[p.section] ??= []
sections[p.section].push(p)
}
return (
<nav aria-label="Documentation" className="flex flex-col gap-6 text-sm">
<Link
href="/docs"
onClick={onNavigate}
className={cn(
'flex items-center justify-between rounded-md px-3 py-2 transition-colors',
pathname === '/docs'
? 'bg-slate-800 text-white'
: 'text-slate-300 hover:bg-slate-800/60 hover:text-white',
)}
>
<span>Overview</span>
</Link>
{Object.entries(sections).map(([section, items]) => (
<div key={section}>
<p className="mb-2 px-3 text-[10px] font-semibold uppercase tracking-wider text-slate-500">
{section}
</p>
<ul className="flex flex-col gap-0.5">
{items.map((p) => {
const href = `/docs/${p.slug}`
const active = pathname === href
return (
<li key={p.slug}>
<Link
href={href}
onClick={onNavigate}
className={cn(
'block rounded-md px-3 py-1.5 transition-colors',
active
? 'bg-violet-500/10 text-violet-300'
: 'text-slate-400 hover:bg-slate-800/60 hover:text-white',
)}
>
{p.title}
</Link>
</li>
)
})}
</ul>
</div>
))}
</nav>
)
}

View File

@ -1,43 +0,0 @@
import Link from 'next/link'
import { ArrowRight } from 'lucide-react'
import { Section } from './section'
/**
* Last push before the footer. Visually distinct (border, gradient
* background) so it doesn't read as "just another section".
*/
export function CtaBanner() {
return (
<Section className="py-16 sm:py-20">
<div
className="relative overflow-hidden rounded-2xl border border-violet-500/20 bg-slate-900/70 px-6 py-16 text-center sm:px-12 sm:py-20"
style={{
background:
'radial-gradient(800px circle at 50% -40%, rgb(139 92 246 / 0.18), transparent 60%), linear-gradient(to bottom, rgb(15 23 42 / 0.9), rgb(15 23 42 / 0.7))',
}}
>
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">
Ready to stop switching between tools?
</h2>
<p className="mt-4 text-base text-slate-400">
Bring your WhatsApp conversations, contacts, and deals into one place.
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Link
href="/signup"
className="inline-flex items-center gap-2 rounded-lg bg-violet-500 px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-violet-400"
>
Get started free
<ArrowRight className="h-4 w-4" />
</Link>
<Link
href="/login"
className="inline-flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-900 px-6 py-3 text-sm font-semibold text-slate-200 transition-colors hover:border-slate-600 hover:text-white"
>
Sign in
</Link>
</div>
</div>
</Section>
)
}

View File

@ -1,49 +0,0 @@
"use client"
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { Section, SectionHeader } from './section'
import { FAQ_ITEMS } from '@/lib/seo/faq-data'
import { cn } from '@/lib/utils'
export function FAQ() {
const [openIdx, setOpenIdx] = useState<number | null>(0)
return (
<Section id="faq">
<SectionHeader
eyebrow="FAQ"
title="Questions, answered"
description="If you cannot find what you are looking for, reach out and we will get back to you."
/>
<div className="mx-auto max-w-3xl divide-y divide-slate-800 rounded-xl border border-slate-800 bg-slate-900/40">
{FAQ_ITEMS.map((item, i) => {
const isOpen = openIdx === i
return (
<div key={item.q}>
<button
type="button"
onClick={() => setOpenIdx(isOpen ? null : i)}
className="flex w-full items-center justify-between gap-4 px-6 py-4 text-left transition-colors hover:bg-slate-900/70"
aria-expanded={isOpen}
>
<span className="text-sm font-medium text-white">{item.q}</span>
<ChevronDown
className={cn(
'h-4 w-4 flex-shrink-0 text-slate-500 transition-transform',
isOpen && 'rotate-180 text-violet-400',
)}
/>
</button>
{isOpen && (
<div className="px-6 pb-5 text-sm leading-relaxed text-slate-400">
{item.a}
</div>
)}
</div>
)
})}
</div>
</Section>
)
}

View File

@ -1,64 +0,0 @@
import type { ReactNode } from 'react'
import { Check } from 'lucide-react'
import { Section } from './section'
import { cn } from '@/lib/utils'
interface FeatureSpotlightProps {
eyebrow: string
title: string
body: string
bullets?: string[]
/** Right-side by default; flip to put the visual on the left. */
reverse?: boolean
visual: ReactNode
anchorId?: string
}
/**
* Reusable alternating feature section (copy on one side, product
* miniature on the other). Three instances on the page so we use the
* same component to keep spacing + typography consistent.
*/
export function FeatureSpotlight({
eyebrow,
title,
body,
bullets,
reverse,
visual,
anchorId,
}: FeatureSpotlightProps) {
return (
<Section id={anchorId} className="py-16 sm:py-20">
<div
className={cn(
'grid grid-cols-1 items-center gap-12 lg:grid-cols-2 lg:gap-16',
reverse && 'lg:[&>*:first-child]:order-2',
)}
>
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-violet-400">
{eyebrow}
</p>
<h3 className="mt-3 text-2xl font-bold tracking-tight text-white sm:text-3xl">
{title}
</h3>
<p className="mt-4 text-base leading-relaxed text-slate-400">
{body}
</p>
{bullets && bullets.length > 0 && (
<ul className="mt-5 space-y-2.5">
{bullets.map((b) => (
<li key={b} className="flex items-start gap-2.5 text-sm text-slate-300">
<Check className="mt-0.5 h-4 w-4 flex-shrink-0 text-violet-400" />
<span>{b}</span>
</li>
))}
</ul>
)}
</div>
<div>{visual}</div>
</div>
</Section>
)
}

View File

@ -1,99 +0,0 @@
import {
MessageSquare,
Users,
GitBranch,
Radio,
Zap,
LineChart,
} from 'lucide-react'
import type { ComponentType } from 'react'
import { Section, SectionHeader } from './section'
interface Feature {
title: string
description: string
icon: ComponentType<{ className?: string }>
tint: string
}
// Intentionally six — more than that becomes a feature list, less
// feels thin. Copy is benefit-first ("never miss a lead") rather
// than feature-first ("notifications").
const FEATURES: Feature[] = [
{
title: 'Shared inbox',
description:
'Every WhatsApp conversation in one place. Assign threads, reply as a team, never drop a lead.',
icon: MessageSquare,
tint: 'text-blue-400 bg-blue-500/10',
},
{
title: 'Contact hub',
description:
'Tags, custom fields, notes, and automatic deduplication. Import existing contacts from CSV.',
icon: Users,
tint: 'text-violet-400 bg-violet-500/10',
},
{
title: 'Sales pipelines',
description:
'Drag deals through stages. See what is won, what is slipping, and where revenue is stuck.',
icon: GitBranch,
tint: 'text-violet-400 bg-violet-500/10',
},
{
title: 'Broadcast campaigns',
description:
'Send Meta-approved templates to segmented lists. Track delivery, reads, and replies in real time.',
icon: Radio,
tint: 'text-amber-400 bg-amber-500/10',
},
{
title: 'No-code automations',
description:
'Welcome new contacts, chase unanswered replies, route leads by keyword. Visual flow builder.',
icon: Zap,
tint: 'text-rose-400 bg-rose-500/10',
},
{
title: 'Real-time analytics',
description:
'Response times, daily volume, pipeline value. See what is working without building dashboards.',
icon: LineChart,
tint: 'text-cyan-400 bg-cyan-500/10',
},
]
export function FeaturesGrid() {
return (
<Section id="features">
<SectionHeader
eyebrow="Everything you need"
title="One toolkit for your WhatsApp business"
description="Stop stitching together an inbox, a spreadsheet, and a broadcast tool. This CRM template for WhatsApp brings them together — and talks to WhatsApp natively."
/>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
{FEATURES.map((f) => {
const Icon = f.icon
return (
<div
key={f.title}
className="rounded-xl border border-slate-800 bg-slate-900/40 p-6 transition-colors hover:border-slate-700 hover:bg-slate-900/70"
>
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${f.tint}`}>
<Icon className="h-5 w-5" />
</div>
<h3 className="mt-4 text-base font-semibold text-white">
{f.title}
</h3>
<p className="mt-1.5 text-sm leading-relaxed text-slate-400">
{f.description}
</p>
</div>
)
})}
</div>
</Section>
)
}

View File

@ -1,119 +0,0 @@
import Link from 'next/link'
import { MessageSquare } from 'lucide-react'
export function Footer() {
const year = new Date().getFullYear()
return (
<footer className="border-t border-slate-800 bg-slate-950">
<div className="mx-auto grid w-full max-w-7xl grid-cols-2 gap-8 px-6 py-12 sm:grid-cols-5">
<div className="col-span-2 sm:col-span-2">
<Link
href="/"
className="flex items-center gap-2"
aria-label="CRM Template for WhatsApp home"
>
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500">
<MessageSquare className="h-4 w-4 text-white" />
</span>
<span className="text-sm font-semibold text-white">
CRM Template for WhatsApp
</span>
</Link>
<p className="mt-3 max-w-sm text-sm text-slate-500">
One toolkit for your WhatsApp business — shared inbox, pipelines,
broadcasts, and automations.
</p>
</div>
<FooterColumn
title="Product"
links={[
{ href: '#features', label: 'Features' },
{ href: '#how-it-works', label: 'How it works' },
{ href: '#faq', label: 'FAQ' },
]}
/>
<FooterColumn
title="Open source"
links={[
{
href: 'https://github.com/ArnasDon/wacrm',
label: 'GitHub repo',
external: true,
},
{ href: '#self-host', label: 'Self-host' },
{ href: '/docs', label: 'Docs' },
{
href: 'https://www.hostinger.com/web-apps-hosting',
label: 'Deploy on Hostinger',
external: true,
},
]}
/>
<FooterColumn
title="Account"
links={[
{ href: '/signup', label: 'Get started' },
{ href: '/login', label: 'Sign in' },
{ href: '/forgot-password', label: 'Forgot password' },
]}
/>
</div>
<div className="border-t border-slate-900">
<div className="mx-auto flex w-full max-w-7xl flex-col items-start justify-between gap-3 px-6 py-5 text-xs text-slate-500 sm:flex-row sm:items-center">
<span>© {year} CRM Template for WhatsApp. All rights reserved.</span>
<span>Built on the official WhatsApp Business API.</span>
</div>
<div className="mx-auto w-full max-w-7xl px-6 pb-5 text-xs leading-relaxed text-slate-600">
WhatsApp® is a registered trademark of Meta Platforms, Inc.
Hostinger is not affiliated with, endorsed by, or sponsored by
Meta Platforms, Inc.
</div>
</div>
</footer>
)
}
function FooterColumn({
title,
links,
}: {
title: string
links: { href: string; label: string; external?: boolean }[]
}) {
return (
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-slate-400">
{title}
</h4>
<ul className="mt-4 space-y-2.5">
{links.map((l) =>
l.external ? (
<li key={l.href}>
<a
href={l.href}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-400 transition-colors hover:text-white"
>
{l.label}
</a>
</li>
) : (
<li key={l.href}>
<Link
href={l.href}
className="text-sm text-slate-400 transition-colors hover:text-white"
>
{l.label}
</Link>
</li>
),
)}
</ul>
</div>
)
}

View File

@ -1,18 +0,0 @@
import type { SVGProps } from 'react'
export function GithubIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 .5C5.37.5 0 5.87 0 12.5c0 5.3 3.44 9.8 8.21 11.38.6.11.82-.26.82-.58 0-.28-.01-1.04-.02-2.04-3.34.72-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.74.08-.73.08-.73 1.2.08 1.84 1.24 1.84 1.24 1.07 1.84 2.81 1.31 3.5 1 .11-.78.42-1.31.76-1.61-2.67-.3-5.48-1.34-5.48-5.97 0-1.32.47-2.39 1.24-3.23-.13-.3-.54-1.53.11-3.18 0 0 1.01-.32 3.3 1.23.96-.27 1.98-.4 3-.41 1.02.01 2.04.14 3 .41 2.29-1.55 3.3-1.23 3.3-1.23.65 1.65.24 2.88.12 3.18.77.84 1.24 1.91 1.24 3.23 0 4.64-2.81 5.67-5.49 5.96.43.37.81 1.1.81 2.22 0 1.6-.01 2.89-.01 3.29 0 .32.22.7.83.58C20.57 22.29 24 17.8 24 12.5 24 5.87 18.63.5 12 .5Z"
/>
</svg>
)
}

View File

@ -1,80 +0,0 @@
import Link from 'next/link'
import { ShieldCheck, ArrowRight } from 'lucide-react'
import { InboxMock } from './mock/inbox-mock'
/**
* Above-the-fold hero. Two-column on desktop (copy + product visual),
* stacks on mobile. The product miniature doubles as proof of what
* the app actually looks like — better than a generic illustration.
*/
export function Hero() {
return (
<div className="relative overflow-hidden">
{/* Soft radial glow behind the hero. Pure CSS so it doesn't
count against any image budget. */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 opacity-60"
style={{
background:
'radial-gradient(800px circle at 70% -10%, rgb(139 92 246 / 0.18), transparent 60%), radial-gradient(600px circle at 10% 30%, rgb(59 130 246 / 0.08), transparent 60%)',
}}
/>
<div className="mx-auto grid w-full max-w-7xl grid-cols-1 items-center gap-12 px-6 py-20 sm:py-28 lg:grid-cols-2 lg:gap-16">
<div>
<div className="inline-flex items-center gap-2 rounded-full border border-slate-800 bg-slate-900/60 px-3 py-1 text-xs text-slate-300">
<ShieldCheck className="h-3.5 w-3.5 text-violet-400" />
Built on the official WhatsApp® Business API
</div>
<h1 className="mt-5 text-4xl font-bold tracking-tight text-white sm:text-5xl lg:text-6xl">
Run your WhatsApp business from{' '}
<span className="text-violet-400">one inbox.</span>
</h1>
<p className="mt-5 max-w-xl text-base text-slate-400 sm:text-lg">
Shared inbox, contact hub, sales pipelines, broadcasts, and
no-code automations — in one place. Get your team on the same
page without switching tools.
</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Link
href="/signup"
className="inline-flex items-center gap-2 rounded-lg bg-violet-500 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-violet-400"
>
Get started
<ArrowRight className="h-4 w-4" />
</Link>
<Link
href="/login"
className="inline-flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-900 px-5 py-3 text-sm font-semibold text-slate-200 transition-colors hover:border-slate-600 hover:text-white"
>
Sign in
</Link>
</div>
<div className="mt-8 flex flex-wrap items-center gap-x-6 gap-y-2 text-xs text-slate-500">
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-violet-500" />
No credit card required
</span>
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-violet-500" />
Live in 30 minutes
</span>
<span className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-violet-500" />
Your data, your Supabase
</span>
</div>
</div>
<div className="lg:justify-self-end">
<InboxMock />
</div>
</div>
</div>
)
}

View File

@ -1,68 +0,0 @@
import { Plug, Users, Zap } from 'lucide-react'
import { Section, SectionHeader } from './section'
const STEPS = [
{
num: '01',
icon: Plug,
title: 'Connect your WhatsApp number',
body:
'Paste your phone number ID and access token from Meta. Works with any Meta-approved WhatsApp Business API provider.',
},
{
num: '02',
icon: Users,
title: 'Bring in your contacts',
body:
'Import a CSV, or let incoming messages build your contact list automatically. Tags and custom fields are ready from day one.',
},
{
num: '03',
icon: Zap,
title: 'Reply, automate, measure',
body:
'Use the shared inbox with your team, set up flows for repeat work, and track what is actually moving the needle in your analytics.',
},
]
export function HowItWorks() {
return (
<Section id="how-it-works">
<SectionHeader
eyebrow="How it works"
title="Live in under 30 minutes"
description="Most teams are up and running before their first coffee refill. No onboarding calls required."
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{STEPS.map((s) => {
const Icon = s.icon
return (
<div
key={s.num}
className="relative rounded-xl border border-slate-800 bg-slate-900/40 p-6"
>
<div className="flex items-start justify-between">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-violet-500/10 text-violet-400">
<Icon className="h-5 w-5" />
</div>
<span
className="text-xl font-bold tracking-tight text-slate-800 tabular-nums"
aria-hidden
>
{s.num}
</span>
</div>
<h3 className="mt-4 text-base font-semibold text-white">
{s.title}
</h3>
<p className="mt-1.5 text-sm leading-relaxed text-slate-400">
{s.body}
</p>
</div>
)
})}
</div>
</Section>
)
}

View File

@ -1,101 +0,0 @@
/**
* Miniature analytics panel with a line chart + 3 stat tiles.
* Shown alongside the dashboard feature spotlight. SVG rendered
* inline so there's no image to load.
*/
export function AnalyticsMock() {
// Pre-sampled line data so the curve looks "realistic" without
// being random (we want the same pleasing shape on every render).
const incoming = [4, 6, 3, 7, 9, 6, 11, 14, 10, 13, 16, 18, 15, 22]
const outgoing = [3, 5, 2, 6, 7, 5, 9, 12, 9, 11, 14, 15, 13, 19]
const VB_W = 320
const VB_H = 120
const PAD = { top: 8, right: 10, bottom: 10, left: 24 }
const chartW = VB_W - PAD.left - PAD.right
const chartH = VB_H - PAD.top - PAD.bottom
const maxY = Math.max(...incoming, ...outgoing)
const stepX = chartW / (incoming.length - 1)
const y = (v: number) => PAD.top + chartH - (v / maxY) * chartH
const x = (i: number) => PAD.left + i * stepX
const path = (arr: number[]) =>
arr.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i)},${y(v)}`).join(' ')
return (
<div className="w-full overflow-hidden rounded-2xl border border-slate-800 bg-slate-950 shadow-2xl">
<div className="flex items-center gap-1.5 border-b border-slate-800 bg-slate-900 px-3 py-2">
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="ml-3 text-[10px] text-slate-500">
Dashboard — CRM Template for WhatsApp
</span>
</div>
<div className="space-y-3 p-4">
<div className="grid grid-cols-3 gap-2">
{[
{ label: 'Open convos', value: '42', delta: '+5', positive: true },
{ label: 'New today', value: '18', delta: '+3', positive: true },
{ label: 'Avg reply', value: '3.2m', delta: '-0.4m', positive: true },
].map((s) => (
<div
key={s.label}
className="rounded-lg border border-slate-800 bg-slate-900 px-3 py-2"
>
<div className="text-[10px] text-slate-500">{s.label}</div>
<div className="mt-0.5 text-lg font-bold text-white tabular-nums">
{s.value}
</div>
<div
className={`text-[10px] tabular-nums ${
s.positive ? 'text-violet-400' : 'text-red-400'
}`}
>
{s.delta}
</div>
</div>
))}
</div>
<div className="rounded-lg border border-slate-800 bg-slate-900 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-white">
Conversations over time
</span>
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-1 text-[9px] text-slate-500">
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
Incoming
</span>
<span className="inline-flex items-center gap-1 text-[9px] text-slate-500">
<span className="h-1.5 w-1.5 rounded-full bg-violet-500" />
Outgoing
</span>
</div>
</div>
<svg
viewBox={`0 0 ${VB_W} ${VB_H}`}
className="h-28 w-full"
role="img"
aria-label="Sample conversations chart"
>
{[0, 0.5, 1].map((t) => (
<line
key={t}
x1={PAD.left}
x2={VB_W - PAD.right}
y1={PAD.top + chartH * t}
y2={PAD.top + chartH * t}
stroke="rgb(30 41 59)"
strokeDasharray="3 3"
/>
))}
<path d={path(outgoing)} fill="none" stroke="#7c3aed" strokeWidth={1.5} />
<path d={path(incoming)} fill="none" stroke="#3b82f6" strokeWidth={1.5} />
</svg>
</div>
</div>
</div>
)
}

View File

@ -1,93 +0,0 @@
import { MessageSquare, Zap, GitBranch, Hourglass } from 'lucide-react'
/**
* Miniature flow-builder for the Automations feature spotlight.
* Mirrors the real builder's look (trigger + stepped cards + arrows)
* but is pure presentation — no interactivity.
*/
export function AutomationMock() {
const steps = [
{
kind: 'trigger',
label: 'First Message from Contact',
icon: Zap,
accent: 'border-l-blue-500',
badgeClass: 'bg-blue-500/10 text-blue-400',
kindLabel: 'TRIGGER',
},
{
kind: 'action',
label: 'Send "Hi! 👋 Thanks for reaching out."',
icon: MessageSquare,
accent: 'border-l-violet-500',
badgeClass: 'bg-violet-500/10 text-violet-400',
kindLabel: 'ACTION',
},
{
kind: 'wait',
label: 'Wait 10 minutes',
icon: Hourglass,
accent: 'border-l-slate-500',
badgeClass: 'bg-slate-500/10 text-slate-400',
kindLabel: 'WAIT',
},
{
kind: 'condition',
label: 'If tag = "lead" → assign to sales',
icon: GitBranch,
accent: 'border-l-amber-500',
badgeClass: 'bg-amber-500/10 text-amber-400',
kindLabel: 'CONDITION',
},
]
return (
<div className="relative w-full overflow-hidden rounded-2xl border border-slate-800 bg-slate-950 shadow-2xl">
<div className="flex items-center gap-1.5 border-b border-slate-800 bg-slate-900 px-3 py-2">
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="ml-3 text-[10px] text-slate-500">
Automations — CRM Template for WhatsApp
</span>
</div>
{/* Dotted canvas — matches the real flow builder's background. */}
<div
className="relative flex flex-col items-center gap-0 px-6 py-6"
style={{
backgroundImage: 'radial-gradient(circle, rgb(30 41 59) 1px, transparent 1px)',
backgroundSize: '14px 14px',
}}
>
{steps.map((s, i) => {
const Icon = s.icon
return (
<div key={i} className="flex w-full max-w-xs flex-col items-center">
{i > 0 && <div className="h-4 w-px bg-slate-700" aria-hidden />}
<div
className={`w-full rounded-lg border border-slate-800 border-l-4 bg-slate-900 px-3 py-2 ${s.accent}`}
>
<div className="flex items-center gap-2">
<span
className={`flex h-6 w-6 items-center justify-center rounded-md ${s.badgeClass}`}
>
<Icon className="h-3 w-3" />
</span>
<div className="min-w-0">
<div className="text-[9px] font-semibold uppercase tracking-wider text-slate-500">
{s.kindLabel}
</div>
<div className="truncate text-[11px] font-medium text-white">
{s.label}
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@ -1,99 +0,0 @@
import { MessageSquare, Search } from 'lucide-react'
/**
* Miniature of the app's shared inbox. Pure CSS — no real data, no
* supabase calls. Used in the hero and the inbox feature spotlight.
* Kept stateless and presentational so the page renders fast.
*/
export function InboxMock() {
return (
<div className="w-full overflow-hidden rounded-2xl border border-slate-800 bg-slate-950 shadow-2xl">
{/* Fake window chrome — makes the mock read as "a product", not
an abstract component. */}
<div className="flex items-center gap-1.5 border-b border-slate-800 bg-slate-900 px-3 py-2">
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="ml-3 text-[10px] text-slate-500">
Inbox — CRM Template for WhatsApp
</span>
</div>
<div className="grid grid-cols-[180px_1fr] lg:grid-cols-[220px_1fr]">
{/* Conversation list */}
<div className="border-r border-slate-800 bg-slate-900/60">
<div className="flex items-center gap-2 border-b border-slate-800 px-3 py-2">
<Search className="h-3 w-3 text-slate-500" />
<span className="text-[10px] text-slate-500">Search</span>
</div>
{[
{ name: 'Aisha', preview: 'Thanks! Received it.', unread: false, active: true },
{ name: 'Diego', preview: 'Do you ship to Brazil?', unread: true, active: false },
{ name: 'Yuki', preview: 'Price sheet attached.', unread: false, active: false },
{ name: 'Luca', preview: 'Got it, will test.', unread: false, active: false },
].map((c, i) => (
<div
key={i}
className={`flex items-start gap-2 px-3 py-2.5 ${
c.active ? 'border-l-2 border-violet-500 bg-slate-800/60' : 'border-l-2 border-transparent'
}`}
>
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full bg-violet-500/10 text-[10px] font-medium text-violet-400">
{c.name[0]}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium text-white">{c.name}</span>
{c.unread && <span className="h-1.5 w-1.5 rounded-full bg-violet-500" />}
</div>
<p className="truncate text-[10px] text-slate-500">{c.preview}</p>
</div>
</div>
))}
</div>
{/* Thread */}
<div className="flex min-h-[280px] flex-col bg-slate-950">
<div className="flex items-center gap-2 border-b border-slate-800 px-4 py-2.5">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-violet-500/10 text-[10px] font-medium text-violet-400">
A
</span>
<div>
<div className="text-xs font-medium text-white">Aisha</div>
<div className="text-[10px] text-slate-500">+44 7700 900123</div>
</div>
<div className="ml-auto inline-flex items-center gap-1 rounded-full border border-violet-500/30 bg-violet-500/10 px-2 py-0.5 text-[10px] text-violet-300">
Open
</div>
</div>
<div className="flex flex-1 flex-col gap-2 px-4 py-4">
<div className="mr-auto max-w-[75%] rounded-2xl rounded-bl-sm bg-slate-800 px-3 py-2 text-xs text-slate-200">
Hi! Is the kit available in large?
</div>
<div className="ml-auto max-w-[75%] rounded-2xl rounded-br-sm bg-violet-500 px-3 py-2 text-xs text-white">
Yes — shipping today 📦
</div>
<div className="mr-auto max-w-[75%] rounded-2xl rounded-bl-sm bg-slate-800 px-3 py-2 text-xs text-slate-200">
Thanks! Received it.
</div>
</div>
<div className="flex items-center gap-2 border-t border-slate-800 bg-slate-900/60 px-4 py-2.5">
<div className="flex h-8 flex-1 items-center rounded-lg border border-slate-700 bg-slate-900 px-3 text-[10px] text-slate-500">
Type a message…
</div>
<button
type="button"
tabIndex={-1}
aria-hidden
className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500 text-white"
>
<MessageSquare className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,70 +0,0 @@
/**
* Miniature kanban pipeline with three stages and a handful of fake
* deal cards. Feature-spotlight for the Pipelines section.
*/
export function PipelineMock() {
const stages = [
{
name: 'Lead',
color: 'bg-blue-500',
deals: [
{ title: 'Acme Co', value: '$1,200' },
{ title: 'Bakery on 3rd', value: '$450' },
],
},
{
name: 'Proposal',
color: 'bg-amber-500',
deals: [
{ title: 'Nova Studios', value: '$4,800' },
{ title: 'Riveria Hotel', value: '$2,100' },
{ title: 'Pine & Co', value: '$960' },
],
},
{
name: 'Won',
color: 'bg-violet-500',
deals: [{ title: 'Lagoon Spa', value: '$3,200' }],
},
]
return (
<div className="w-full overflow-hidden rounded-2xl border border-slate-800 bg-slate-950 shadow-2xl">
<div className="flex items-center gap-1.5 border-b border-slate-800 bg-slate-900 px-3 py-2">
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
<span className="ml-3 text-[10px] text-slate-500">
Pipelines — CRM Template for WhatsApp
</span>
</div>
<div className="grid grid-cols-3 gap-3 p-4">
{stages.map((s) => (
<div key={s.name} className="flex flex-col gap-2 rounded-lg bg-slate-900/60 p-2">
<div className="flex items-center gap-2 px-1">
<span className={`h-1.5 w-1.5 rounded-full ${s.color}`} />
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-300">
{s.name}
</span>
<span className="ml-auto text-[10px] text-slate-500 tabular-nums">
{s.deals.length}
</span>
</div>
{s.deals.map((d, i) => (
<div
key={i}
className="rounded-md border border-slate-800 bg-slate-900 px-2.5 py-2"
>
<div className="text-[11px] font-medium text-white">{d.title}</div>
<div className="mt-1 text-[10px] text-slate-500 tabular-nums">
{d.value}
</div>
</div>
))}
</div>
))}
</div>
</div>
)
}

View File

@ -1,191 +0,0 @@
"use client"
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { MessageSquare, Menu, X } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { cn } from '@/lib/utils'
import { GithubIcon } from './github-icon'
/**
* Landing-page top nav. Client component because we need to flip the
* primary CTA depending on whether the visitor is already signed in.
* "Auth pending" renders a placeholder (a pair of muted pill shapes)
* so the CTA doesn't pop in jarringly after hydration.
*/
type AuthState = 'pending' | 'signed-in' | 'signed-out'
const LINKS = [
{ href: '#features', label: 'Features' },
{ href: '#how-it-works', label: 'How it works' },
{ href: '#self-host', label: 'Self-host' },
{ href: '/docs', label: 'Docs' },
{ href: '#faq', label: 'FAQ' },
]
const REPO_URL = 'https://github.com/ArnasDon/wacrm'
export function LandingNav() {
const [auth, setAuth] = useState<AuthState>('pending')
const [scrolled, setScrolled] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
useEffect(() => {
// Quick auth check — no realtime needed, just the initial state.
const supabase = createClient()
let cancelled = false
supabase.auth.getSession().then(({ data }) => {
if (cancelled) return
setAuth(data.session?.user ? 'signed-in' : 'signed-out')
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8)
onScroll()
window.addEventListener('scroll', onScroll, { passive: true })
return () => window.removeEventListener('scroll', onScroll)
}, [])
return (
<header
className={cn(
'sticky top-0 z-40 w-full border-b transition-colors',
scrolled
? 'border-slate-800 bg-slate-950/80 backdrop-blur'
: 'border-transparent bg-transparent',
)}
>
<div className="mx-auto flex h-16 w-full max-w-7xl items-center justify-between px-6">
<Link
href="/"
className="flex items-center gap-2"
aria-label="CRM Template for WhatsApp home"
>
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500">
<MessageSquare className="h-4 w-4 text-white" />
</span>
<span className="text-sm font-semibold text-white">
CRM Template for WhatsApp
</span>
</Link>
<nav className="hidden items-center gap-8 md:flex">
{LINKS.map((l) => (
<Link
key={l.href}
href={l.href}
className="text-sm text-slate-300 transition-colors hover:text-white"
>
{l.label}
</Link>
))}
</nav>
<div className="hidden items-center gap-2 md:flex">
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
aria-label="View source on GitHub"
className="flex h-9 w-9 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-slate-800 hover:text-white"
>
<GithubIcon className="h-4 w-4" />
</a>
<NavCtas auth={auth} />
</div>
<button
type="button"
className="flex h-9 w-9 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-slate-800 hover:text-white md:hidden"
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
onClick={() => setMobileOpen((v) => !v)}
>
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
{mobileOpen && (
<div className="border-t border-slate-800 bg-slate-950/95 backdrop-blur md:hidden">
<div className="mx-auto flex max-w-7xl flex-col gap-1 px-6 py-4">
{LINKS.map((l) => (
<Link
key={l.href}
href={l.href}
onClick={() => setMobileOpen(false)}
className="rounded-md px-3 py-2 text-sm text-slate-300 hover:bg-slate-800 hover:text-white"
>
{l.label}
</Link>
))}
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => setMobileOpen(false)}
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-slate-300 hover:bg-slate-800 hover:text-white"
>
<GithubIcon className="h-4 w-4" />
GitHub
</a>
<div className="mt-2 flex flex-col gap-2 border-t border-slate-800 pt-3">
<NavCtas auth={auth} mobile />
</div>
</div>
</div>
)}
</header>
)
}
function NavCtas({ auth, mobile = false }: { auth: AuthState; mobile?: boolean }) {
const btnBase =
'inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-colors'
const secondary = cn(
btnBase,
'text-slate-300 hover:bg-slate-800 hover:text-white',
mobile && 'justify-center',
)
const primary = cn(
btnBase,
'bg-violet-500 text-white hover:bg-violet-400',
mobile && 'justify-center',
)
if (auth === 'pending') {
// Neutral placeholder that matches the eventual button sizes so
// nothing shifts once auth resolves.
return (
<>
<span className={cn(btnBase, 'w-20 bg-slate-800/60 text-transparent')}>·</span>
<span
className={cn(btnBase, 'w-28 bg-slate-800/60 text-transparent')}
>
·
</span>
</>
)
}
if (auth === 'signed-in') {
return (
<Link href="/dashboard" className={primary}>
Go to Dashboard
</Link>
)
}
return (
<>
<Link href="/login" className={secondary}>
Sign in
</Link>
<Link href="/signup" className={primary}>
Get started
</Link>
</>
)
}

View File

@ -1,80 +0,0 @@
import Link from 'next/link'
import { ArrowRight, BookOpen, Server } from 'lucide-react'
import { Section, SectionHeader } from './section'
import { GithubIcon } from './github-icon'
const REPO_URL = 'https://github.com/ArnasDon/wacrm'
const HOSTINGER_URL = 'https://www.hostinger.com/web-apps-hosting'
export function OpenSource() {
return (
<Section id="self-host">
<SectionHeader
eyebrow="Open source"
title="Fork it, brand it, host it"
description="This CRM template for WhatsApp is a template you can take and make your own. Grab the source on GitHub and self-host — we recommend Hostinger Managed Node.js Hosting for a zero-ops deploy."
/>
<div className="mx-auto grid max-w-5xl grid-cols-1 gap-5 md:grid-cols-2">
<a
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className="group flex flex-col rounded-xl border border-slate-800 bg-slate-900/40 p-6 transition-colors hover:border-slate-700 hover:bg-slate-900/70"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-slate-800 text-slate-100">
<GithubIcon className="h-5 w-5" />
</div>
<h3 className="mt-4 text-base font-semibold text-white">
Source on GitHub
</h3>
<p className="mt-1.5 text-sm leading-relaxed text-slate-400">
Clone or fork the repository, tweak the code, ship your own build.
MIT-style freedom with the full CRM underneath.
</p>
<span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-violet-400 transition-colors group-hover:text-violet-300">
ArnasDon/wacrm
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</span>
</a>
<a
href={HOSTINGER_URL}
target="_blank"
rel="noopener noreferrer"
className="group flex flex-col rounded-xl border border-violet-500/20 bg-slate-900/40 p-6 transition-colors hover:border-violet-500/40 hover:bg-slate-900/70"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-violet-500/10 text-violet-400">
<Server className="h-5 w-5" />
</div>
<h3 className="mt-4 flex flex-wrap items-center gap-2 text-base font-semibold text-white">
Deploy on Hostinger
<span className="rounded-full bg-violet-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-violet-400">
Recommended
</span>
</h3>
<p className="mt-1.5 text-sm leading-relaxed text-slate-400">
Best fit for this template — connect your forked repo to
Managed Node.js Hosting and your CRM is live in a few minutes. No
servers to patch.
</p>
<span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-violet-400 transition-colors group-hover:text-violet-300">
Managed Node.js Hosting
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</span>
</a>
</div>
<div className="mx-auto mt-6 flex max-w-5xl items-center justify-center">
<Link
href="/docs"
className="group inline-flex items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/40 px-5 py-3 text-sm font-medium text-slate-200 transition-colors hover:border-slate-700 hover:bg-slate-900/70 hover:text-white"
>
<BookOpen className="h-4 w-4 text-violet-400" />
Read the full documentation
<ArrowRight className="h-4 w-4 text-slate-400 transition-transform group-hover:translate-x-0.5 group-hover:text-slate-200" />
</Link>
</div>
</Section>
)
}

View File

@ -1,69 +0,0 @@
import { cn } from '@/lib/utils'
import type { ReactNode } from 'react'
/**
* Shared section wrapper — consistent horizontal padding, max-width,
* and vertical rhythm across every landing section. Keeping it in one
* place means tweaking spacing once vs. in every section component.
*/
export function Section({
id,
className,
children,
tight,
}: {
id?: string
className?: string
children: ReactNode
/** Compact vertical padding — useful for nav strips / dense sections. */
tight?: boolean
}) {
return (
<section
id={id}
className={cn(
'mx-auto w-full max-w-7xl px-6',
tight ? 'py-6' : 'py-20 sm:py-24',
className,
)}
>
{children}
</section>
)
}
/**
* Pair of small label + big headline used atop every section. The
* eyebrow establishes context in 1-2 words before the user commits
* to reading the headline.
*/
export function SectionHeader({
eyebrow,
title,
description,
align = 'center',
}: {
eyebrow?: string
title: string
description?: string
align?: 'center' | 'left'
}) {
const base =
align === 'center' ? 'text-center' : 'text-left'
const max = align === 'center' ? 'max-w-2xl mx-auto' : 'max-w-2xl'
return (
<div className={cn(base, max, 'mb-12')}>
{eyebrow && (
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-violet-400">
{eyebrow}
</p>
)}
<h2 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">
{title}
</h2>
{description && (
<p className="mt-4 text-base text-slate-400">{description}</p>
)}
</div>
)
}

View File

@ -1,22 +0,0 @@
/**
* Emits one <script type="application/ld+json"> per schema node.
* Google recommends separate tags per schema type over one combined
* @graph blob, to make it easier for them to parse selectively.
*
* Uses dangerouslySetInnerHTML because React escapes the < / > in
* script bodies otherwise, which breaks JSON-LD in subtle ways.
* The input is JSON.stringify'd locally — no user input flows here.
*/
export function JsonLd({ data }: { data: Record<string, unknown>[] }) {
return (
<>
{data.map((node, i) => (
<script
key={i}
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(node) }}
/>
))}
</>
)
}

View File

@ -1,227 +0,0 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { marked } from 'marked'
const DOCS_DIR = path.join(process.cwd(), 'docs')
export interface DocPage {
slug: string
title: string
description?: string
order: number
section: string
}
export interface DocContent extends DocPage {
html: string
toc: Array<{ id: string; text: string; depth: number }>
}
// Hand-curated because file order on disk is not reading order.
const ORDER: Record<string, { order: number; section: string; description: string }> = {
'getting-started': {
order: 1,
section: 'Setup',
description: 'Fork, install, run the template locally in about 15 minutes.',
},
'supabase-setup': {
order: 2,
section: 'Setup',
description: 'Create the database, apply migrations, grab the keys.',
},
'whatsapp-setup': {
order: 3,
section: 'Setup',
description: 'Meta app, access token, webhook verification.',
},
'environment-variables': {
order: 4,
section: 'Setup',
description: 'Full reference for every env var the app reads.',
},
'deployment-hostinger': {
order: 5,
section: 'Deploy',
description: 'Ship the template on Hostinger Managed Node.js Hosting.',
},
'automations-and-cron': {
order: 6,
section: 'Deploy',
description: 'Schedule the drain so Wait steps resume on time.',
},
architecture: {
order: 7,
section: 'Reference',
description: 'Stack, folder layout, request lifecycle.',
},
troubleshooting: {
order: 8,
section: 'Reference',
description: 'The usual suspects when something breaks.',
},
}
// Tailwind classes applied after marked renders.
// Listed as full class strings so Tailwind's source scanner picks them up.
const CLASSES = {
h1: 'text-3xl sm:text-4xl font-bold tracking-tight text-white mt-1 mb-4 scroll-mt-20',
h2: 'text-2xl font-semibold tracking-tight text-white mt-10 mb-3 pt-6 border-t border-slate-800 scroll-mt-20',
h3: 'text-lg font-semibold text-white mt-8 mb-2 scroll-mt-20',
p: 'my-3 text-slate-300 leading-relaxed',
ul: 'my-3 pl-6 list-disc marker:text-slate-500 text-slate-300 leading-relaxed space-y-1.5',
ol: 'my-3 pl-6 list-decimal marker:text-slate-500 text-slate-300 leading-relaxed space-y-1.5',
li: 'pl-1',
blockquote: 'my-4 rounded-r-lg border-l-4 border-violet-500 bg-violet-500/5 px-4 py-3 text-slate-200',
hr: 'my-8 border-t border-slate-800',
a: 'text-violet-400 underline decoration-transparent underline-offset-2 transition-colors hover:decoration-violet-500/60',
codespan: 'rounded-md border border-slate-700 bg-slate-800 px-1.5 py-0.5 text-[0.85em] font-mono text-slate-100',
pre: 'my-5 overflow-x-auto rounded-lg border border-slate-800 bg-slate-900/80 p-4 text-[0.825rem] leading-relaxed font-mono text-slate-100',
table: 'my-5 block w-full overflow-x-auto rounded-lg border border-slate-800 text-sm',
th: 'border-b border-slate-800 px-3.5 py-2.5 text-left text-xs font-semibold uppercase tracking-wide text-white bg-slate-900/60',
td: 'border-b border-slate-800 px-3.5 py-2.5 text-left align-top text-slate-300',
strong: 'font-semibold text-white',
}
let pagesCache: DocPage[] | null = null
export async function listDocs(): Promise<DocPage[]> {
if (pagesCache) return pagesCache
const entries = await fs.readdir(DOCS_DIR)
const pages: DocPage[] = []
for (const file of entries) {
if (!file.endsWith('.md') || file === 'README.md') continue
const slug = file.replace(/\.md$/, '')
const meta = ORDER[slug]
if (!meta) continue
const raw = await fs.readFile(path.join(DOCS_DIR, file), 'utf8')
const title = extractTitle(raw) ?? slug
pages.push({
slug,
title,
description: meta.description,
order: meta.order,
section: meta.section,
})
}
pages.sort((a, b) => a.order - b.order)
pagesCache = pages
return pages
}
export async function loadDoc(slug: string): Promise<DocContent | null> {
const pages = await listDocs()
const meta = pages.find((p) => p.slug === slug)
if (!meta) return null
const raw = await fs.readFile(path.join(DOCS_DIR, `${slug}.md`), 'utf8')
// Resolve inter-doc links like `./supabase-setup.md` to `/docs/supabase-setup`.
const rewritten = raw.replace(
/\]\(\.\/([a-zA-Z0-9-_]+)\.md([^)]*)\)/g,
(_, target: string, rest: string) => `](/docs/${target}${rest})`,
)
const vanilla = await marked.parse(rewritten, { gfm: true, breaks: false })
const { html, toc } = decorate(vanilla as string)
return { ...meta, html, toc }
}
function decorate(html: string): { html: string; toc: DocContent['toc'] } {
const toc: DocContent['toc'] = []
let out = html
// Headings: add id + Tailwind classes. Collect h2/h3 for TOC.
out = out.replace(
/<h([1-6])>([\s\S]*?)<\/h\1>/g,
(_, depthStr: string, inner: string) => {
const depth = Number(depthStr)
const text = stripTags(inner)
const id = slugify(text)
if (depth >= 2 && depth <= 3) toc.push({ id, text, depth })
const cls =
depth === 1
? CLASSES.h1
: depth === 2
? CLASSES.h2
: depth === 3
? CLASSES.h3
: ''
return `<h${depth} id="${id}" class="${cls}">${inner}</h${depth}>`
},
)
// Drop the top border from the very first h2 so it doesn't read as a
// divider right under the page title.
let firstH2 = true
out = out.replace(
/<h2 (id="[^"]*") class="([^"]*)"/g,
(match, idAttr: string, cls: string) => {
if (!firstH2) return match
firstH2 = false
const trimmed = cls
.replace(/(?:^|\s)border-t(?:\s|$)/, ' ')
.replace(/(?:^|\s)border-slate-800(?:\s|$)/, ' ')
.replace(/(?:^|\s)pt-6(?:\s|$)/, ' ')
.replace(/(?:^|\s)mt-10(?:\s|$)/, ' mt-6 ')
.trim()
.replace(/\s+/g, ' ')
return `<h2 ${idAttr} class="${trimmed}"`
},
)
out = out.replace(/<p>/g, `<p class="${CLASSES.p}">`)
out = out.replace(/<ul>/g, `<ul class="${CLASSES.ul}">`)
out = out.replace(/<ol>/g, `<ol class="${CLASSES.ol}">`)
out = out.replace(/<li>/g, `<li class="${CLASSES.li}">`)
out = out.replace(/<blockquote>/g, `<blockquote class="${CLASSES.blockquote}">`)
out = out.replace(/<hr>/g, `<hr class="${CLASSES.hr}" />`)
out = out.replace(/<strong>/g, `<strong class="${CLASSES.strong}">`)
out = out.replace(/<table>/g, `<table class="${CLASSES.table}">`)
out = out.replace(/<th>/g, `<th class="${CLASSES.th}">`)
out = out.replace(/<th align="([^"]+)">/g, `<th align="$1" class="${CLASSES.th}">`)
out = out.replace(/<td>/g, `<td class="${CLASSES.td}">`)
out = out.replace(/<td align="([^"]+)">/g, `<td align="$1" class="${CLASSES.td}">`)
// Inline <code> from marked comes wrapped in <pre> for code blocks, or
// bare for codespan. We distinguish by whether <pre> precedes it.
out = out.replace(
/<pre><code( class="language-[^"]*")?>/g,
(_, langAttr = '') =>
`<pre class="${CLASSES.pre}"><code${langAttr}>`,
)
// Remaining <code> tags are inline codespans.
out = out.replace(
/<code(?! class="language-)([^>]*)>/g,
(_, rest: string) => `<code${rest} class="${CLASSES.codespan}">`,
)
// Links: style + open http(s) in new tabs.
out = out.replace(
/<a href="([^"]+)"([^>]*)>/g,
(_, href: string, rest: string) => {
const isExternal = /^https?:\/\//i.test(href)
const extra = isExternal ? ' target="_blank" rel="noopener noreferrer"' : ''
return `<a href="${href}"${rest} class="${CLASSES.a}"${extra}>`
},
)
return { html: out, toc }
}
function extractTitle(raw: string): string | null {
const match = raw.match(/^#\s+(.+?)\s*$/m)
return match ? match[1].trim() : null
}
function stripTags(input: string): string {
return input.replace(/<[^>]*>/g, '').trim()
}
function slugify(input: string): string {
return input
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.trim()
.replace(/\s+/g, '-')
}

View File

@ -1,36 +0,0 @@
/**
* FAQ content used by both the rendered FAQ accordion on the landing
* page AND the FAQPage JSON-LD emitted for rich-result eligibility
* on search engines. Single source so they never drift.
*/
export interface FaqItem {
q: string
a: string
}
export const FAQ_ITEMS: FaqItem[] = [
{
q: 'Do I need my own WhatsApp Business API access?',
a: 'Yes. This CRM template for WhatsApp plugs into your existing Meta WhatsApp Business setup — you bring the phone number and access token, we provide the CRM tooling around it. Any Meta-approved BSP (Business Solution Provider) works.',
},
{
q: 'Can my whole team share one WhatsApp number?',
a: 'Yes. Assign conversations to specific agents, track who is responding to what, and hand off threads without losing context. All your agents work from a single shared inbox.',
},
{
q: 'How fast is setup?',
a: 'Most teams are live in under 30 minutes once their WhatsApp Business number has been approved by Meta. Paste your credentials in Settings, import contacts if you have them, and start replying.',
},
{
q: 'Who owns the data?',
a: 'You do. Everything lives in your own Supabase project — contacts, conversations, deals, automation logs. Export it anytime; there is no lock-in on the data layer.',
},
{
q: 'Can I send bulk messages and automated replies?',
a: 'Yes. Broadcasts send Meta-approved templates to segmented contact lists with delivery tracking. Automations run no-code flows triggered by new contacts, keywords, tag changes, and more.',
},
{
q: 'What about message templates?',
a: 'Templates you create in Meta are synced automatically. Use them from the inbox, broadcasts, or inside an automation step.',
},
]

View File

@ -1,56 +0,0 @@
/**
* Single source of truth for anything the SEO layer needs to know
* about the site — base URL, brand name, headline copy, social
* handles. Import from here everywhere (metadata, JSON-LD, sitemap,
* robots, OG image) so a rename or domain change is a one-file edit.
*/
/** Production origin — used to resolve all absolute URLs in metadata. */
export const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, '') || 'https://wacrm.tech'
export const SITE_NAME = 'CRM Template for WhatsApp'
export const SITE_TAGLINE = 'Run your WhatsApp® business from one inbox'
/** Default description — reused by layout metadata and structured data. */
export const SITE_DESCRIPTION =
'A CRM template for WhatsApp® built for small teams: shared inbox, contact hub, sales pipelines, broadcasts, and no-code automations — built on the official WhatsApp Business API.'
/**
* Keyword targets. Search engines largely ignore the meta keywords
* field, but a couple still use it and it's a cheap inclusion.
*/
export const SITE_KEYWORDS = [
'WhatsApp CRM',
'WhatsApp Business CRM',
'WhatsApp shared inbox',
'WhatsApp Business API',
'WhatsApp automation',
'WhatsApp broadcast',
'customer messaging platform',
'sales pipeline CRM',
'team inbox',
]
/** Public hero image for OG / Twitter previews. */
export const OG_IMAGE_PATH = '/opengraph-image'
export const OG_IMAGE_ALT = `${SITE_NAME} — ${SITE_TAGLINE}`
/** Organization info surfaced in JSON-LD. */
export const ORG_INFO = {
name: SITE_NAME,
legalName: 'CRM Template for WhatsApp',
url: SITE_URL,
logo: `${SITE_URL}/icon`,
}
/**
* Helper: absolute-URL a relative path using SITE_URL.
* Use this instead of string concat so trailing-slash handling stays
* consistent.
*/
export function absoluteUrl(path = '/'): string {
const trimmed = path.startsWith('/') ? path : `/${path}`
return `${SITE_URL}${trimmed}`
}

View File

@ -1,110 +0,0 @@
import {
absoluteUrl,
ORG_INFO,
SITE_DESCRIPTION,
SITE_NAME,
SITE_URL,
} from './site-config'
import { FAQ_ITEMS } from './faq-data'
/**
* Builders for the JSON-LD blobs we inject on the landing page. Each
* returns a plain object — the consumer wraps it in <script
* type="application/ld+json">. Keeping them typed as `Record<string,
* unknown>` so schema.org shapes don't leak into the rest of the app.
*/
type Ld = Record<string, unknown>
/** Generic site-level node. Helps search engines map the domain. */
export function websiteLd(): Ld {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: SITE_NAME,
url: SITE_URL,
description: SITE_DESCRIPTION,
inLanguage: 'en',
}
}
/** Publisher / brand. Paired with the WebSite node. */
export function organizationLd(): Ld {
return {
'@context': 'https://schema.org',
'@type': 'Organization',
name: ORG_INFO.name,
legalName: ORG_INFO.legalName,
url: ORG_INFO.url,
logo: ORG_INFO.logo,
}
}
/**
* Core product node. SoftwareApplication is the recommended type for
* web-based SaaS in Google's Rich Results guidelines. The free-tier
* `offers` block enables a "Free" badge in SERPs where eligible.
*/
export function softwareApplicationLd(): Ld {
return {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: SITE_NAME,
description: SITE_DESCRIPTION,
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web',
url: SITE_URL,
offers: {
'@type': 'Offer',
price: '0',
priceCurrency: 'USD',
availability: 'https://schema.org/InStock',
},
featureList: [
'Shared WhatsApp inbox',
'Contact management with tags and custom fields',
'Sales pipelines with kanban board',
'Broadcast campaigns via approved WhatsApp templates',
'No-code workflow automations',
'Real-time analytics dashboard',
],
}
}
/**
* FAQPage — eligible for the FAQ rich result on Google.
* We build this from the same source the UI renders, so answers
* in SERP match answers on the page.
*/
export function faqPageLd(): Ld {
return {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: FAQ_ITEMS.map((item) => ({
'@type': 'Question',
name: item.q,
acceptedAnswer: {
'@type': 'Answer',
text: item.a,
},
})),
}
}
/**
* Convenience: an array of the nodes we want on the landing page.
* The consumer component iterates and emits one <script> per node
* (Google prefers one schema per script tag).
*/
export function landingPageLd(): Ld[] {
return [
websiteLd(),
organizationLd(),
softwareApplicationLd(),
faqPageLd(),
]
}
// Kept exported for discoverability; unused by runtime code but handy
// for anyone building additional pages that need an absolute URL in
// their JSON-LD.
export { absoluteUrl }