mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #88 from ArnasDon/chore/extract-marketing-site
Extract marketing site to wacrm-site repo
This commit is contained in:
commit
1e6d70a878
38
README.md
38
README.md
@ -1,4 +1,4 @@
|
|||||||
# CRM Template for WhatsApp
|
# wacrm — CRM Template for WhatsApp
|
||||||
|
|
||||||
> Self-hostable CRM template for WhatsApp® — shared inbox, contacts,
|
> Self-hostable CRM template for WhatsApp® — shared inbox, contacts,
|
||||||
> sales pipelines, broadcasts, and no-code automations. Fork it, brand
|
> sales pipelines, broadcasts, and no-code automations. Fork it, brand
|
||||||
@ -10,6 +10,11 @@
|
|||||||
[](https://supabase.com)
|
[](https://supabase.com)
|
||||||
[](https://github.com/ArnasDon/wacrm/stargazers)
|
[](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
|
## What you get out of the box
|
||||||
|
|
||||||
- **Shared inbox** on the official WhatsApp Business API — multiple
|
- **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
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open <http://localhost:3000>.
|
Open <http://localhost:3000>. You'll be redirected to `/login` (or
|
||||||
|
`/dashboard` if already signed in).
|
||||||
Full setup — Supabase migrations, WhatsApp Business API config, and
|
|
||||||
production deploy — is in [`docs/`](./docs/README.md).
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
**Setup**
|
Full self-host documentation — Supabase migrations, WhatsApp Business
|
||||||
- [Getting started](./docs/getting-started.md)
|
API config, and production deploy — lives at
|
||||||
- [Supabase setup](./docs/supabase-setup.md)
|
**[wacrm.tech/docs](https://wacrm.tech/docs)**
|
||||||
- [WhatsApp setup](./docs/whatsapp-setup.md)
|
(source: [ArnasDon/wacrm-site](https://github.com/ArnasDon/wacrm-site)).
|
||||||
- [Environment variables](./docs/environment-variables.md)
|
|
||||||
|
|
||||||
**Deploy**
|
Key pages:
|
||||||
- [Deploy on Hostinger](./docs/deployment-hostinger.md)
|
- [Getting started](https://wacrm.tech/docs/getting-started)
|
||||||
- [Automations cron](./docs/automations-and-cron.md)
|
- [Supabase setup](https://wacrm.tech/docs/supabase-setup)
|
||||||
|
- [WhatsApp setup](https://wacrm.tech/docs/whatsapp-setup)
|
||||||
**Reference**
|
- [Environment variables](https://wacrm.tech/docs/environment-variables)
|
||||||
- [Architecture](./docs/architecture.md) — stack, folder layout,
|
- [Deploy on Hostinger](https://wacrm.tech/docs/deployment-hostinger)
|
||||||
request lifecycle
|
- [Architecture](https://wacrm.tech/docs/architecture)
|
||||||
- [Troubleshooting](./docs/troubleshooting.md)
|
- [Troubleshooting](https://wacrm.tech/docs/troubleshooting)
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
|
|||||||
@ -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>.
|
|
||||||
@ -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` |
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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)
|
|
||||||
@ -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)
|
|
||||||
@ -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)
|
|
||||||
@ -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.
|
|
||||||
@ -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
13
package-lock.json
generated
@ -19,7 +19,6 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"marked": "^18.0.2",
|
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
@ -7078,18 +7077,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
|||||||
@ -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.",
|
"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",
|
"license": "MIT",
|
||||||
"author": "Arnas Donauskas",
|
"author": "Arnas Donauskas",
|
||||||
"homepage": "https://wacrm.tech",
|
"homepage": "https://github.com/ArnasDon/wacrm",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/ArnasDon/wacrm.git"
|
"url": "git+https://github.com/ArnasDon/wacrm.git"
|
||||||
@ -47,7 +47,6 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"marked": "^18.0.2",
|
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
|||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -2,71 +2,21 @@ import type { Metadata, Viewport } from "next";
|
|||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import "./globals.css";
|
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({
|
const inter = Inter({
|
||||||
variable: "--font-sans",
|
variable: "--font-sans",
|
||||||
subsets: ["latin"],
|
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 = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(SITE_URL),
|
|
||||||
title: {
|
title: {
|
||||||
default: `${SITE_NAME} — ${SITE_TAGLINE}`,
|
default: "wacrm",
|
||||||
template: `%s — ${SITE_NAME}`,
|
template: "%s — wacrm",
|
||||||
},
|
|
||||||
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"],
|
|
||||||
},
|
},
|
||||||
|
description: "Self-hostable CRM template for WhatsApp.",
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: false,
|
||||||
follow: true,
|
follow: false,
|
||||||
googleBot: {
|
|
||||||
index: true,
|
|
||||||
follow: true,
|
|
||||||
"max-image-preview": "large",
|
|
||||||
"max-snippet": -1,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
icons: {
|
icons: {
|
||||||
icon: [{ url: "/icon" }],
|
icon: [{ url: "/icon" }],
|
||||||
@ -78,7 +28,6 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dark theme color for the mobile browser chrome.
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: "#020617",
|
themeColor: "#020617",
|
||||||
colorScheme: "dark",
|
colorScheme: "dark",
|
||||||
|
|||||||
@ -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 </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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
119
src/app/page.tsx
119
src/app/page.tsx
@ -1,118 +1,5 @@
|
|||||||
import type { Metadata } from 'next'
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
import { LandingNav } from '@/components/landing/nav'
|
export default function RootPage() {
|
||||||
import { Hero } from '@/components/landing/hero'
|
redirect('/dashboard')
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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) }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -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, '-')
|
|
||||||
}
|
|
||||||
@ -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.',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@ -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}`
|
|
||||||
}
|
|
||||||
@ -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 }
|
|
||||||
Loading…
Reference in New Issue
Block a user