This commit is contained in:
parent
62b4d389c8
commit
c64f7ffa9e
12
.dockerignore
Normal file
12
.dockerignore
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
*.db
|
||||
docker
|
||||
.github
|
||||
.w4c
|
||||
tests
|
||||
README.md
|
||||
coverage
|
||||
15
.env.example
Normal file
15
.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# --- Core -------------------------------------------------------------------
|
||||
# SQLite in development; point at Postgres in production (see README).
|
||||
DATABASE_URL="file:./dev.db"
|
||||
# Public origin used for absolute URLs (sitemap, OG, checkout redirects).
|
||||
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
|
||||
|
||||
# --- Payments (optional) ----------------------------------------------------
|
||||
# Leave empty to use the built-in mock provider (no keys needed).
|
||||
STRIPE_SECRET_KEY=""
|
||||
STRIPE_WEBHOOK_SECRET=""
|
||||
|
||||
# --- Email (optional) -------------------------------------------------------
|
||||
# Leave empty to use the built-in log provider (no keys needed).
|
||||
RESEND_API_KEY=""
|
||||
EMAIL_FROM="Acme <noreply@example.com>"
|
||||
7
.eslintrc.json
Normal file
7
.eslintrc.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"rules": {
|
||||
"@next/next/no-img-element": "off",
|
||||
"react/no-unescaped-entities": "off"
|
||||
}
|
||||
}
|
||||
33
.github/workflows/ci.yml
vendored
Normal file
33
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: "file:./ci.db"
|
||||
NEXT_PUBLIC_SITE_URL: "http://localhost:3000"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run typecheck
|
||||
- run: npm run db:setup
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
- name: e2e smoke
|
||||
run: |
|
||||
npm start &
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://127.0.0.1:3000/api/health >/dev/null; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
npm run test:e2e
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
node_modules
|
||||
.next
|
||||
out
|
||||
dist
|
||||
coverage
|
||||
.env
|
||||
.env.local
|
||||
*.db
|
||||
*.db-journal
|
||||
.vercel
|
||||
next-env.d.ts
|
||||
BIN
.w4c/preview.png
Normal file
BIN
.w4c/preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
38
.w4c/qa.json
Normal file
38
.w4c/qa.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"install": "npm ci",
|
||||
"setup": "npm run db:setup",
|
||||
"lint": "npm run lint",
|
||||
"typecheck": "npm run typecheck",
|
||||
"unit": "npm run test",
|
||||
"build": "npm run build",
|
||||
"start": "npm run start",
|
||||
"e2e": "node tests/e2e/smoke.mjs",
|
||||
"port": 3000,
|
||||
"health": "/api/health",
|
||||
"env": {
|
||||
"DATABASE_URL": "file:./dev.db",
|
||||
"NEXT_PUBLIC_SITE_URL": "http://localhost:3000"
|
||||
},
|
||||
"routes": [
|
||||
{ "path": "/", "expect": [200] },
|
||||
{ "path": "/pricing", "expect": [200] },
|
||||
{ "path": "/login", "expect": [200] },
|
||||
{ "path": "/register", "expect": [200] },
|
||||
{ "path": "/legal/privacy", "expect": [200] },
|
||||
{ "path": "/dashboard", "expect": [307, 302] },
|
||||
{ "path": "/admin", "expect": [307, 302] },
|
||||
{ "path": "/admin/customers", "expect": [307, 302] },
|
||||
{ "path": "/admin/deals", "expect": [307, 302] },
|
||||
{ "path": "/admin/import", "expect": [307, 302] },
|
||||
{ "path": "/admin/audit", "expect": [307, 302] },
|
||||
{ "path": "/admin/users", "expect": [307, 302] },
|
||||
{ "path": "/api/health", "expect": [200] },
|
||||
{ "path": "/api/auth/me", "expect": [401] },
|
||||
{ "path": "/api/admin/customers", "expect": [401] },
|
||||
{ "path": "/api/admin/audit", "expect": [401] },
|
||||
{ "path": "/api/admin/export/customers", "expect": [401] },
|
||||
{ "path": "/sitemap.xml", "expect": [200] },
|
||||
{ "path": "/robots.txt", "expect": [200] }
|
||||
],
|
||||
"timeout": { "install": 900, "build": 900, "boot": 120 }
|
||||
}
|
||||
28
.w4c/template.json
Normal file
28
.w4c/template.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"schema": 2,
|
||||
"system": "v2",
|
||||
"id": "admin-next",
|
||||
"case": "admin",
|
||||
"stack": "next",
|
||||
"title": "Admin / CRM — Next.js",
|
||||
"description": "Ready-to-run internal tool: an entity registry with CRUD, roles, CSV import/export and an audit log.",
|
||||
"tags": ["internal", "crm", "admin", "csv"],
|
||||
"preview": ".w4c/preview.png",
|
||||
"demoUrl": "https://admin-next.demo.wiz4chat.com",
|
||||
"agentId": "w4c-start2",
|
||||
"defaultName": "my-crm",
|
||||
"featured": true,
|
||||
"env": [
|
||||
{ "key": "DATABASE_URL", "required": false, "note": "SQLite by default; Postgres in production" },
|
||||
{ "key": "NEXT_PUBLIC_SITE_URL", "required": false, "note": "Public origin for sitemap/OG" }
|
||||
],
|
||||
"modules": [
|
||||
{ "id": "s3-storage", "skill": "integration-s3-storage" },
|
||||
{ "id": "resend-email", "skill": "integration-resend-email" }
|
||||
],
|
||||
"seededAccounts": [
|
||||
{ "role": "admin", "email": "admin@example.com", "password": "password" },
|
||||
{ "role": "editor", "email": "editor@example.com", "password": "password" },
|
||||
{ "role": "viewer", "email": "viewer@example.com", "password": "password" }
|
||||
]
|
||||
}
|
||||
66
README.md
66
README.md
|
|
@ -1,3 +1,65 @@
|
|||
# templates2.Admin.Next
|
||||
# Admin / CRM — Next.js (templates2)
|
||||
|
||||
Ready-to-run internal tool: an entity registry with CRUD, roles, CSV import/export and an audit log.
|
||||
A **ready-to-run** internal tool built on the v2 Next.js base: an entity registry (Customers,
|
||||
Deals) with CRUD, roles, CSV import/export and an audit log. Everything works on the first run —
|
||||
SQLite + mock providers need no keys.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
cp .env.example .env # SQLite + mock providers by default — no keys needed
|
||||
npm run db:setup # prisma db push + seed (20 customers, 15 deals, audit trail)
|
||||
npm run dev # http://localhost:3000/admin
|
||||
```
|
||||
|
||||
## Demo accounts (seeded)
|
||||
|
||||
| Role | Email | Password | Can do |
|
||||
|---|---|---|---|
|
||||
| admin | `admin@example.com` | `password` | read, create/edit, delete, import/export |
|
||||
| editor | `editor@example.com` | `password` | read, create/edit, import/export |
|
||||
| viewer | `viewer@example.com` | `password` | read only |
|
||||
|
||||
Roles are enforced **server-side** on every admin API route (`lib/server/guard.ts` +
|
||||
`lib/rbac.ts`); the UI merely hides what a role cannot do.
|
||||
|
||||
## What this case adds on top of the base
|
||||
|
||||
- **Entity registry** — `Customer` and `Deal` with list (search, column filter, sort, pagination),
|
||||
detail, create/edit and delete (delete asks for confirmation). `components/admin/CustomersManager`
|
||||
and `DealsManager` drive the `/admin/customers` and `/admin/deals` pages.
|
||||
- **Dashboard** — counters (customers, leads, deals, won value), a deals-by-stage bar chart and
|
||||
recent activity on `/admin` (`lib/server/crm.ts#overview`).
|
||||
- **CSV import** — `/admin/import` validates a CSV, shows a per-row error report and a preview of
|
||||
the first valid rows, then commits. Export is a `text/csv` download.
|
||||
- **Audit log** — every mutation writes an `AuditLog` row (`lib/server/audit.ts`); `/admin/audit`
|
||||
lists it and filters by action.
|
||||
- **Loading / empty / error states** on every list and the import wizard.
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | Role |
|
||||
|---|---|---|
|
||||
| GET | `/api/admin/customers?q=&status=&sort=&page=` | viewer+ |
|
||||
| POST | `/api/admin/customers` | editor+ |
|
||||
| GET / PATCH / DELETE | `/api/admin/customers/:id` | viewer+ / editor+ / admin |
|
||||
| GET / POST | `/api/admin/deals` | viewer+ / editor+ |
|
||||
| PATCH / DELETE | `/api/admin/deals/:id` | editor+ / admin |
|
||||
| POST | `/api/admin/import/customers` (`{ csv, commit }`) | editor+ |
|
||||
| GET | `/api/admin/export/customers` | viewer+ |
|
||||
| GET | `/api/admin/audit?action=` | viewer+ |
|
||||
|
||||
## Data model
|
||||
|
||||
`prisma/schema.prisma`: the base `User`, `Session`, `Subscription`, `AuditLog` plus `Customer`
|
||||
and `Deal`. SQLite in development; point `DATABASE_URL` at Postgres for production.
|
||||
|
||||
## Quality
|
||||
|
||||
```bash
|
||||
node ../templates2/qa.mjs Admin.Next # from the repo root: node templates2/qa.mjs Admin.Next
|
||||
```
|
||||
|
||||
The harness runs validate → clone → install → lint → typecheck → unit → build → boot → routes →
|
||||
e2e → screenshot. See [`../CONTRACT.md`](../CONTRACT.md) for the Definition of Done.
|
||||
|
|
|
|||
59
app/(app)/dashboard/page.tsx
Normal file
59
app/(app)/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import Link from 'next/link';
|
||||
import { Badge, Card } from '@/components/ui';
|
||||
import { requireUser } from '@/lib/server/auth';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { getPlan } from '@/lib/plans';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await requireUser();
|
||||
const sub = await db.subscription.findUnique({ where: { userId: user.id } });
|
||||
const plan = getPlan(sub?.plan);
|
||||
const usedCalls = await db.auditLog.count({ where: { actorId: user.id } });
|
||||
|
||||
const usage = [
|
||||
{ label: 'Seats', used: 1, limit: plan.limits.seats },
|
||||
{ label: 'API calls', used: usedCalls, limit: plan.limits.apiCalls },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t('dashboard.title')}</h1>
|
||||
<p className="muted mt-1">{t('dashboard.welcome', { name: user.name || user.email })}</p>
|
||||
</div>
|
||||
<Badge tone={plan.id === 'pro' ? 'brand' : 'neutral'}>{plan.name}</Badge>
|
||||
</header>
|
||||
|
||||
<Card title={t('dashboard.plan')} actions={<Link href="/pricing" className="btn-secondary">{t('pricing.choose')}</Link>}>
|
||||
<p className="muted text-sm">
|
||||
{plan.name} — ${plan.price} / month
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card title={t('dashboard.usage')}>
|
||||
<ul className="space-y-4">
|
||||
{usage.map((u) => {
|
||||
const pct = Math.min(100, Math.round((u.used / u.limit) * 100));
|
||||
return (
|
||||
<li key={u.label}>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span>{u.label}</span>
|
||||
<span className="muted">
|
||||
{u.used} / {u.limit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full" style={{ background: 'var(--border)' }}>
|
||||
<div className="h-full rounded-full" style={{ width: `${pct}%`, background: 'var(--color-brand-600)' }} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
app/(app)/layout.tsx
Normal file
12
app/(app)/layout.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { SiteHeader } from '@/components/SiteHeader';
|
||||
import { SiteFooter } from '@/components/SiteFooter';
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader />
|
||||
<main className="page flex-1 py-10">{children}</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
app/(auth)/layout.tsx
Normal file
13
app/(auth)/layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import Link from 'next/link';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-5 py-12">
|
||||
<Link href="/" className="mb-8 text-xl font-bold">
|
||||
{t('brand.name')}
|
||||
</Link>
|
||||
<div className="w-full max-w-sm">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
app/(auth)/login/page.tsx
Normal file
15
app/(auth)/login/page.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Suspense } from 'react';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { AuthForm } from '@/components/AuthForm';
|
||||
import { getCurrentUser } from '@/lib/server/auth';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function LoginPage() {
|
||||
if (await getCurrentUser()) redirect('/dashboard');
|
||||
return (
|
||||
<Suspense fallback={<p className="muted text-center">Loading…</p>}>
|
||||
<AuthForm mode="login" />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
15
app/(auth)/register/page.tsx
Normal file
15
app/(auth)/register/page.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { Suspense } from 'react';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { AuthForm } from '@/components/AuthForm';
|
||||
import { getCurrentUser } from '@/lib/server/auth';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function RegisterPage() {
|
||||
if (await getCurrentUser()) redirect('/dashboard');
|
||||
return (
|
||||
<Suspense fallback={<p className="muted text-center">Loading…</p>}>
|
||||
<AuthForm mode="register" />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
12
app/(marketing)/layout.tsx
Normal file
12
app/(marketing)/layout.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { SiteHeader } from '@/components/SiteHeader';
|
||||
import { SiteFooter } from '@/components/SiteFooter';
|
||||
|
||||
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader />
|
||||
<main className="flex-1">{children}</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
app/(marketing)/legal/privacy/page.tsx
Normal file
10
app/(marketing)/legal/privacy/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export default function PrivacyPage() {
|
||||
return (
|
||||
<article className="page prose max-w-3xl py-16">
|
||||
<h1 className="text-3xl font-bold">Privacy Policy</h1>
|
||||
<p className="muted mt-4">
|
||||
This is a placeholder legal page. Replace it with your own policy before going live.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
10
app/(marketing)/legal/terms/page.tsx
Normal file
10
app/(marketing)/legal/terms/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export default function TermsPage() {
|
||||
return (
|
||||
<article className="page prose max-w-3xl py-16">
|
||||
<h1 className="text-3xl font-bold">Terms of Service</h1>
|
||||
<p className="muted mt-4">
|
||||
This is a placeholder legal page. Replace it with your own terms before going live.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
44
app/(marketing)/page.tsx
Normal file
44
app/(marketing)/page.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import Link from 'next/link';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
const features = ['auth', 'billing', 'admin', 'seo'] as const;
|
||||
|
||||
export default function HomePage() {
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: t('brand.name'),
|
||||
url: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
<section className="page py-20 text-center">
|
||||
<h1 className="mx-auto max-w-3xl text-4xl font-extrabold tracking-tight sm:text-5xl">
|
||||
{t('home.title')}
|
||||
</h1>
|
||||
<p className="muted mx-auto mt-5 max-w-2xl text-lg">{t('home.subtitle')}</p>
|
||||
<div className="mt-8 flex justify-center gap-3">
|
||||
<Link href="/register" className="btn-primary">
|
||||
{t('home.cta')}
|
||||
</Link>
|
||||
<Link href="/pricing" className="btn-secondary">
|
||||
{t('home.secondaryCta')}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="page pb-24">
|
||||
<h2 className="mb-8 text-center text-2xl font-bold">{t('home.featuresTitle')}</h2>
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{features.map((key) => (
|
||||
<div key={key} className="card">
|
||||
<h3 className="text-base font-semibold">{t(`home.features.${key}.title`)}</h3>
|
||||
<p className="muted mt-2 text-sm">{t(`home.features.${key}.body`)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
app/(marketing)/pricing/page.tsx
Normal file
74
app/(marketing)/pricing/page.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import Link from 'next/link';
|
||||
import { Badge } from '@/components/ui';
|
||||
import { UpgradeButton } from '@/components/UpgradeButton';
|
||||
import { getCurrentUser } from '@/lib/server/auth';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { PLANS, PLAN_IDS, getPlan } from '@/lib/plans';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function PricingPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ billing?: string }>;
|
||||
}) {
|
||||
const { billing } = await searchParams;
|
||||
const user = await getCurrentUser();
|
||||
const sub = user ? await db.subscription.findUnique({ where: { userId: user.id } }) : null;
|
||||
const currentPlan = getPlan(sub?.plan).id;
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: t('brand.name'),
|
||||
applicationCategory: 'BusinessApplication',
|
||||
offers: PLAN_IDS.map((id) => ({ '@type': 'Offer', name: PLANS[id].name, price: PLANS[id].price, priceCurrency: 'USD' })),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page py-16">
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||
<h1 className="text-center text-3xl font-bold">{t('pricing.title')}</h1>
|
||||
<p className="muted mt-3 text-center">{t('pricing.subtitle')}</p>
|
||||
{billing === 'success' && (
|
||||
<p className="mx-auto mt-6 max-w-md rounded-lg border border-emerald-500 bg-emerald-50 px-4 py-3 text-center text-sm text-emerald-800">
|
||||
{t('pricing.upgraded')}
|
||||
</p>
|
||||
)}
|
||||
<div className="mx-auto mt-10 grid max-w-4xl gap-6 sm:grid-cols-2">
|
||||
{PLAN_IDS.map((id) => {
|
||||
const plan = PLANS[id];
|
||||
const isCurrent = user && currentPlan === id;
|
||||
return (
|
||||
<div key={id} className="card flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">{plan.name}</h2>
|
||||
{isCurrent && <Badge tone="brand">Current</Badge>}
|
||||
</div>
|
||||
<p className="mt-4 text-3xl font-bold">
|
||||
{formatCurrency(plan.price)}
|
||||
<span className="muted text-base font-normal"> {t('pricing.perMonth')}</span>
|
||||
</p>
|
||||
<ul className="muted mt-5 flex-1 space-y-2 text-sm">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f}>• {f}</li>
|
||||
))}
|
||||
</ul>
|
||||
{isCurrent ? (
|
||||
<p className="mt-6 text-center text-sm font-medium text-emerald-600">{t('pricing.current')}</p>
|
||||
) : user ? (
|
||||
<UpgradeButton plan={id} label={t('pricing.choose')} />
|
||||
) : (
|
||||
<Link href={`/login?next=/pricing`} className="btn-primary mt-6 w-full">
|
||||
{t('pricing.choose')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
app/admin/audit/page.tsx
Normal file
62
app/admin/audit/page.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import Link from 'next/link';
|
||||
import { requireStaff } from '@/lib/server/auth';
|
||||
import { listAudit } from '@/lib/server/crm';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminAuditPage({ searchParams }: { searchParams: Promise<{ action?: string }> }) {
|
||||
await requireStaff();
|
||||
const { action } = await searchParams;
|
||||
const filter = action?.trim() || '';
|
||||
const entries = await listAudit({ action: filter || undefined, take: 100 });
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t('admin.audit')}</h1>
|
||||
|
||||
<form className="card flex flex-wrap items-end gap-3" method="get">
|
||||
<label className="text-sm">
|
||||
{t('admin.filterByAction')}
|
||||
<input className="input mt-1" name="action" defaultValue={filter} placeholder={t('admin.actionPlaceholder')} />
|
||||
</label>
|
||||
<button type="submit" className="btn-secondary">
|
||||
{t('admin.filter')}
|
||||
</button>
|
||||
{filter && (
|
||||
<Link href="/admin/audit" className="btn-ghost">
|
||||
{t('admin.clear')}
|
||||
</Link>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="muted py-6 text-center text-sm">{t('admin.empty')}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: 'var(--border)' }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="muted text-left text-xs uppercase">
|
||||
<tr>
|
||||
<th className="px-4 py-3">{t('admin.action')}</th>
|
||||
<th className="px-4 py-3">{t('admin.target')}</th>
|
||||
<th className="px-4 py-3">{t('admin.actor')}</th>
|
||||
<th className="px-4 py-3">{t('admin.when')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.id} className="border-t" style={{ borderColor: 'var(--border)' }}>
|
||||
<td className="px-4 py-3 font-medium">{e.action}</td>
|
||||
<td className="px-4 py-3">{e.target ?? t('admin.none')}</td>
|
||||
<td className="px-4 py-3">{e.actor?.email ?? 'system'}</td>
|
||||
<td className="px-4 py-3">{formatDate(e.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
app/admin/customers/page.tsx
Normal file
10
app/admin/customers/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { CustomersManager } from '@/components/admin/CustomersManager';
|
||||
import { requireStaff } from '@/lib/server/auth';
|
||||
import { canDelete, canWrite } from '@/lib/rbac';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminCustomersPage() {
|
||||
const staff = await requireStaff();
|
||||
return <CustomersManager canWrite={canWrite(staff.role)} canDelete={canDelete(staff.role)} />;
|
||||
}
|
||||
10
app/admin/deals/page.tsx
Normal file
10
app/admin/deals/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { DealsManager } from '@/components/admin/DealsManager';
|
||||
import { requireStaff } from '@/lib/server/auth';
|
||||
import { canDelete, canWrite } from '@/lib/rbac';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminDealsPage() {
|
||||
const staff = await requireStaff();
|
||||
return <DealsManager canWrite={canWrite(staff.role)} canDelete={canDelete(staff.role)} />;
|
||||
}
|
||||
10
app/admin/import/page.tsx
Normal file
10
app/admin/import/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { ImportWizard } from '@/components/admin/ImportWizard';
|
||||
import { requireStaff } from '@/lib/server/auth';
|
||||
import { canImport } from '@/lib/rbac';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminImportPage() {
|
||||
const staff = await requireStaff();
|
||||
return <ImportWizard canImport={canImport(staff.role)} />;
|
||||
}
|
||||
31
app/admin/layout.tsx
Normal file
31
app/admin/layout.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import Link from 'next/link';
|
||||
import { AdminNav } from '@/components/AdminNav';
|
||||
import { requireStaff } from '@/lib/server/auth';
|
||||
import { roleLabel } from '@/lib/rbac';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const staff = await requireStaff();
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="border-b" style={{ borderColor: 'var(--border)' }}>
|
||||
<div className="page flex h-16 items-center justify-between">
|
||||
<Link href="/" className="text-lg font-bold">
|
||||
{t('brand.name')} <span className="muted font-normal">/ {t('admin.title')}</span>
|
||||
</Link>
|
||||
<span className="muted text-sm">
|
||||
{staff.email} · {roleLabel(staff.role)}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className="page flex flex-1 flex-col gap-6 py-8 md:flex-row">
|
||||
<aside className="md:w-52 md:shrink-0">
|
||||
<AdminNav role={staff.role} />
|
||||
</aside>
|
||||
<main className="min-w-0 flex-1">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
app/admin/page.tsx
Normal file
75
app/admin/page.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import Link from 'next/link';
|
||||
import { Card } from '@/components/ui';
|
||||
import { requireStaff } from '@/lib/server/auth';
|
||||
import { overview } from '@/lib/server/crm';
|
||||
import { formatCurrency, formatDate } from '@/lib/utils';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminOverviewPage() {
|
||||
await requireStaff();
|
||||
const data = await overview();
|
||||
const maxStage = Math.max(1, ...data.byStage.map((s) => s.count));
|
||||
|
||||
const counters = [
|
||||
{ label: t('admin.customersTotal'), value: String(data.customers), href: '/admin/customers' },
|
||||
{ label: t('admin.leads'), value: String(data.leads), href: '/admin/customers?status=lead' },
|
||||
{ label: t('admin.dealsTotal'), value: String(data.deals), href: '/admin/deals' },
|
||||
{ label: t('admin.wonValue'), value: formatCurrency(data.wonValue), href: '/admin/deals?stage=won' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t('admin.overview')}</h1>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{counters.map((c) => (
|
||||
<Link key={c.label} href={c.href} className="card block">
|
||||
<p className="muted text-sm">{c.label}</p>
|
||||
<p className="mt-2 text-3xl font-bold">{c.value}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card title={t('admin.dealStages')}>
|
||||
<ul className="space-y-4">
|
||||
{data.byStage.map((s) => (
|
||||
<li key={s.stage}>
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span>{t(`admin.${s.stage}`)}</span>
|
||||
<span className="muted">{s.count}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full" style={{ background: 'var(--border)' }}>
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: `${Math.round((s.count / maxStage) * 100)}%`, background: 'var(--color-brand-600)' }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card title={t('admin.recentActivity')} actions={<Link href="/admin/audit" className="btn-secondary">{t('admin.audit')}</Link>}>
|
||||
{data.recent.length === 0 ? (
|
||||
<p className="muted text-sm">{t('common.empty')}</p>
|
||||
) : (
|
||||
<ul className="divide-y" style={{ borderColor: 'var(--border)' }}>
|
||||
{data.recent.map((entry) => (
|
||||
<li key={entry.id} className="flex items-center justify-between gap-4 py-2 text-sm">
|
||||
<span>
|
||||
<span className="font-medium">{entry.action}</span>
|
||||
{entry.target ? <span className="muted"> → {entry.target}</span> : null}
|
||||
</span>
|
||||
<span className="muted shrink-0 text-xs">
|
||||
{entry.actor} · {formatDate(entry.createdAt)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
app/admin/users/page.tsx
Normal file
51
app/admin/users/page.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { Badge, EmptyState } from '@/components/ui';
|
||||
import { UserActions } from '@/components/admin/UserActions';
|
||||
import { requireAdmin } from '@/lib/server/auth';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
await requireAdmin();
|
||||
const users = await db.user.findMany({ orderBy: { createdAt: 'asc' }, include: { subscription: true } });
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t('admin.users')}</h1>
|
||||
{users.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: 'var(--border)' }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="muted text-left text-xs uppercase">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Email</th>
|
||||
<th className="px-4 py-3">Role</th>
|
||||
<th className="px-4 py-3">Plan</th>
|
||||
<th className="px-4 py-3">Created</th>
|
||||
<th className="px-4 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-t" style={{ borderColor: 'var(--border)' }}>
|
||||
<td className="px-4 py-3 font-medium">{u.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge tone={u.role === 'admin' ? 'brand' : 'neutral'}>{u.role}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">{u.subscription?.plan ?? 'free'}</td>
|
||||
<td className="px-4 py-3">{formatDate(u.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<UserActions id={u.id} role={u.role} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
app/api/admin/audit/route.ts
Normal file
23
app/api/admin/audit/route.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { apiStaff } from '@/lib/server/guard';
|
||||
import { listAudit } from '@/lib/server/crm';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** Audit log, newest first; optional `?action=` substring filter. */
|
||||
export async function GET(request: Request) {
|
||||
const g = await apiStaff();
|
||||
if (g.error) return g.error;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const rows = await listAudit({ action: searchParams.get('action')?.trim() || undefined, take: 50 });
|
||||
return NextResponse.json({
|
||||
entries: rows.map((r) => ({
|
||||
id: r.id,
|
||||
action: r.action,
|
||||
target: r.target,
|
||||
actor: r.actor?.email ?? 'system',
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
48
app/api/admin/customers/[id]/route.ts
Normal file
48
app/api/admin/customers/[id]/route.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { apiDelete, apiStaff, apiWrite } from '@/lib/server/guard';
|
||||
import { customerSchema, deleteCustomer, getCustomer, updateCustomer } from '@/lib/server/crm';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(_request: Request, { params }: Ctx) {
|
||||
const g = await apiStaff();
|
||||
if (g.error) return g.error;
|
||||
|
||||
const { id } = await params;
|
||||
const customer = await getCustomer(id);
|
||||
if (!customer) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
return NextResponse.json({ customer });
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: Ctx) {
|
||||
const g = await apiWrite();
|
||||
if (g.error) return g.error;
|
||||
|
||||
const { id } = await params;
|
||||
const parsed = customerSchema.safeParse(await request.json().catch(() => ({})));
|
||||
if (!parsed.success) return NextResponse.json({ error: 'Invalid customer', issues: parsed.error.flatten() }, { status: 400 });
|
||||
|
||||
try {
|
||||
const result = await updateCustomer(id, parsed.data, g.user);
|
||||
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 409 });
|
||||
return NextResponse.json({ customer: result.customer });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: Ctx) {
|
||||
const g = await apiDelete();
|
||||
if (g.error) return g.error;
|
||||
|
||||
const { id } = await params;
|
||||
try {
|
||||
const result = await deleteCustomer(id, g.user);
|
||||
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 403 });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
34
app/api/admin/customers/route.ts
Normal file
34
app/api/admin/customers/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { apiStaff, apiWrite } from '@/lib/server/guard';
|
||||
import { createCustomer, customerSchema, listCustomers } from '@/lib/server/crm';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const g = await apiStaff();
|
||||
if (g.error) return g.error;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = Math.max(1, Number(searchParams.get('page') ?? '1') || 1);
|
||||
const pageSize = Math.min(50, Math.max(1, Number(searchParams.get('pageSize') ?? '10') || 10));
|
||||
const result = await listCustomers({
|
||||
q: searchParams.get('q')?.trim() || undefined,
|
||||
status: searchParams.get('status') || undefined,
|
||||
sort: searchParams.get('sort') || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const g = await apiWrite();
|
||||
if (g.error) return g.error;
|
||||
|
||||
const parsed = customerSchema.safeParse(await request.json().catch(() => ({})));
|
||||
if (!parsed.success) return NextResponse.json({ error: 'Invalid customer', issues: parsed.error.flatten() }, { status: 400 });
|
||||
|
||||
const result = await createCustomer(parsed.data, g.user);
|
||||
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 409 });
|
||||
return NextResponse.json({ customer: result.customer }, { status: 201 });
|
||||
}
|
||||
38
app/api/admin/deals/[id]/route.ts
Normal file
38
app/api/admin/deals/[id]/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { apiDelete, apiWrite } from '@/lib/server/guard';
|
||||
import { dealSchema, deleteDeal, updateDeal } from '@/lib/server/crm';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function PATCH(request: Request, { params }: Ctx) {
|
||||
const g = await apiWrite();
|
||||
if (g.error) return g.error;
|
||||
|
||||
const { id } = await params;
|
||||
const parsed = dealSchema.safeParse(await request.json().catch(() => ({})));
|
||||
if (!parsed.success) return NextResponse.json({ error: 'Invalid deal', issues: parsed.error.flatten() }, { status: 400 });
|
||||
|
||||
try {
|
||||
const result = await updateDeal(id, parsed.data, g.user);
|
||||
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 403 });
|
||||
return NextResponse.json({ deal: result.deal });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: Ctx) {
|
||||
const g = await apiDelete();
|
||||
if (g.error) return g.error;
|
||||
|
||||
const { id } = await params;
|
||||
try {
|
||||
const result = await deleteDeal(id, g.user);
|
||||
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 403 });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
28
app/api/admin/deals/route.ts
Normal file
28
app/api/admin/deals/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { apiStaff, apiWrite } from '@/lib/server/guard';
|
||||
import { createDeal, dealSchema, listDeals } from '@/lib/server/crm';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const g = await apiStaff();
|
||||
if (g.error) return g.error;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = Math.max(1, Number(searchParams.get('page') ?? '1') || 1);
|
||||
const pageSize = Math.min(50, Math.max(1, Number(searchParams.get('pageSize') ?? '10') || 10));
|
||||
const result = await listDeals({ stage: searchParams.get('stage') || undefined, page, pageSize });
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const g = await apiWrite();
|
||||
if (g.error) return g.error;
|
||||
|
||||
const parsed = dealSchema.safeParse(await request.json().catch(() => ({})));
|
||||
if (!parsed.success) return NextResponse.json({ error: 'Invalid deal', issues: parsed.error.flatten() }, { status: 400 });
|
||||
|
||||
const result = await createDeal(parsed.data, g.user);
|
||||
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 403 });
|
||||
return NextResponse.json({ deal: result.deal }, { status: 201 });
|
||||
}
|
||||
18
app/api/admin/export/customers/route.ts
Normal file
18
app/api/admin/export/customers/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { apiStaff } from '@/lib/server/guard';
|
||||
import { exportCustomersCsv } from '@/lib/server/crm';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** Download every customer as a CSV attachment. */
|
||||
export async function GET() {
|
||||
const g = await apiStaff();
|
||||
if (g.error) return g.error;
|
||||
|
||||
const csv = await exportCustomersCsv();
|
||||
return new Response(csv, {
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="customers.csv"',
|
||||
},
|
||||
});
|
||||
}
|
||||
21
app/api/admin/import/customers/route.ts
Normal file
21
app/api/admin/import/customers/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { apiImport } from '@/lib/server/guard';
|
||||
import { importCustomers } from '@/lib/server/crm';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const bodySchema = z.object({ csv: z.string().min(1), commit: z.boolean().optional() });
|
||||
|
||||
/** CSV import: `commit:false` returns a validated preview, `commit:true` writes it. */
|
||||
export async function POST(request: Request) {
|
||||
const g = await apiImport();
|
||||
if (g.error) return g.error;
|
||||
|
||||
const parsed = bodySchema.safeParse(await request.json().catch(() => ({})));
|
||||
if (!parsed.success) return NextResponse.json({ error: 'Provide CSV text' }, { status: 400 });
|
||||
|
||||
const result = await importCustomers(parsed.data.csv, parsed.data.commit === true, g.user);
|
||||
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 403 });
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
43
app/api/admin/users/[id]/route.ts
Normal file
43
app/api/admin/users/[id]/route.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { getCurrentUser } from '@/lib/server/auth';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { recordAudit } from '@/lib/server/audit';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const patchSchema = z.object({ role: z.enum(['user', 'admin']) });
|
||||
|
||||
async function requireAdminApi() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
||||
if (user.role !== 'admin') return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
|
||||
return { user };
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const auth = await requireAdminApi();
|
||||
if (auth.error) return auth.error;
|
||||
const { id } = await params;
|
||||
|
||||
const parsed = patchSchema.safeParse(await request.json().catch(() => ({})));
|
||||
if (!parsed.success) return NextResponse.json({ error: 'Invalid role' }, { status: 400 });
|
||||
if (id === auth.user!.id && parsed.data.role !== 'admin') {
|
||||
return NextResponse.json({ error: 'You cannot remove your own admin role.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await db.user.update({ where: { id }, data: { role: parsed.data.role } });
|
||||
await recordAudit({ actorId: auth.user!.id, action: 'user.role', target: updated.email, meta: { role: parsed.data.role } });
|
||||
return NextResponse.json({ user: { id: updated.id, email: updated.email, role: updated.role } });
|
||||
}
|
||||
|
||||
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const auth = await requireAdminApi();
|
||||
if (auth.error) return auth.error;
|
||||
const { id } = await params;
|
||||
if (id === auth.user!.id) return NextResponse.json({ error: 'You cannot delete your own account.' }, { status: 400 });
|
||||
|
||||
const deleted = await db.user.delete({ where: { id } });
|
||||
await recordAudit({ actorId: auth.user!.id, action: 'user.delete', target: deleted.email });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
17
app/api/admin/users/route.ts
Normal file
17
app/api/admin/users/route.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/server/auth';
|
||||
import { db } from '@/lib/server/db';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
if (user.role !== 'admin') return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
const users = await db.user.findMany({
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { id: true, email: true, name: true, role: true, createdAt: true, subscription: { select: { plan: true, status: true } } },
|
||||
});
|
||||
return NextResponse.json({ users });
|
||||
}
|
||||
31
app/api/auth/login/route.ts
Normal file
31
app/api/auth/login/route.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { createSession, verifyPassword } from '@/lib/server/auth';
|
||||
import { recordAudit } from '@/lib/server/audit';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const schema = z.object({ email: z.string().email(), password: z.string().min(1) });
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const parsed = schema.safeParse(await request.json().catch(() => ({})));
|
||||
if (!parsed.success) return NextResponse.json({ error: 'Email and password are required.' }, { status: 400 });
|
||||
|
||||
const email = parsed.data.email.toLowerCase();
|
||||
const user = await db.user.findUnique({ where: { email } });
|
||||
// Constant-ish response: never reveal whether the email exists.
|
||||
if (!user || !(await verifyPassword(parsed.data.password, user.passwordHash))) {
|
||||
return NextResponse.json({ error: 'Invalid email or password.' }, { status: 401 });
|
||||
}
|
||||
|
||||
await db.subscription.upsert({
|
||||
where: { userId: user.id },
|
||||
update: {},
|
||||
create: { userId: user.id, plan: 'free', status: 'active', provider: 'mock' },
|
||||
});
|
||||
await createSession(user.id);
|
||||
await recordAudit({ actorId: user.id, action: 'user.login', target: user.email });
|
||||
|
||||
return NextResponse.json({ user: { id: user.id, email: user.email, name: user.name, role: user.role } });
|
||||
}
|
||||
10
app/api/auth/logout/route.ts
Normal file
10
app/api/auth/logout/route.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { destroySession } from '@/lib/server/auth';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST() {
|
||||
await destroySession();
|
||||
// 303 so a plain HTML form POST turns into a GET of the home page.
|
||||
return NextResponse.redirect(new URL('/', process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'), 303);
|
||||
}
|
||||
10
app/api/auth/me/route.ts
Normal file
10
app/api/auth/me/route.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { getCurrentUser } from '@/lib/server/auth';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return NextResponse.json({ user: null }, { status: 401 });
|
||||
return NextResponse.json({ user });
|
||||
}
|
||||
44
app/api/auth/register/route.ts
Normal file
44
app/api/auth/register/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { createSession, hashPassword } from '@/lib/server/auth';
|
||||
import { recordAudit } from '@/lib/server/audit';
|
||||
import { sendEmail } from '@/lib/server/email';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email().max(200),
|
||||
password: z.string().min(8).max(200),
|
||||
name: z.string().max(120).optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const parsed = schema.safeParse(await request.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid email or password (min 8 characters).' }, { status: 400 });
|
||||
}
|
||||
const email = parsed.data.email.toLowerCase();
|
||||
const existing = await db.user.findUnique({ where: { email } });
|
||||
if (existing) return NextResponse.json({ error: 'An account with this email already exists.' }, { status: 409 });
|
||||
|
||||
const user = await db.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: parsed.data.name || null,
|
||||
role: 'user',
|
||||
passwordHash: await hashPassword(parsed.data.password),
|
||||
subscription: { create: { plan: 'free', status: 'active', provider: 'mock' } },
|
||||
},
|
||||
});
|
||||
|
||||
await recordAudit({ actorId: user.id, action: 'user.register', target: user.email });
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: 'Welcome',
|
||||
html: '<p>Your account is ready. Sign in to get started.</p>',
|
||||
}).catch((e) => console.error('[email] register', e));
|
||||
|
||||
await createSession(user.id);
|
||||
return NextResponse.json({ user: { id: user.id, email: user.email, name: user.name, role: user.role } }, { status: 201 });
|
||||
}
|
||||
43
app/api/billing/checkout/route.ts
Normal file
43
app/api/billing/checkout/route.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { getCurrentUser } from '@/lib/server/auth';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { getPayments } from '@/lib/server/payments';
|
||||
import { recordAudit } from '@/lib/server/audit';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const schema = z.object({ plan: z.enum(['free', 'pro']) });
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const parsed = schema.safeParse(await request.json().catch(() => ({})));
|
||||
if (!parsed.success) return NextResponse.json({ error: 'Invalid plan' }, { status: 400 });
|
||||
|
||||
const provider = getPayments();
|
||||
const siteUrl = env.NEXT_PUBLIC_SITE_URL.replace(/\/$/, '');
|
||||
const result = await provider.createCheckout({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
plan: parsed.data.plan,
|
||||
successUrl: `${siteUrl}/dashboard`,
|
||||
cancelUrl: `${siteUrl}/pricing`,
|
||||
});
|
||||
|
||||
// The mock provider has no external redirect: activate the subscription right away so the
|
||||
// full "plan → checkout → return → active" flow works with zero configuration.
|
||||
if (provider.name === 'mock') {
|
||||
const currentPeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
await db.subscription.upsert({
|
||||
where: { userId: user.id },
|
||||
update: { plan: parsed.data.plan, status: 'active', provider: 'mock', currentPeriodEnd },
|
||||
create: { userId: user.id, plan: parsed.data.plan, status: 'active', provider: 'mock', currentPeriodEnd },
|
||||
});
|
||||
}
|
||||
|
||||
await recordAudit({ actorId: user.id, action: 'billing.checkout', target: parsed.data.plan, meta: { provider: provider.name } });
|
||||
return NextResponse.json({ id: result.id, url: result.url, provider: provider.name });
|
||||
}
|
||||
35
app/api/billing/webhook/route.ts
Normal file
35
app/api/billing/webhook/route.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { getPayments } from '@/lib/server/payments';
|
||||
import { recordAudit } from '@/lib/server/audit';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const raw = await request.text();
|
||||
const signature = request.headers.get('stripe-signature');
|
||||
const provider = getPayments();
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = await provider.parseWebhook(raw, signature);
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: e instanceof Error ? e.message : 'Bad webhook' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (event.type === 'checkout.completed' && event.userId) {
|
||||
const currentPeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
await db.subscription.upsert({
|
||||
where: { userId: event.userId },
|
||||
update: { plan: event.plan ?? 'pro', status: 'active', provider: provider.name, currentPeriodEnd },
|
||||
create: { userId: event.userId, plan: event.plan ?? 'pro', status: 'active', provider: provider.name, currentPeriodEnd },
|
||||
});
|
||||
await recordAudit({ actorId: event.userId, action: 'billing.activated', target: event.plan ?? 'pro' });
|
||||
} else if (event.type === 'subscription.canceled' && event.userId) {
|
||||
await db.subscription.update({ where: { userId: event.userId }, data: { status: 'canceled' } }).catch(() => undefined);
|
||||
await recordAudit({ actorId: event.userId, action: 'billing.canceled', target: event.userId });
|
||||
}
|
||||
|
||||
// Idempotent: re-delivered events simply re-apply the same upsert.
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
7
app/api/health/route.ts
Normal file
7
app/api/health/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({ status: 'ok', service: 'templates2-base-next', time: new Date().toISOString() });
|
||||
}
|
||||
13
app/error.tsx
Normal file
13
app/error.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
'use client';
|
||||
|
||||
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-5 text-center">
|
||||
<h1 className="text-xl font-semibold">Something went wrong</h1>
|
||||
<p className="muted mt-2 text-sm">{error.message || 'An unexpected error occurred.'}</p>
|
||||
<button type="button" className="btn-primary mt-8" onClick={reset}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
app/globals.css
Normal file
153
app/globals.css
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* Theme is toggled by a `.dark` class on <html> (see ThemeProvider). */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--color-brand-50: #eef2ff;
|
||||
--color-brand-100: #e0e7ff;
|
||||
--color-brand-200: #c7d2fe;
|
||||
--color-brand-400: #818cf8;
|
||||
--color-brand-500: #6366f1;
|
||||
--color-brand-600: #4f46e5;
|
||||
--color-brand-700: #4338ca;
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #f8fafc;
|
||||
--border: #e2e8f0;
|
||||
--text: #0f172a;
|
||||
--text-muted: #52607a;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--surface: #0b1120;
|
||||
--surface-muted: #111827;
|
||||
--border: #1f2937;
|
||||
--text: #e5e7eb;
|
||||
--text-muted: #94a3b8;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.page {
|
||||
@apply mx-auto w-full max-w-6xl px-5;
|
||||
}
|
||||
|
||||
.surface {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-2xl border p-6;
|
||||
background: var(--surface-muted);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* All button variants share the base rule (Tailwind v4 cannot @apply a custom class). */
|
||||
.btn,
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-ghost,
|
||||
.btn-danger {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition;
|
||||
}
|
||||
.btn:disabled,
|
||||
.btn-primary:disabled,
|
||||
.btn-secondary:disabled,
|
||||
.btn-ghost:disabled,
|
||||
.btn-danger:disabled {
|
||||
@apply cursor-not-allowed opacity-60;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply text-white;
|
||||
background: var(--color-brand-600);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--color-brand-700);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply border;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--surface-muted);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
@apply bg-red-700;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply mb-1 block text-sm font-medium;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full rounded-lg border px-3 py-2 text-sm outline-none;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--color-brand-500);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand-500) 25%, transparent);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
/* Visible keyboard focus everywhere (WCAG 2.4.7). */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-brand-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
31
app/layout.tsx
Normal file
31
app/layout.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteUrl),
|
||||
title: { default: 'Acme — ready-to-run app', template: '%s · Acme' },
|
||||
description: 'A production-ready base app: auth, roles, payments, admin, SEO — working on day one.',
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
siteName: 'Acme',
|
||||
url: '/',
|
||||
title: 'Acme — ready-to-run app',
|
||||
description: 'A production-ready base app: auth, roles, payments, admin, SEO.',
|
||||
},
|
||||
robots: { index: true, follow: true },
|
||||
};
|
||||
|
||||
const themeScript = `try{var t=localStorage.getItem('theme');var d=t? t==='dark' : matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');}catch(e){}`;
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
</head>
|
||||
<body className="min-h-screen antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
10
app/loading.tsx
Normal file
10
app/loading.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-5 animate-spin rounded-full border-2 border-current border-t-transparent" aria-hidden />
|
||||
<span className="muted text-sm">Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/not-found.tsx
Normal file
14
app/not-found.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import Link from 'next/link';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-5 text-center">
|
||||
<p className="text-6xl font-bold">404</p>
|
||||
<h1 className="mt-4 text-xl font-semibold">Page not found</h1>
|
||||
<p className="muted mt-2">The page you are looking for does not exist.</p>
|
||||
<Link href="/" className="btn-primary mt-8">
|
||||
Back home
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
app/robots.ts
Normal file
9
app/robots.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import type { MetadataRoute } from 'next';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const base = (process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
return {
|
||||
rules: { userAgent: '*', allow: '/', disallow: ['/admin', '/dashboard', '/api'] },
|
||||
sitemap: `${base}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
12
app/sitemap.ts
Normal file
12
app/sitemap.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { MetadataRoute } from 'next';
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const base = (process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||
const now = new Date();
|
||||
return [
|
||||
{ url: `${base}/`, lastModified: now, changeFrequency: 'weekly', priority: 1 },
|
||||
{ url: `${base}/pricing`, lastModified: now, changeFrequency: 'monthly', priority: 0.8 },
|
||||
{ url: `${base}/legal/privacy`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },
|
||||
{ url: `${base}/legal/terms`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },
|
||||
];
|
||||
}
|
||||
30
components/AdminNav.tsx
Normal file
30
components/AdminNav.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import Link from 'next/link';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
type Item = { href: string; label: string; adminOnly?: boolean };
|
||||
|
||||
const ITEMS: Item[] = [
|
||||
{ href: '/admin', label: t('admin.overview') },
|
||||
{ href: '/admin/customers', label: t('admin.customers') },
|
||||
{ href: '/admin/deals', label: t('admin.deals') },
|
||||
{ href: '/admin/import', label: t('admin.import') },
|
||||
{ href: '/admin/audit', label: t('admin.audit') },
|
||||
{ href: '/admin/users', label: t('admin.users'), adminOnly: true },
|
||||
];
|
||||
|
||||
export function AdminNav({ role }: { role: string }) {
|
||||
const items = ITEMS.filter((item) => !item.adminOnly || role === 'admin');
|
||||
return (
|
||||
<nav className="flex flex-wrap gap-1 md:flex-col" aria-label="Admin">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
79
components/AuthForm.tsx
Normal file
79
components/AuthForm.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { Field } from '@/components/ui';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
export function AuthForm({ mode }: { mode: 'login' | 'register' }) {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const next = params.get('next') || '/dashboard';
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const form = new FormData(e.currentTarget);
|
||||
const body = Object.fromEntries(form.entries());
|
||||
try {
|
||||
const res = await fetch(`/api/auth/${mode}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error || `Request failed (${res.status})`);
|
||||
}
|
||||
router.push(next);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Request failed');
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isRegister = mode === 'register';
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h1 className="text-xl font-semibold">{isRegister ? t('auth.registerTitle') : t('auth.loginTitle')}</h1>
|
||||
<form className="mt-6 space-y-4" onSubmit={onSubmit} noValidate>
|
||||
{isRegister && (
|
||||
<Field label={t('auth.name')} htmlFor="name">
|
||||
<input id="name" name="name" className="input" autoComplete="name" />
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t('auth.email')} htmlFor="email">
|
||||
<input id="email" name="email" type="email" required className="input" autoComplete="email" />
|
||||
</Field>
|
||||
<Field label={t('auth.password')} htmlFor="password">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="input"
|
||||
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||
/>
|
||||
</Field>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button type="submit" className="btn-primary w-full" disabled={busy}>
|
||||
{busy ? '…' : isRegister ? t('auth.submitRegister') : t('auth.submitLogin')}
|
||||
</button>
|
||||
</form>
|
||||
<p className="muted mt-5 text-center text-sm">
|
||||
{isRegister ? t('auth.hasAccount') : t('auth.noAccount')}{' '}
|
||||
<Link href={isRegister ? '/login' : '/register'} className="font-medium text-brand-600 hover:underline">
|
||||
{isRegister ? t('auth.submitLogin') : t('auth.submitRegister')}
|
||||
</Link>
|
||||
</p>
|
||||
{!isRegister && <p className="muted mt-3 text-center text-xs">{t('auth.demoHint')}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
components/SiteFooter.tsx
Normal file
25
components/SiteFooter.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import Link from 'next/link';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t" style={{ borderColor: 'var(--border)' }}>
|
||||
<div className="page flex flex-col items-center justify-between gap-4 py-8 text-sm sm:flex-row">
|
||||
<p className="muted">
|
||||
© {new Date().getFullYear()} {t('brand.name')}
|
||||
</p>
|
||||
<nav className="flex gap-5" aria-label="Footer">
|
||||
<Link href="/pricing" className="muted hover:opacity-80">
|
||||
{t('nav.pricing')}
|
||||
</Link>
|
||||
<Link href="/legal/privacy" className="muted hover:opacity-80">
|
||||
Privacy
|
||||
</Link>
|
||||
<Link href="/legal/terms" className="muted hover:opacity-80">
|
||||
Terms
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
49
components/SiteHeader.tsx
Normal file
49
components/SiteHeader.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import Link from 'next/link';
|
||||
import { getCurrentUser } from '@/lib/server/auth';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
export async function SiteHeader() {
|
||||
const user = await getCurrentUser();
|
||||
return (
|
||||
<header className="border-b" style={{ borderColor: 'var(--border)' }}>
|
||||
<div className="page flex h-16 items-center justify-between gap-4">
|
||||
<Link href="/" className="text-lg font-bold tracking-tight">
|
||||
{t('brand.name')}
|
||||
</Link>
|
||||
<nav className="hidden items-center gap-6 text-sm md:flex" aria-label="Main">
|
||||
<Link href="/" className="muted hover:opacity-80">
|
||||
{t('nav.home')}
|
||||
</Link>
|
||||
<Link href="/pricing" className="muted hover:opacity-80">
|
||||
{t('nav.pricing')}
|
||||
</Link>
|
||||
{user && (
|
||||
<Link href="/dashboard" className="muted hover:opacity-80">
|
||||
{t('nav.dashboard')}
|
||||
</Link>
|
||||
)}
|
||||
{user?.role === 'admin' && (
|
||||
<Link href="/admin" className="muted hover:opacity-80">
|
||||
{t('nav.admin')}
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
{user ? (
|
||||
<form action="/api/auth/logout" method="post">
|
||||
<button type="submit" className="btn-secondary">
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<Link href="/login" className="btn-primary">
|
||||
{t('nav.login')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
41
components/ThemeToggle.tsx
Normal file
41
components/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
function apply(theme: Theme) {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
try {
|
||||
localStorage.setItem('theme', theme);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<Theme>('light');
|
||||
|
||||
useEffect(() => {
|
||||
const current = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
setTheme(current);
|
||||
}, []);
|
||||
|
||||
function toggle() {
|
||||
const next: Theme = theme === 'dark' ? 'light' : 'dark';
|
||||
setTheme(next);
|
||||
apply(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={toggle}
|
||||
aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||
>
|
||||
<span aria-hidden>{theme === 'dark' ? '☾' : '☀'}</span>
|
||||
<span className="hidden sm:inline">{theme === 'dark' ? 'Dark' : 'Light'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
43
components/UpgradeButton.tsx
Normal file
43
components/UpgradeButton.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function UpgradeButton({ plan, label, disabled }: { plan: string; label: string; disabled?: boolean }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function upgrade() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/billing/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ plan }),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
router.push('/login?next=/pricing');
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(`Checkout failed (${res.status})`);
|
||||
const data = (await res.json()) as { url: string };
|
||||
router.push(data.url.replace(/^https?:\/\/[^/]+/, ''));
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Checkout failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<button type="button" className="btn-primary w-full" onClick={upgrade} disabled={busy || disabled}>
|
||||
{busy ? 'Working…' : label}
|
||||
</button>
|
||||
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
298
components/admin/CustomersManager.tsx
Normal file
298
components/admin/CustomersManager.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Badge } from '@/components/ui';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
|
||||
type Customer = { id: string; name: string; email: string; company: string | null; status: string; createdAt: string };
|
||||
type ListResponse = { items: Customer[]; total: number; page: number; pageSize: number; pages: number };
|
||||
type FormState = { name: string; email: string; company: string; status: string };
|
||||
|
||||
const EMPTY: FormState = { name: '', email: '', company: '', status: 'active' };
|
||||
const STATUSES = ['active', 'lead', 'churned'];
|
||||
const tone = (status: string) => (status === 'active' ? 'success' : status === 'churned' ? 'danger' : 'warning');
|
||||
|
||||
export function CustomersManager({ canWrite, canDelete }: { canWrite: boolean; canDelete: boolean }) {
|
||||
const [items, setItems] = useState<Customer[]>([]);
|
||||
const [meta, setMeta] = useState({ page: 1, pages: 1, total: 0 });
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [sort, setSort] = useState('newest');
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: '10', sort });
|
||||
if (search) params.set('q', search);
|
||||
if (status) params.set('status', status);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/customers?${params.toString()}`);
|
||||
if (!res.ok) throw new Error('request failed');
|
||||
const data = (await res.json()) as ListResponse;
|
||||
setItems(data.items);
|
||||
setMeta({ page: data.page, pages: data.pages, total: data.total });
|
||||
} catch {
|
||||
setError(t('common.error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, sort, search, status]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
function openCreate() {
|
||||
setEditingId(null);
|
||||
setFormError(null);
|
||||
setForm({ ...EMPTY });
|
||||
}
|
||||
|
||||
function openEdit(c: Customer) {
|
||||
setEditingId(c.id);
|
||||
setFormError(null);
|
||||
setForm({ name: c.name, email: c.email, company: c.company ?? '', status: c.status });
|
||||
}
|
||||
|
||||
async function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!form) return;
|
||||
setBusy(true);
|
||||
setFormError(null);
|
||||
const res = await fetch(editingId ? `/api/admin/customers/${editingId}` : '/api/admin/customers', {
|
||||
method: editingId ? 'PATCH' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
setFormError(((await res.json().catch(() => ({}))) as { error?: string }).error || t('common.error'));
|
||||
return;
|
||||
}
|
||||
setForm(null);
|
||||
setEditingId(null);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function remove(c: Customer) {
|
||||
if (!window.confirm(`${t('admin.delete')} “${c.name}”? This cannot be undone.`)) return;
|
||||
setBusy(true);
|
||||
await fetch(`/api/admin/customers/${c.id}`, { method: 'DELETE' });
|
||||
setBusy(false);
|
||||
await load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t('admin.customers')}</h1>
|
||||
<p className="muted mt-1 text-sm">{meta.total} records</p>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<button type="button" className="btn-primary" onClick={openCreate}>
|
||||
{t('admin.newCustomer')}
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{!canWrite && <p className="muted text-sm">{t('admin.readOnly')}</p>}
|
||||
|
||||
<form
|
||||
className="card flex flex-wrap items-end gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
setSearch(searchInput.trim());
|
||||
}}
|
||||
>
|
||||
<label className="text-sm">
|
||||
{t('admin.search')}
|
||||
<input
|
||||
className="input mt-1"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder={t('admin.searchPlaceholder')}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t('admin.status')}
|
||||
<select
|
||||
className="input mt-1"
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setStatus(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">{t('admin.all')}</option>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`admin.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t('admin.sort')}
|
||||
<select
|
||||
className="input mt-1"
|
||||
value={sort}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setSort(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="newest">{t('admin.sortNewest')}</option>
|
||||
<option value="name">{t('admin.sortName')}</option>
|
||||
<option value="company">{t('admin.sortCompany')}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" className="btn-secondary">
|
||||
{t('admin.search')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{form && (
|
||||
<form className="card space-y-3" onSubmit={submit}>
|
||||
<h2 className="text-lg font-semibold">{editingId ? t('admin.editCustomer') : t('admin.newCustomer')}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-sm">
|
||||
{t('admin.name')}
|
||||
<input
|
||||
className="input mt-1"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t('admin.email')}
|
||||
<input
|
||||
className="input mt-1"
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t('admin.company')}
|
||||
<input
|
||||
className="input mt-1"
|
||||
value={form.company}
|
||||
onChange={(e) => setForm({ ...form, company: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t('admin.status')}
|
||||
<select
|
||||
className="input mt-1"
|
||||
value={form.status}
|
||||
onChange={(e) => setForm({ ...form, status: e.target.value })}
|
||||
>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`admin.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{formError && <p className="text-sm text-red-600">{formError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="btn-primary" disabled={busy}>
|
||||
{t('admin.save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={() => {
|
||||
setForm(null);
|
||||
setEditingId(null);
|
||||
}}
|
||||
>
|
||||
{t('admin.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{loading ? (
|
||||
<p className="muted text-sm">{t('common.loading')}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="muted py-6 text-center text-sm">{t('admin.empty')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: 'var(--border)' }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="muted text-left text-xs uppercase">
|
||||
<tr>
|
||||
<th className="px-4 py-3">{t('admin.name')}</th>
|
||||
<th className="px-4 py-3">{t('admin.email')}</th>
|
||||
<th className="px-4 py-3">{t('admin.company')}</th>
|
||||
<th className="px-4 py-3">{t('admin.status')}</th>
|
||||
<th className="px-4 py-3">{t('admin.created')}</th>
|
||||
{(canWrite || canDelete) && <th className="px-4 py-3 text-right">{t('admin.actions')}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((c) => (
|
||||
<tr key={c.id} className="border-t" style={{ borderColor: 'var(--border)' }}>
|
||||
<td className="px-4 py-3 font-medium">{c.name}</td>
|
||||
<td className="px-4 py-3">{c.email}</td>
|
||||
<td className="px-4 py-3">{c.company || t('admin.none')}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge tone={tone(c.status)}>{t(`admin.${c.status}`)}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">{formatDate(c.createdAt)}</td>
|
||||
{(canWrite || canDelete) && (
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
{canWrite && (
|
||||
<button type="button" className="btn-secondary" onClick={() => openEdit(c)}>
|
||||
{t('admin.edit')}
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button type="button" className="btn-danger" disabled={busy} onClick={() => remove(c)}>
|
||||
{t('admin.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<button type="button" className="btn-secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{t('admin.prev')}
|
||||
</button>
|
||||
<span className="muted">{t('admin.pageOf', { page: meta.page, pages: meta.pages })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
disabled={page >= meta.pages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
{t('admin.next')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
273
components/admin/DealsManager.tsx
Normal file
273
components/admin/DealsManager.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Badge } from '@/components/ui';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
|
||||
type Deal = {
|
||||
id: string;
|
||||
title: string;
|
||||
customerId: string | null;
|
||||
customer: { id: string; name: string } | null;
|
||||
value: number;
|
||||
stage: string;
|
||||
owner: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
type CustomerOption = { id: string; name: string };
|
||||
type ListResponse = { items: Deal[]; total: number; page: number; pageSize: number; pages: number };
|
||||
type FormState = { title: string; customerId: string; value: string; stage: string; owner: string };
|
||||
|
||||
const EMPTY: FormState = { title: '', customerId: '', value: '0', stage: 'prospecting', owner: '' };
|
||||
const STAGES = ['prospecting', 'proposal', 'won', 'lost'];
|
||||
const stageTone = (stage: string) => (stage === 'won' ? 'success' : stage === 'lost' ? 'danger' : stage === 'proposal' ? 'warning' : 'neutral');
|
||||
|
||||
export function DealsManager({ canWrite, canDelete }: { canWrite: boolean; canDelete: boolean }) {
|
||||
const [items, setItems] = useState<Deal[]>([]);
|
||||
const [customers, setCustomers] = useState<CustomerOption[]>([]);
|
||||
const [meta, setMeta] = useState({ page: 1, pages: 1, total: 0 });
|
||||
const [stage, setStage] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: '10' });
|
||||
if (stage) params.set('stage', stage);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/deals?${params.toString()}`);
|
||||
if (!res.ok) throw new Error('request failed');
|
||||
const data = (await res.json()) as ListResponse;
|
||||
setItems(data.items);
|
||||
setMeta({ page: data.page, pages: data.pages, total: data.total });
|
||||
} catch {
|
||||
setError(t('common.error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, stage]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetch('/api/admin/customers?pageSize=50')
|
||||
.then((r) => (r.ok ? r.json() : { items: [] }))
|
||||
.then((d: { items?: CustomerOption[] }) => setCustomers(d.items ?? []));
|
||||
}, []);
|
||||
|
||||
function openCreate() {
|
||||
setEditingId(null);
|
||||
setFormError(null);
|
||||
setForm({ ...EMPTY });
|
||||
}
|
||||
|
||||
function openEdit(d: Deal) {
|
||||
setEditingId(d.id);
|
||||
setFormError(null);
|
||||
setForm({ title: d.title, customerId: d.customerId ?? '', value: String(d.value), stage: d.stage, owner: d.owner ?? '' });
|
||||
}
|
||||
|
||||
async function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!form) return;
|
||||
setBusy(true);
|
||||
setFormError(null);
|
||||
const payload = { ...form, value: Number(form.value) || 0, customerId: form.customerId || null };
|
||||
const res = await fetch(editingId ? `/api/admin/deals/${editingId}` : '/api/admin/deals', {
|
||||
method: editingId ? 'PATCH' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
setFormError(((await res.json().catch(() => ({}))) as { error?: string }).error || t('common.error'));
|
||||
return;
|
||||
}
|
||||
setForm(null);
|
||||
setEditingId(null);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function remove(d: Deal) {
|
||||
if (!window.confirm(`${t('admin.delete')} “${d.title}”? This cannot be undone.`)) return;
|
||||
setBusy(true);
|
||||
await fetch(`/api/admin/deals/${d.id}`, { method: 'DELETE' });
|
||||
setBusy(false);
|
||||
await load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t('admin.deals')}</h1>
|
||||
<p className="muted mt-1 text-sm">{meta.total} records</p>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<button type="button" className="btn-primary" onClick={openCreate}>
|
||||
{t('admin.newDeal')}
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{!canWrite && <p className="muted text-sm">{t('admin.readOnly')}</p>}
|
||||
|
||||
<div className="card flex flex-wrap items-end gap-3">
|
||||
<label className="text-sm">
|
||||
{t('admin.stage')}
|
||||
<select
|
||||
className="input mt-1"
|
||||
value={stage}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setStage(e.target.value);
|
||||
}}
|
||||
>
|
||||
<option value="">{t('admin.all')}</option>
|
||||
{STAGES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`admin.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{form && (
|
||||
<form className="card space-y-3" onSubmit={submit}>
|
||||
<h2 className="text-lg font-semibold">{editingId ? t('admin.editDeal') : t('admin.newDeal')}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-sm">
|
||||
{t('admin.dealTitle')}
|
||||
<input className="input mt-1" required value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t('admin.customer')}
|
||||
<select className="input mt-1" value={form.customerId} onChange={(e) => setForm({ ...form, customerId: e.target.value })}>
|
||||
<option value="">{t('admin.none')}</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t('admin.value')}
|
||||
<input
|
||||
className="input mt-1"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
value={form.value}
|
||||
onChange={(e) => setForm({ ...form, value: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t('admin.stage')}
|
||||
<select className="input mt-1" value={form.stage} onChange={(e) => setForm({ ...form, stage: e.target.value })}>
|
||||
{STAGES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`admin.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t('admin.owner')}
|
||||
<input className="input mt-1" value={form.owner} onChange={(e) => setForm({ ...form, owner: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
{formError && <p className="text-sm text-red-600">{formError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="btn-primary" disabled={busy}>
|
||||
{t('admin.save')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={() => {
|
||||
setForm(null);
|
||||
setEditingId(null);
|
||||
}}
|
||||
>
|
||||
{t('admin.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{loading ? (
|
||||
<p className="muted text-sm">{t('common.loading')}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="muted py-6 text-center text-sm">{t('admin.empty')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: 'var(--border)' }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="muted text-left text-xs uppercase">
|
||||
<tr>
|
||||
<th className="px-4 py-3">{t('admin.dealTitle')}</th>
|
||||
<th className="px-4 py-3">{t('admin.customer')}</th>
|
||||
<th className="px-4 py-3">{t('admin.value')}</th>
|
||||
<th className="px-4 py-3">{t('admin.stage')}</th>
|
||||
<th className="px-4 py-3">{t('admin.owner')}</th>
|
||||
{(canWrite || canDelete) && <th className="px-4 py-3 text-right">{t('admin.actions')}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((d) => (
|
||||
<tr key={d.id} className="border-t" style={{ borderColor: 'var(--border)' }}>
|
||||
<td className="px-4 py-3 font-medium">{d.title}</td>
|
||||
<td className="px-4 py-3">{d.customer?.name ?? t('admin.none')}</td>
|
||||
<td className="px-4 py-3">{formatCurrency(d.value)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge tone={stageTone(d.stage)}>{t(`admin.${d.stage}`)}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">{d.owner || t('admin.none')}</td>
|
||||
{(canWrite || canDelete) && (
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
{canWrite && (
|
||||
<button type="button" className="btn-secondary" onClick={() => openEdit(d)}>
|
||||
{t('admin.edit')}
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button type="button" className="btn-danger" disabled={busy} onClick={() => remove(d)}>
|
||||
{t('admin.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<button type="button" className="btn-secondary" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{t('admin.prev')}
|
||||
</button>
|
||||
<span className="muted">{t('admin.pageOf', { page: meta.page, pages: meta.pages })}</span>
|
||||
<button type="button" className="btn-secondary" disabled={page >= meta.pages} onClick={() => setPage((p) => p + 1)}>
|
||||
{t('admin.next')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
components/admin/ImportWizard.tsx
Normal file
182
components/admin/ImportWizard.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
type Row = { name: string; email: string; company: string; status: string };
|
||||
type Preview = { total: number; valid: number; errors: { row: number; message: string }[]; duplicatesInFile: number[]; sample: Row[] };
|
||||
type Committed = { imported: number; updated: number; errors: { row: number; message: string }[]; duplicatesInFile: number[] };
|
||||
|
||||
const SAMPLE = 'name,email,company,status\nJane Doe,jane@example.com,Acme,lead\nBad Row,not-an-email,Globex,active';
|
||||
|
||||
export function ImportWizard({ canImport }: { canImport: boolean }) {
|
||||
const [csv, setCsv] = useState('');
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const [committed, setCommitted] = useState<Committed | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function post(commit: boolean) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/import/customers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ csv, commit }),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
if (!res.ok) {
|
||||
setError((data.error as string) || t('common.error'));
|
||||
return;
|
||||
}
|
||||
if (commit) {
|
||||
setPreview(null);
|
||||
setCommitted(data as unknown as Committed);
|
||||
} else {
|
||||
setCommitted(null);
|
||||
setPreview(data as unknown as Preview);
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFile(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) setCsv(await file.text());
|
||||
}
|
||||
|
||||
if (!canImport) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">{t('admin.importTitle')}</h1>
|
||||
<p className="muted text-sm">{t('admin.readOnly')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-bold">{t('admin.importTitle')}</h1>
|
||||
<a className="btn-secondary" href="/api/admin/export/customers" download>
|
||||
{t('admin.export')}
|
||||
</a>
|
||||
</header>
|
||||
<p className="muted text-sm">{t('admin.importHint')}</p>
|
||||
|
||||
<div className="card space-y-3">
|
||||
<label className="block text-sm">
|
||||
{t('admin.chooseFile')}
|
||||
<input type="file" accept=".csv,text/csv" className="mt-1 block text-sm" onChange={onFile} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
CSV
|
||||
<textarea
|
||||
className="input mt-1 min-h-40 font-mono"
|
||||
value={csv}
|
||||
onChange={(e) => setCsv(e.target.value)}
|
||||
placeholder={SAMPLE}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" className="btn-secondary" disabled={busy || !csv.trim()} onClick={() => post(false)}>
|
||||
{t('admin.preview')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={busy || !preview || preview.valid === 0}
|
||||
onClick={() => post(true)}
|
||||
>
|
||||
{busy ? t('admin.importing') : t('admin.commit', { count: preview?.valid ?? 0 })}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
|
||||
{committed && (
|
||||
<div className="card">
|
||||
<p className="text-sm font-medium">
|
||||
{t('admin.imported', { imported: committed.imported, updated: committed.updated })}
|
||||
</p>
|
||||
{committed.errors.length > 0 && <RowErrors errors={committed.errors} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="card space-y-4">
|
||||
<p className="text-sm">
|
||||
{t('admin.previewResult', {
|
||||
total: preview.total,
|
||||
valid: preview.valid,
|
||||
errors: preview.errors.length,
|
||||
duplicates: preview.duplicatesInFile.length,
|
||||
})}
|
||||
</p>
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-semibold">{t('admin.sample')}</h2>
|
||||
{preview.sample.length === 0 ? (
|
||||
<p className="muted text-sm">{t('admin.empty')}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: 'var(--border)' }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="muted text-left text-xs uppercase">
|
||||
<tr>
|
||||
<th className="px-4 py-2">{t('admin.name')}</th>
|
||||
<th className="px-4 py-2">{t('admin.email')}</th>
|
||||
<th className="px-4 py-2">{t('admin.company')}</th>
|
||||
<th className="px-4 py-2">{t('admin.status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.sample.map((r) => (
|
||||
<tr key={r.email} className="border-t" style={{ borderColor: 'var(--border)' }}>
|
||||
<td className="px-4 py-2">{r.name}</td>
|
||||
<td className="px-4 py-2">{r.email}</td>
|
||||
<td className="px-4 py-2">{r.company || t('admin.none')}</td>
|
||||
<td className="px-4 py-2">{t(`admin.${r.status}`)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-semibold">{t('admin.perRowErrors')}</h2>
|
||||
{preview.errors.length === 0 ? (
|
||||
<p className="muted text-sm">{t('admin.noErrors')}</p>
|
||||
) : (
|
||||
<RowErrors errors={preview.errors} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowErrors({ errors }: { errors: { row: number; message: string }[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: 'var(--border)' }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="muted text-left text-xs uppercase">
|
||||
<tr>
|
||||
<th className="px-4 py-2">{t('admin.row')}</th>
|
||||
<th className="px-4 py-2">{t('admin.message')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{errors.map((e) => (
|
||||
<tr key={`${e.row}-${e.message}`} className="border-t" style={{ borderColor: 'var(--border)' }}>
|
||||
<td className="px-4 py-2">{e.row}</td>
|
||||
<td className="px-4 py-2 text-red-600">{e.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
components/admin/UserActions.tsx
Normal file
44
components/admin/UserActions.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function UserActions({ id, role }: { id: string; role: string }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function update(nextRole: string) {
|
||||
setBusy(true);
|
||||
await fetch(`/api/admin/users/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: nextRole }),
|
||||
});
|
||||
setBusy(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!window.confirm('Delete this user? This cannot be undone.')) return;
|
||||
setBusy(true);
|
||||
await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
|
||||
setBusy(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() => update(role === 'admin' ? 'user' : 'admin')}
|
||||
>
|
||||
{role === 'admin' ? 'Make user' : 'Make admin'}
|
||||
</button>
|
||||
<button type="button" className="btn-danger" disabled={busy} onClick={remove}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
components/ui/index.tsx
Normal file
68
components/ui/index.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
export function Card({ title, children, actions }: { title?: ReactNode; children: ReactNode; actions?: ReactNode }) {
|
||||
return (
|
||||
<section className="card">
|
||||
{(title || actions) && (
|
||||
<header className="mb-4 flex items-center justify-between gap-4">
|
||||
{title ? <h2 className="text-lg font-semibold">{title}</h2> : <span />}
|
||||
{actions}
|
||||
</header>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const badgeTones: Record<string, string> = {
|
||||
neutral: 'bg-slate-200 text-slate-800 dark:bg-slate-700 dark:text-slate-100',
|
||||
success: 'bg-emerald-600 text-white',
|
||||
warning: 'bg-amber-300 text-black',
|
||||
danger: 'bg-red-600 text-white',
|
||||
brand: 'bg-brand-600 text-white',
|
||||
};
|
||||
|
||||
export function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: keyof typeof badgeTones }) {
|
||||
return <span className={`badge ${badgeTones[tone] ?? badgeTones.neutral}`}>{children}</span>;
|
||||
}
|
||||
|
||||
const alertTones: Record<string, string> = {
|
||||
info: 'border-brand-400 bg-brand-50 text-brand-700 dark:bg-brand-700/20 dark:text-brand-100',
|
||||
success: 'border-emerald-500 bg-emerald-50 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-100',
|
||||
warning: 'border-amber-400 bg-amber-50 text-black',
|
||||
error: 'border-red-500 bg-red-50 text-red-800 dark:bg-red-900/30 dark:text-red-100',
|
||||
};
|
||||
|
||||
export function Alert({ children, tone = 'info' }: { children: ReactNode; tone?: keyof typeof alertTones }) {
|
||||
return (
|
||||
<div role="status" className={`rounded-lg border px-4 py-3 text-sm ${alertTones[tone] ?? alertTones.info}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="label" htmlFor={htmlFor}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <p className="muted mt-1 text-xs">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ children }: { children?: ReactNode }) {
|
||||
return <p className="muted py-6 text-center text-sm">{children ?? 'Nothing here yet.'}</p>;
|
||||
}
|
||||
23
docker/Dockerfile
Normal file
23
docker/Dockerfile
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Build stage
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat openssl
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci || npm install
|
||||
COPY . .
|
||||
RUN npx prisma generate && npm run build
|
||||
|
||||
# Runtime stage
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN apk add --no-cache openssl && addgroup -S app && adduser -S app -G app
|
||||
COPY --from=build /app/.next/standalone ./
|
||||
COPY --from=build /app/.next/static ./.next/static
|
||||
COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/prisma ./prisma
|
||||
USER app
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
|
||||
CMD ["node", "server.js"]
|
||||
15
docker/docker-compose.yml
Normal file
15
docker/docker-compose.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
services:
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: "file:./prod.db"
|
||||
NEXT_PUBLIC_SITE_URL: "http://localhost:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
107
lib/csv.ts
Normal file
107
lib/csv.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
export type CsvRow = Record<string, string>;
|
||||
|
||||
export type ParsedCsv = { header: string[]; rows: CsvRow[] };
|
||||
|
||||
export type CustomerInput = { name: string; email: string; company: string; status: string };
|
||||
export type ImportResult = {
|
||||
total: number;
|
||||
valid: CustomerInput[];
|
||||
errors: { row: number; message: string }[];
|
||||
duplicatesInFile: number[];
|
||||
};
|
||||
|
||||
const KNOWN_STATUSES = ['active', 'lead', 'churned'];
|
||||
|
||||
/** Minimal RFC4180-ish CSV parser: quoted fields, escaped quotes, CRLF. */
|
||||
export function parseCsv(text: string): ParsedCsv {
|
||||
const rows: string[][] = [];
|
||||
let field = '';
|
||||
let row: string[] = [];
|
||||
let inQuotes = false;
|
||||
const src = text.replace(/\r\n?/g, '\n');
|
||||
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
const ch = src[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (src[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else inQuotes = false;
|
||||
} else field += ch;
|
||||
} else if (ch === '"') {
|
||||
inQuotes = true;
|
||||
} else if (ch === ',') {
|
||||
row.push(field);
|
||||
field = '';
|
||||
} else if (ch === '\n') {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
row = [];
|
||||
field = '';
|
||||
} else field += ch;
|
||||
}
|
||||
if (field.length > 0 || row.length > 0) {
|
||||
row.push(field);
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
const cleaned = rows.filter((r) => r.some((c) => c.trim() !== ''));
|
||||
const first = cleaned[0];
|
||||
if (!first) return { header: [], rows: [] };
|
||||
const header = first.map((h) => h.trim().toLowerCase());
|
||||
const out: CsvRow[] = cleaned.slice(1).map((r) => {
|
||||
const obj: CsvRow = {};
|
||||
header.forEach((h, idx) => (obj[h] = (r[idx] ?? '').trim()));
|
||||
return obj;
|
||||
});
|
||||
return { header, rows: out };
|
||||
}
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/** Validate parsed rows for the customer import: required columns, email shape, status enum. */
|
||||
export function validateCustomerRows(rows: CsvRow[]): ImportResult {
|
||||
const errors: { row: number; message: string }[] = [];
|
||||
const valid: CustomerInput[] = [];
|
||||
const seen = new Map<string, number>();
|
||||
const duplicatesInFile: number[] = [];
|
||||
|
||||
rows.forEach((row, idx) => {
|
||||
const line = idx + 2; // 1-based + header
|
||||
const name = (row.name ?? '').trim();
|
||||
const email = (row.email ?? '').trim().toLowerCase();
|
||||
const company = (row.company ?? '').trim();
|
||||
const status = (row.status ?? 'active').trim().toLowerCase() || 'active';
|
||||
|
||||
if (!name) {
|
||||
errors.push({ row: line, message: 'name is required' });
|
||||
return;
|
||||
}
|
||||
if (!email || !EMAIL_RE.test(email)) {
|
||||
errors.push({ row: line, message: `invalid email "${row.email ?? ''}"` });
|
||||
return;
|
||||
}
|
||||
if (!KNOWN_STATUSES.includes(status)) {
|
||||
errors.push({ row: line, message: `unknown status "${status}" (use ${KNOWN_STATUSES.join('/')})` });
|
||||
return;
|
||||
}
|
||||
const priorLine = seen.get(email);
|
||||
if (priorLine !== undefined) {
|
||||
duplicatesInFile.push(line);
|
||||
return;
|
||||
}
|
||||
seen.set(email, line);
|
||||
valid.push({ name, email, company, status });
|
||||
});
|
||||
|
||||
return { total: rows.length, valid, errors, duplicatesInFile };
|
||||
}
|
||||
|
||||
export function toCsv(rows: Record<string, unknown>[], columns: string[]): string {
|
||||
const escape = (v: unknown) => {
|
||||
const s = v === null || v === undefined ? '' : String(v);
|
||||
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
};
|
||||
return [columns.join(','), ...rows.map((r) => columns.map((c) => escape(r[c])).join(','))].join('\n');
|
||||
}
|
||||
20
lib/env.ts
Normal file
20
lib/env.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const schema = z.object({
|
||||
DATABASE_URL: z.string().default('file:./dev.db'),
|
||||
NEXT_PUBLIC_SITE_URL: z.string().url().default('http://localhost:3000'),
|
||||
STRIPE_SECRET_KEY: z.string().optional().default(''),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().optional().default(''),
|
||||
RESEND_API_KEY: z.string().optional().default(''),
|
||||
EMAIL_FROM: z.string().default('Acme <noreply@example.com>'),
|
||||
});
|
||||
|
||||
const parsed = schema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error('Invalid environment:', parsed.error.flatten().fieldErrors);
|
||||
throw new Error('Invalid environment configuration');
|
||||
}
|
||||
|
||||
export const env = parsed.data;
|
||||
export const isProd = process.env.NODE_ENV === 'production';
|
||||
19
lib/i18n.ts
Normal file
19
lib/i18n.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import en from '@/messages/en.json';
|
||||
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
export const messages = { en } as const;
|
||||
export type Locale = keyof typeof messages;
|
||||
|
||||
type Vars = Record<string, string | number>;
|
||||
|
||||
/** Tiny dictionary lookup: t('nav.pricing') with optional {name} interpolation. */
|
||||
export function t(key: string, vars?: Vars, locale: Locale = DEFAULT_LOCALE): string {
|
||||
const dict = messages[locale] as Record<string, unknown>;
|
||||
const value = key.split('.').reduce<unknown>((acc, part) => {
|
||||
if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[part];
|
||||
return undefined;
|
||||
}, dict);
|
||||
if (typeof value !== 'string') return key;
|
||||
if (!vars) return value;
|
||||
return value.replace(/\{(\w+)\}/g, (_, name: string) => String(vars[name] ?? `{${name}}`));
|
||||
}
|
||||
10
lib/password.ts
Normal file
10
lib/password.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import bcrypt from 'bcryptjs';
|
||||
|
||||
/** Pure password helpers (no server-only import) so they are unit-testable. */
|
||||
export function hashPassword(plain: string): Promise<string> {
|
||||
return bcrypt.hash(plain, 10);
|
||||
}
|
||||
|
||||
export function verifyPassword(plain: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(plain, hash);
|
||||
}
|
||||
41
lib/plans.ts
Normal file
41
lib/plans.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
export type PlanId = 'free' | 'pro';
|
||||
|
||||
export type Plan = {
|
||||
id: PlanId;
|
||||
name: string;
|
||||
price: number;
|
||||
/** Monthly included usage per metric. */
|
||||
limits: { seats: number; apiCalls: number };
|
||||
features: string[];
|
||||
};
|
||||
|
||||
export const PLANS: Record<PlanId, Plan> = {
|
||||
free: {
|
||||
id: 'free',
|
||||
name: 'Free',
|
||||
price: 0,
|
||||
limits: { seats: 2, apiCalls: 1_000 },
|
||||
features: ['Up to 2 members', '1,000 API calls / month', 'Community support'],
|
||||
},
|
||||
pro: {
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
price: 29,
|
||||
limits: { seats: 25, apiCalls: 100_000 },
|
||||
features: ['Up to 25 members', '100,000 API calls / month', 'Priority support'],
|
||||
},
|
||||
};
|
||||
|
||||
export const PLAN_IDS: PlanId[] = ['free', 'pro'];
|
||||
|
||||
export function isPlanId(value: unknown): value is PlanId {
|
||||
return value === 'free' || value === 'pro';
|
||||
}
|
||||
|
||||
export function getPlan(value: unknown): Plan {
|
||||
return isPlanId(value) ? PLANS[value] : PLANS.free;
|
||||
}
|
||||
|
||||
export function withinLimit(plan: unknown, metric: keyof Plan['limits'], used: number): boolean {
|
||||
return used < getPlan(plan).limits[metric];
|
||||
}
|
||||
24
lib/rbac.ts
Normal file
24
lib/rbac.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export type CrmRole = 'admin' | 'editor' | 'viewer';
|
||||
export const CRM_ROLES: CrmRole[] = ['admin', 'editor', 'viewer'];
|
||||
|
||||
export function isCrmRole(value: unknown): value is CrmRole {
|
||||
return value === 'admin' || value === 'editor' || value === 'viewer';
|
||||
}
|
||||
|
||||
/** Editors and admins may create/update records; viewers are read-only. */
|
||||
export function canWrite(role: unknown): boolean {
|
||||
return role === 'admin' || role === 'editor';
|
||||
}
|
||||
|
||||
/** Only admins may delete records. */
|
||||
export function canDelete(role: unknown): boolean {
|
||||
return role === 'admin';
|
||||
}
|
||||
|
||||
export function canImport(role: unknown): boolean {
|
||||
return role === 'admin' || role === 'editor';
|
||||
}
|
||||
|
||||
export function roleLabel(role: unknown): string {
|
||||
return isCrmRole(role) ? role : 'viewer';
|
||||
}
|
||||
15
lib/server/audit.ts
Normal file
15
lib/server/audit.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import 'server-only';
|
||||
import { db } from './db';
|
||||
|
||||
export type AuditInput = { actorId?: string | null; action: string; target?: string; meta?: unknown };
|
||||
|
||||
export async function recordAudit({ actorId, action, target, meta }: AuditInput): Promise<void> {
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
actorId: actorId ?? null,
|
||||
action,
|
||||
target: target ?? null,
|
||||
meta: meta === undefined ? null : JSON.stringify(meta),
|
||||
},
|
||||
});
|
||||
}
|
||||
75
lib/server/auth.ts
Normal file
75
lib/server/auth.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import 'server-only';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { db } from './db';
|
||||
import { isCrmRole } from '@/lib/rbac';
|
||||
|
||||
export { hashPassword, verifyPassword } from '@/lib/password';
|
||||
|
||||
export const SESSION_COOKIE = 'w4c_session';
|
||||
const TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
};
|
||||
|
||||
/** Create a session row and set the httpOnly cookie (works in Route Handlers and Server Actions). */
|
||||
export async function createSession(userId: string): Promise<string> {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + TTL_MS);
|
||||
await db.session.create({ data: { token, userId, expiresAt } });
|
||||
const store = await cookies();
|
||||
store.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
expires: expiresAt,
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function destroySession(): Promise<void> {
|
||||
const store = await cookies();
|
||||
const token = store.get(SESSION_COOKIE)?.value;
|
||||
if (token) await db.session.deleteMany({ where: { token } });
|
||||
store.delete(SESSION_COOKIE);
|
||||
}
|
||||
|
||||
export async function getCurrentUser(): Promise<SessionUser | null> {
|
||||
const store = await cookies();
|
||||
const token = store.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
const session = await db.session.findUnique({ where: { token }, include: { user: true } });
|
||||
if (!session || session.expiresAt < new Date()) return null;
|
||||
const { id, email, name, role } = session.user;
|
||||
return { id, email, name, role };
|
||||
}
|
||||
|
||||
/** Page guard: redirect to /login when unauthenticated. */
|
||||
export async function requireUser(): Promise<SessionUser> {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Page guard: require the admin role. */
|
||||
export async function requireAdmin(): Promise<SessionUser> {
|
||||
const user = await requireUser();
|
||||
if (user.role !== 'admin') redirect('/dashboard');
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page guard for the CRM shell: admin, editor and viewer may enter the admin area;
|
||||
* what they can *do* there is decided per action by lib/rbac.
|
||||
*/
|
||||
export async function requireStaff(): Promise<SessionUser> {
|
||||
const user = await requireUser();
|
||||
if (!isCrmRole(user.role)) redirect('/dashboard');
|
||||
return user;
|
||||
}
|
||||
178
lib/server/crm.ts
Normal file
178
lib/server/crm.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import 'server-only';
|
||||
import { z } from 'zod';
|
||||
import { db } from './db';
|
||||
import { recordAudit } from './audit';
|
||||
import { canDelete, canImport, canWrite } from '@/lib/rbac';
|
||||
import { parseCsv, toCsv, validateCustomerRows } from '@/lib/csv';
|
||||
|
||||
export const customerSchema = z.object({
|
||||
name: z.string().min(1).max(160),
|
||||
email: z.string().email().max(200),
|
||||
company: z.string().max(160).optional().or(z.literal('')),
|
||||
status: z.enum(['active', 'lead', 'churned']).optional(),
|
||||
});
|
||||
|
||||
export const dealSchema = z.object({
|
||||
title: z.string().min(1).max(160),
|
||||
customerId: z.string().optional().nullable(),
|
||||
value: z.number().int().min(0),
|
||||
stage: z.enum(['prospecting', 'proposal', 'won', 'lost']).optional(),
|
||||
owner: z.string().max(120).optional().or(z.literal('')),
|
||||
});
|
||||
|
||||
type Actor = { id: string; role: string };
|
||||
|
||||
export async function listCustomers({ q, status, sort, page = 1, pageSize = 10 }: { q?: string; status?: string; sort?: string; page?: number; pageSize?: number }) {
|
||||
const where = {
|
||||
...(status ? { status } : {}),
|
||||
...(q ? { OR: [{ name: { contains: q } }, { email: { contains: q } }, { company: { contains: q } }] } : {}),
|
||||
};
|
||||
const orderBy = sort === 'name' ? { name: 'asc' as const } : sort === 'company' ? { company: 'asc' as const } : { createdAt: 'desc' as const };
|
||||
const [items, total] = await Promise.all([
|
||||
db.customer.findMany({ where, orderBy, skip: (page - 1) * pageSize, take: pageSize }),
|
||||
db.customer.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize, pages: Math.max(1, Math.ceil(total / pageSize)) };
|
||||
}
|
||||
|
||||
export function getCustomer(id: string) {
|
||||
return db.customer.findUnique({ where: { id }, include: { deals: true } });
|
||||
}
|
||||
|
||||
export async function createCustomer(input: z.infer<typeof customerSchema>, actor: Actor) {
|
||||
if (!canWrite(actor.role)) return { ok: false as const, error: 'Forbidden' };
|
||||
try {
|
||||
const customer = await db.customer.create({
|
||||
data: { name: input.name, email: input.email.toLowerCase(), company: input.company || null, status: input.status ?? 'active' },
|
||||
});
|
||||
await recordAudit({ actorId: actor.id, action: 'customer.create', target: customer.email });
|
||||
return { ok: true as const, customer };
|
||||
} catch {
|
||||
return { ok: false as const, error: 'A customer with that email already exists.' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCustomer(id: string, input: z.infer<typeof customerSchema>, actor: Actor) {
|
||||
if (!canWrite(actor.role)) return { ok: false as const, error: 'Forbidden' };
|
||||
const customer = await db.customer.update({
|
||||
where: { id },
|
||||
data: { name: input.name, email: input.email.toLowerCase(), company: input.company || null, status: input.status ?? 'active' },
|
||||
});
|
||||
await recordAudit({ actorId: actor.id, action: 'customer.update', target: customer.email });
|
||||
return { ok: true as const, customer };
|
||||
}
|
||||
|
||||
export async function deleteCustomer(id: string, actor: Actor) {
|
||||
if (!canDelete(actor.role)) return { ok: false as const, error: 'Only admins can delete records.' };
|
||||
const customer = await db.customer.delete({ where: { id } });
|
||||
await recordAudit({ actorId: actor.id, action: 'customer.delete', target: customer.email });
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function listDeals({ stage, page = 1, pageSize = 10 }: { stage?: string; page?: number; pageSize?: number }) {
|
||||
const where = stage ? { stage } : {};
|
||||
const [items, total] = await Promise.all([
|
||||
db.deal.findMany({ where, include: { customer: true }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize }),
|
||||
db.deal.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize, pages: Math.max(1, Math.ceil(total / pageSize)) };
|
||||
}
|
||||
|
||||
export async function createDeal(input: z.infer<typeof dealSchema>, actor: Actor) {
|
||||
if (!canWrite(actor.role)) return { ok: false as const, error: 'Forbidden' };
|
||||
const deal = await db.deal.create({
|
||||
data: {
|
||||
title: input.title,
|
||||
customerId: input.customerId || null,
|
||||
value: input.value,
|
||||
stage: input.stage ?? 'prospecting',
|
||||
owner: input.owner || null,
|
||||
},
|
||||
});
|
||||
await recordAudit({ actorId: actor.id, action: 'deal.create', target: deal.title });
|
||||
return { ok: true as const, deal };
|
||||
}
|
||||
|
||||
export async function updateDeal(id: string, input: z.infer<typeof dealSchema>, actor: Actor) {
|
||||
if (!canWrite(actor.role)) return { ok: false as const, error: 'Forbidden' };
|
||||
const deal = await db.deal.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: input.title,
|
||||
customerId: input.customerId || null,
|
||||
value: input.value,
|
||||
stage: input.stage ?? 'prospecting',
|
||||
owner: input.owner || null,
|
||||
},
|
||||
});
|
||||
await recordAudit({ actorId: actor.id, action: 'deal.update', target: deal.title, meta: { stage: deal.stage } });
|
||||
return { ok: true as const, deal };
|
||||
}
|
||||
|
||||
export async function deleteDeal(id: string, actor: Actor) {
|
||||
if (!canDelete(actor.role)) return { ok: false as const, error: 'Only admins can delete records.' };
|
||||
const deal = await db.deal.delete({ where: { id } });
|
||||
await recordAudit({ actorId: actor.id, action: 'deal.delete', target: deal.title });
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function importCustomers(csvText: string, commit: boolean, actor: Actor) {
|
||||
if (!canImport(actor.role)) return { ok: false as const, error: 'Forbidden' };
|
||||
const { rows } = parseCsv(csvText);
|
||||
const parsed = validateCustomerRows(rows);
|
||||
if (!commit) {
|
||||
return { ok: true as const, preview: true, total: parsed.total, errors: parsed.errors, duplicatesInFile: parsed.duplicatesInFile, valid: parsed.valid.length, sample: parsed.valid.slice(0, 5) };
|
||||
}
|
||||
let imported = 0;
|
||||
let updated = 0;
|
||||
for (const row of parsed.valid) {
|
||||
const existing = await db.customer.findUnique({ where: { email: row.email } });
|
||||
if (existing) {
|
||||
await db.customer.update({ where: { email: row.email }, data: { name: row.name, company: row.company || null, status: row.status } });
|
||||
updated++;
|
||||
} else {
|
||||
await db.customer.create({ data: { name: row.name, email: row.email, company: row.company || null, status: row.status } });
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
await recordAudit({ actorId: actor.id, action: 'customer.import', target: `${imported} created, ${updated} updated`, meta: { errors: parsed.errors.length } });
|
||||
return { ok: true as const, preview: false, imported, updated, errors: parsed.errors, duplicatesInFile: parsed.duplicatesInFile };
|
||||
}
|
||||
|
||||
export async function exportCustomersCsv() {
|
||||
const rows = await db.customer.findMany({ orderBy: { createdAt: 'desc' } });
|
||||
return toCsv(
|
||||
rows.map((c) => ({ id: c.id, name: c.name, email: c.email, company: c.company ?? '', status: c.status, createdAt: c.createdAt.toISOString() })),
|
||||
['id', 'name', 'email', 'company', 'status', 'createdAt'],
|
||||
);
|
||||
}
|
||||
|
||||
export async function overview() {
|
||||
const [customers, leads, deals, won, recent] = await Promise.all([
|
||||
db.customer.count(),
|
||||
db.customer.count({ where: { status: 'lead' } }),
|
||||
db.deal.count(),
|
||||
db.deal.aggregate({ where: { stage: 'won' }, _sum: { value: true } }),
|
||||
db.auditLog.findMany({ orderBy: { createdAt: 'desc' }, take: 6, include: { actor: true } }),
|
||||
]);
|
||||
const byStage = await Promise.all(
|
||||
['prospecting', 'proposal', 'won', 'lost'].map(async (stage) => ({ stage, count: await db.deal.count({ where: { stage } }) })),
|
||||
);
|
||||
return {
|
||||
customers,
|
||||
leads,
|
||||
deals,
|
||||
wonValue: won._sum.value ?? 0,
|
||||
byStage,
|
||||
recent: recent.map((r) => ({ id: r.id, action: r.action, target: r.target, actor: r.actor?.email ?? 'system', createdAt: r.createdAt })),
|
||||
};
|
||||
}
|
||||
|
||||
export function listAudit({ action, take = 100 }: { action?: string; take?: number } = {}) {
|
||||
return db.auditLog.findMany({
|
||||
where: action ? { action: { contains: action } } : {},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take,
|
||||
include: { actor: true },
|
||||
});
|
||||
}
|
||||
8
lib/server/db.ts
Normal file
8
lib/server/db.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// A single PrismaClient per process (avoids exhausting connections in dev HMR).
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||
|
||||
export const db = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
|
||||
44
lib/server/email.ts
Normal file
44
lib/server/email.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import 'server-only';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
export type EmailMessage = { to: string; subject: string; html: string };
|
||||
export type EmailResult = { id: string; provider: string };
|
||||
|
||||
export interface EmailProvider {
|
||||
name: string;
|
||||
send(message: EmailMessage): Promise<EmailResult>;
|
||||
}
|
||||
|
||||
/** Default provider: no keys needed — logs the message and returns a synthetic id. */
|
||||
export class LogProvider implements EmailProvider {
|
||||
name = 'log';
|
||||
async send(message: EmailMessage): Promise<EmailResult> {
|
||||
console.log(`[email:log] to=${message.to} subject="${message.subject}"`);
|
||||
return { id: `log_${Date.now().toString(36)}`, provider: this.name };
|
||||
}
|
||||
}
|
||||
|
||||
/** Real provider, activated by RESEND_API_KEY. */
|
||||
export class ResendProvider implements EmailProvider {
|
||||
name = 'resend';
|
||||
constructor(private apiKey: string, private from: string) {}
|
||||
async send(message: EmailMessage): Promise<EmailResult> {
|
||||
const res = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from: this.from, to: message.to, subject: message.subject, html: message.html }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Resend error ${res.status}: ${await res.text()}`);
|
||||
const data = (await res.json()) as { id: string };
|
||||
return { id: data.id, provider: this.name };
|
||||
}
|
||||
}
|
||||
|
||||
export function getEmail(): EmailProvider {
|
||||
if (env.RESEND_API_KEY) return new ResendProvider(env.RESEND_API_KEY, env.EMAIL_FROM);
|
||||
return new LogProvider();
|
||||
}
|
||||
|
||||
export function sendEmail(message: EmailMessage): Promise<EmailResult> {
|
||||
return getEmail().send(message);
|
||||
}
|
||||
40
lib/server/guard.ts
Normal file
40
lib/server/guard.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import 'server-only';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getCurrentUser, type SessionUser } from './auth';
|
||||
import { canDelete, canImport, canWrite, isCrmRole } from '@/lib/rbac';
|
||||
|
||||
export type Guarded = { user: SessionUser; error?: undefined } | { user?: undefined; error: NextResponse };
|
||||
|
||||
const forbidden = () => NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
|
||||
/** API guard: a signed-in CRM user (admin | editor | viewer). */
|
||||
export async function apiStaff(): Promise<Guarded> {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return { error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
||||
if (!isCrmRole(user.role)) return { error: forbidden() };
|
||||
return { user };
|
||||
}
|
||||
|
||||
/** API guard for mutations a writer/editor may perform. */
|
||||
export async function apiWrite(): Promise<Guarded> {
|
||||
const g = await apiStaff();
|
||||
if (g.error) return g;
|
||||
if (!canWrite(g.user.role)) return { error: forbidden() };
|
||||
return g;
|
||||
}
|
||||
|
||||
/** API guard for destructive actions (admin only). */
|
||||
export async function apiDelete(): Promise<Guarded> {
|
||||
const g = await apiStaff();
|
||||
if (g.error) return g;
|
||||
if (!canDelete(g.user.role)) return { error: forbidden() };
|
||||
return g;
|
||||
}
|
||||
|
||||
/** API guard for CSV import (admin or editor). */
|
||||
export async function apiImport(): Promise<Guarded> {
|
||||
const g = await apiStaff();
|
||||
if (g.error) return g;
|
||||
if (!canImport(g.user.role)) return { error: forbidden() };
|
||||
return g;
|
||||
}
|
||||
86
lib/server/payments.ts
Normal file
86
lib/server/payments.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import 'server-only';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
export type CheckoutInput = { userId: string; email: string; plan: string; successUrl: string; cancelUrl: string };
|
||||
export type CheckoutResult = { id: string; url: string };
|
||||
export type WebhookEvent = { type: 'checkout.completed' | 'subscription.canceled' | 'ignored'; userId?: string; plan?: string };
|
||||
|
||||
export interface PaymentProvider {
|
||||
name: string;
|
||||
createCheckout(input: CheckoutInput): Promise<CheckoutResult>;
|
||||
parseWebhook(rawBody: string, signature: string | null): Promise<WebhookEvent>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-config provider. It never redirects off-site: the checkout route marks the
|
||||
* subscription active directly, so "plan → checkout → return → active" works with no keys.
|
||||
*/
|
||||
export class MockProvider implements PaymentProvider {
|
||||
name = 'mock';
|
||||
async createCheckout(input: CheckoutInput): Promise<CheckoutResult> {
|
||||
const id = `mock_${input.userId.slice(0, 8)}_${input.plan}_${Date.now().toString(36)}`;
|
||||
return { id, url: `${input.successUrl}?billing=success&provider=mock&plan=${encodeURIComponent(input.plan)}` };
|
||||
}
|
||||
async parseWebhook(rawBody: string): Promise<WebhookEvent> {
|
||||
try {
|
||||
const data = JSON.parse(rawBody) as { type?: string; userId?: string; plan?: string };
|
||||
if (!data.type) return { type: 'ignored' };
|
||||
return { type: data.type as WebhookEvent['type'], userId: data.userId, plan: data.plan };
|
||||
} catch {
|
||||
return { type: 'ignored' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Stripe provider over the REST API (no SDK dependency), activated by STRIPE_SECRET_KEY. */
|
||||
export class StripeProvider implements PaymentProvider {
|
||||
name = 'stripe';
|
||||
constructor(private secretKey: string, private webhookSecret: string) {}
|
||||
|
||||
async createCheckout(input: CheckoutInput): Promise<CheckoutResult> {
|
||||
const body = new URLSearchParams();
|
||||
body.set('mode', 'subscription');
|
||||
body.set('success_url', input.successUrl);
|
||||
body.set('cancel_url', input.cancelUrl);
|
||||
body.set('client_reference_id', input.userId);
|
||||
body.set('customer_email', input.email);
|
||||
body.set('line_items[0][price]', input.plan);
|
||||
body.set('line_items[0][quantity]', '1');
|
||||
const res = await fetch('https://api.stripe.com/v1/checkout/sessions', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${this.secretKey}`, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Stripe error ${res.status}: ${await res.text()}`);
|
||||
const data = (await res.json()) as { id: string; url: string };
|
||||
return { id: data.id, url: data.url };
|
||||
}
|
||||
|
||||
async parseWebhook(rawBody: string, signature: string | null): Promise<WebhookEvent> {
|
||||
if (!this.webhookSecret) throw new Error('STRIPE_WEBHOOK_SECRET is not configured');
|
||||
if (!verifyStripeSignature(rawBody, signature, this.webhookSecret)) return { type: 'ignored' };
|
||||
const event = JSON.parse(rawBody) as { type?: string; data?: { object?: { client_reference_id?: string } } };
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
return { type: 'checkout.completed', userId: event.data?.object?.client_reference_id };
|
||||
}
|
||||
if (event.type === 'customer.subscription.deleted') return { type: 'subscription.canceled' };
|
||||
return { type: 'ignored' };
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal Stripe signature check (t=timestamp,v1=signature) to make webhooks idempotent + trusted.
|
||||
function verifyStripeSignature(payload: string, header: string | null, secret: string): boolean {
|
||||
if (!header) return false;
|
||||
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=') as [string, string]));
|
||||
if (!parts.t || !parts.v1) return false;
|
||||
const expected = createHmac('sha256', secret).update(`${parts.t}.${payload}`).digest('hex');
|
||||
const a = Buffer.from(expected);
|
||||
const b = Buffer.from(parts.v1);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export function getPayments(): PaymentProvider {
|
||||
if (env.STRIPE_SECRET_KEY) return new StripeProvider(env.STRIPE_SECRET_KEY, env.STRIPE_WEBHOOK_SECRET);
|
||||
return new MockProvider();
|
||||
}
|
||||
21
lib/utils.ts
Normal file
21
lib/utils.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/** Join conditional class names. */
|
||||
export function cn(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number, currency = 'USD'): string {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
|
||||
}
|
||||
|
||||
export function formatDate(value: Date | string): string {
|
||||
const d = typeof value === 'string' ? new Date(value) : value;
|
||||
return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(d);
|
||||
}
|
||||
|
||||
export function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
141
messages/en.json
Normal file
141
messages/en.json
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
{
|
||||
"brand": { "name": "Acme CRM" },
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"pricing": "Pricing",
|
||||
"dashboard": "Dashboard",
|
||||
"admin": "Admin",
|
||||
"login": "Sign in",
|
||||
"register": "Create account",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"home": {
|
||||
"title": "The internal tool your team actually needs",
|
||||
"subtitle": "A ready-to-run CRM base: entity registry, roles, CSV import/export, audit log and a dashboard — working on day one.",
|
||||
"cta": "Open the dashboard",
|
||||
"secondaryCta": "View pricing",
|
||||
"featuresTitle": "Everything included",
|
||||
"features": {
|
||||
"auth": { "title": "Auth & roles", "body": "Email + password, httpOnly sessions, admin/editor/viewer server-side guards." },
|
||||
"billing": { "title": "Payments", "body": "Checkout works with a mock provider and no keys; Stripe activates from env." },
|
||||
"admin": { "title": "CRM registry", "body": "Customers and deals with CRUD, filters, CSV import/export and confirmation on delete." },
|
||||
"seo": { "title": "SEO & a11y", "body": "Metadata, sitemap, robots and AA contrast out of the box." }
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"title": "Simple pricing",
|
||||
"subtitle": "Start free. Upgrade when you grow.",
|
||||
"perMonth": "/ month",
|
||||
"choose": "Choose plan",
|
||||
"current": "Current plan",
|
||||
"upgraded": "Your plan is now active."
|
||||
},
|
||||
"auth": {
|
||||
"loginTitle": "Sign in",
|
||||
"registerTitle": "Create your account",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"name": "Name",
|
||||
"submitLogin": "Sign in",
|
||||
"submitRegister": "Create account",
|
||||
"noAccount": "No account yet?",
|
||||
"hasAccount": "Already have an account?",
|
||||
"demoHint": "Demo: admin@example.com / editor@example.com / viewer@example.com — password \"password\"."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Welcome, {name}",
|
||||
"plan": "Your plan",
|
||||
"usage": "Usage"
|
||||
},
|
||||
"admin": {
|
||||
"title": "CRM",
|
||||
"overview": "Overview",
|
||||
"users": "Users",
|
||||
"customers": "Customers",
|
||||
"deals": "Deals",
|
||||
"import": "Import",
|
||||
"audit": "Audit log",
|
||||
"totalUsers": "Total users",
|
||||
"activeSubs": "Active subscriptions",
|
||||
"customersTotal": "Customers",
|
||||
"leads": "Leads",
|
||||
"dealsTotal": "Deals",
|
||||
"wonValue": "Won value",
|
||||
"dealStages": "Deals by stage",
|
||||
"recentActivity": "Recent activity",
|
||||
"makeAdmin": "Make admin",
|
||||
"makeUser": "Make user",
|
||||
"delete": "Delete",
|
||||
"readOnly": "You have read-only access. Ask an admin for the editor role to make changes.",
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Name, email or company…",
|
||||
"status": "Status",
|
||||
"sort": "Sort",
|
||||
"sortNewest": "Newest",
|
||||
"sortName": "Name",
|
||||
"sortCompany": "Company",
|
||||
"all": "All",
|
||||
"active": "Active",
|
||||
"lead": "Lead",
|
||||
"churned": "Churned",
|
||||
"newCustomer": "New customer",
|
||||
"editCustomer": "Edit customer",
|
||||
"newDeal": "New deal",
|
||||
"editDeal": "Edit deal",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"company": "Company",
|
||||
"created": "Created",
|
||||
"updated": "Updated",
|
||||
"actions": "Actions",
|
||||
"prev": "Previous",
|
||||
"next": "Next",
|
||||
"pageOf": "Page {page} of {pages}",
|
||||
"empty": "No records match your filters.",
|
||||
"edit": "Edit",
|
||||
"dealTitle": "Title",
|
||||
"value": "Value",
|
||||
"stage": "Stage",
|
||||
"owner": "Owner",
|
||||
"customer": "Customer",
|
||||
"none": "—",
|
||||
"prospecting": "Prospecting",
|
||||
"proposal": "Proposal",
|
||||
"won": "Won",
|
||||
"lost": "Lost",
|
||||
"importTitle": "Import customers from CSV",
|
||||
"importHint": "Columns: name, email, company, status (active/lead/churned). Duplicate emails are skipped; existing emails are updated.",
|
||||
"chooseFile": "Choose a .csv file",
|
||||
"preview": "Preview",
|
||||
"commit": "Import {count} rows",
|
||||
"importing": "Working…",
|
||||
"previewResult": "{total} rows read · {valid} valid · {errors} errors · {duplicates} duplicates in file",
|
||||
"perRowErrors": "Row errors",
|
||||
"row": "Row",
|
||||
"message": "Message",
|
||||
"sample": "Preview (first 5 valid rows)",
|
||||
"noErrors": "No validation errors.",
|
||||
"imported": "{imported} created, {updated} updated.",
|
||||
"export": "Export CSV",
|
||||
"filterByAction": "Filter by action",
|
||||
"actionPlaceholder": "e.g. customer.create",
|
||||
"action": "Action",
|
||||
"target": "Target",
|
||||
"filter": "Filter",
|
||||
"clear": "Clear",
|
||||
"when": "When",
|
||||
"actor": "Actor"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading…",
|
||||
"empty": "Nothing here yet.",
|
||||
"error": "Something went wrong.",
|
||||
"retry": "Try again",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"delete": "Delete"
|
||||
}
|
||||
}
|
||||
28
middleware.ts
Normal file
28
middleware.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
|
||||
// Keep in sync with lib/server/auth.ts (middleware runs on the edge runtime and must not
|
||||
// import the server-only auth module).
|
||||
const SESSION_COOKIE = 'w4c_session';
|
||||
const PROTECTED = ['/dashboard', '/admin'];
|
||||
|
||||
/**
|
||||
* Coarse gate: unauthenticated visitors are redirected to /login before the page renders,
|
||||
* so the response is a real 307 (not a streamed client redirect). Role checks stay in the
|
||||
* page/layout guards, which remain authoritative.
|
||||
*/
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const isProtected = PROTECTED.some((p) => pathname === p || pathname.startsWith(`${p}/`));
|
||||
if (!isProtected) return NextResponse.next();
|
||||
if (request.cookies.get(SESSION_COOKIE)) return NextResponse.next();
|
||||
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = '/login';
|
||||
url.search = '';
|
||||
url.searchParams.set('next', pathname);
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/dashboard/:path*', '/admin/:path*'],
|
||||
};
|
||||
26
next.config.ts
Normal file
26
next.config.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { NextConfig } from 'next';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
outputFileTracingRoot: root,
|
||||
poweredByHeader: false,
|
||||
reactStrictMode: true,
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
8771
package-lock.json
generated
Normal file
8771
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
46
package.json
Normal file
46
package.json
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"name": "templates2-admin-next",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "node tests/e2e/smoke.mjs",
|
||||
"db:push": "prisma db push --skip-generate",
|
||||
"db:seed": "prisma db seed",
|
||||
"db:setup": "prisma db push --skip-generate && prisma db seed",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.3.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"next": "^15.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^15.2.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prisma": "^6.3.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
83
prisma/schema.prisma
Normal file
83
prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Templates2 Base — core data model shared by every case.
|
||||
// SQLite in development (file:./dev.db); switch the datasource url to Postgres in production.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String?
|
||||
role String @default("user") // user | admin
|
||||
createdAt DateTime @default(now())
|
||||
sessions Session[]
|
||||
subscription Subscription?
|
||||
auditLogs AuditLog[]
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
token String @unique
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
plan String @default("free") // free | pro
|
||||
status String @default("active") // active | canceled | past_due
|
||||
provider String @default("mock") // mock | stripe
|
||||
currentPeriodEnd DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
actorId String?
|
||||
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
|
||||
action String
|
||||
target String?
|
||||
meta String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([actorId])
|
||||
}
|
||||
|
||||
model Customer {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
email String @unique
|
||||
company String?
|
||||
status String @default("active") // active | lead | churned
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deals Deal[]
|
||||
}
|
||||
|
||||
model Deal {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
customerId String?
|
||||
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
|
||||
value Int @default(0)
|
||||
stage String @default("prospecting") // prospecting | proposal | won | lost
|
||||
owner String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([customerId])
|
||||
}
|
||||
74
prisma/seed.ts
Normal file
74
prisma/seed.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const db = new PrismaClient();
|
||||
|
||||
const COMPANIES = ['Northwind', 'Globex', 'Initech', 'Umbrella', 'Soylent', 'Hooli', 'Stark Industries', 'Wayne Enterprises'];
|
||||
const FIRST = ['Ada', 'Alan', 'Grace', 'Linus', 'Margaret', 'Dennis', 'Barbara', 'Ken'];
|
||||
const LAST = ['Lovelace', 'Turing', 'Hopper', 'Torvalds', 'Hamilton', 'Ritchie', 'Liskov', 'Thompson'];
|
||||
const STATUSES = ['active', 'lead', 'churned'];
|
||||
const STAGES = ['prospecting', 'proposal', 'won', 'lost'];
|
||||
|
||||
async function main() {
|
||||
const password = await bcrypt.hash('password', 10);
|
||||
const mk = (email: string, name: string, role: string) =>
|
||||
db.user.upsert({ where: { email }, update: { role }, create: { email, name, role, passwordHash: password } });
|
||||
|
||||
const admin = await mk('admin@example.com', 'Ada Admin', 'admin');
|
||||
await mk('editor@example.com', 'Evan Editor', 'editor');
|
||||
await mk('viewer@example.com', 'Vera Viewer', 'viewer');
|
||||
for (const email of ['admin@example.com', 'editor@example.com', 'viewer@example.com']) {
|
||||
const u = await db.user.findUnique({ where: { email } });
|
||||
if (u) await db.subscription.upsert({ where: { userId: u.id }, update: {}, create: { userId: u.id, plan: 'free', status: 'active', provider: 'mock' } });
|
||||
}
|
||||
|
||||
if ((await db.customer.count()) === 0) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const first = FIRST[i % FIRST.length];
|
||||
const last = LAST[Math.floor(i / FIRST.length) % LAST.length];
|
||||
const name = `${first} ${last}`;
|
||||
const email = `${first}.${last}.${i}@example.com`.toLowerCase();
|
||||
await db.customer.upsert({
|
||||
where: { email },
|
||||
update: {},
|
||||
create: { name, email, company: COMPANIES[i % COMPANIES.length], status: STATUSES[i % STATUSES.length] },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ((await db.deal.count()) === 0) {
|
||||
const customers = await db.customer.findMany({ take: 15 });
|
||||
for (let i = 0; i < Math.min(15, customers.length); i++) {
|
||||
await db.deal.create({
|
||||
data: {
|
||||
title: `Deal #${1000 + i} — ${customers[i].company ?? 'Direct'}`,
|
||||
customerId: customers[i].id,
|
||||
value: 1500 + i * 750,
|
||||
stage: STAGES[i % STAGES.length],
|
||||
owner: 'editor@example.com',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ((await db.auditLog.count()) === 0) {
|
||||
await db.auditLog.createMany({
|
||||
data: [
|
||||
{ actorId: admin.id, action: 'seed', target: 'system', meta: 'Initial demo data' },
|
||||
{ actorId: admin.id, action: 'customer.import', target: '20 created' },
|
||||
{ actorId: admin.id, action: 'deal.create', target: 'Deal #1000' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Seeded CRM: admin@example.com (admin), editor@example.com (editor), viewer@example.com (viewer) — password: "password"');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await db.$disconnect();
|
||||
});
|
||||
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<rect width="32" height="32" rx="7" fill="#4f46e5"/>
|
||||
<path d="M9 22V10h3.2l3.8 7 3.8-7H22v12h-2.6v-7.2L16 21.6h-1.2L11.6 14.8V22H9z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 242 B |
113
tests/e2e/smoke.mjs
Normal file
113
tests/e2e/smoke.mjs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* End-to-end smoke test — drives the running server over HTTP (no browser needed).
|
||||
* Start the app first (`npm start`), then: QA_BASE_URL=http://127.0.0.1:3000 node tests/e2e/smoke.mjs
|
||||
* The QA harness runs this automatically while the server is up.
|
||||
*
|
||||
* Covers the CRM core: staff guard, customers CRUD, CSV preview + commit, export, audit trail
|
||||
* and the read-only viewer role.
|
||||
*/
|
||||
const BASE = (process.env.QA_BASE_URL || 'http://127.0.0.1:3000').replace(/\/$/, '');
|
||||
const jar = new Map();
|
||||
const failures = [];
|
||||
|
||||
function store(res) {
|
||||
const list = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [];
|
||||
for (const raw of list) {
|
||||
const pair = raw.split(';')[0];
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq > 0) jar.set(pair.slice(0, eq), pair.slice(eq + 1));
|
||||
}
|
||||
}
|
||||
|
||||
async function raw(path, { method = 'GET', body, headers = {} } = {}) {
|
||||
const h = { ...headers };
|
||||
if (body !== undefined) h['Content-Type'] = 'application/json';
|
||||
if (jar.size) h.cookie = [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
const res = await fetch(BASE + path, { method, headers: h, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual' });
|
||||
store(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
async function req(path, opts) {
|
||||
const res = await raw(path, opts);
|
||||
const json = await res.clone().json().catch(() => null);
|
||||
return { status: res.status, json };
|
||||
}
|
||||
|
||||
function check(name, cond, detail = '') {
|
||||
if (cond) console.log(` ok ${name}`);
|
||||
else { console.log(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); failures.push(name); }
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const stamp = Date.now().toString(36);
|
||||
const newUser = `smoke_${stamp}@example.com`;
|
||||
const customer = { name: `Smoke ${stamp}`, email: `smoke_${stamp}@example.com`, company: 'Acme', status: 'lead' };
|
||||
|
||||
const health = await req('/api/health');
|
||||
check('health 200', health.status === 200 && health.json?.status === 'ok', `status=${health.status}`);
|
||||
|
||||
const reg = await req('/api/auth/register', { method: 'POST', body: { email: newUser, password: 'password123', name: 'Smoke' } });
|
||||
check('register 201', reg.status === 201, `status=${reg.status}`);
|
||||
|
||||
const blocked = await req('/api/admin/customers');
|
||||
check('non-staff blocked from CRM API', blocked.status === 403, `status=${blocked.status}`);
|
||||
|
||||
await req('/api/auth/logout', { method: 'POST' });
|
||||
|
||||
const adminLogin = await req('/api/auth/login', { method: 'POST', body: { email: 'admin@example.com', password: 'password' } });
|
||||
check('seeded admin can sign in', adminLogin.status === 200 && adminLogin.json?.user?.role === 'admin', `status=${adminLogin.status}`);
|
||||
|
||||
const seeded = await req('/api/admin/customers?pageSize=50');
|
||||
check('admin lists seeded customers', seeded.status === 200 && (seeded.json?.total ?? 0) >= 20, `status=${seeded.status} total=${seeded.json?.total}`);
|
||||
|
||||
const created = await req('/api/admin/customers', { method: 'POST', body: customer });
|
||||
check('create customer 201', created.status === 201, `status=${created.status}`);
|
||||
const id = created.json?.customer?.id;
|
||||
|
||||
const found = await req(`/api/admin/customers?q=${encodeURIComponent(customer.email)}`);
|
||||
check('created customer appears in the list', found.status === 200 && found.json?.items?.some((c) => c.email === customer.email), `status=${found.status}`);
|
||||
|
||||
const detail = await req(`/api/admin/customers/${id}`);
|
||||
check('customer detail 200', detail.status === 200 && detail.json?.customer?.email === customer.email, `status=${detail.status}`);
|
||||
|
||||
const patched = await req(`/api/admin/customers/${id}`, { method: 'PATCH', body: { ...customer, status: 'churned' } });
|
||||
check('update customer 200', patched.status === 200 && patched.json?.customer?.status === 'churned', `status=${patched.status}`);
|
||||
|
||||
const importCsv = `name,email,company,status\nImported ${stamp},imported_${stamp}@example.com,Globex,lead\nBroken,bad-email,Globex,active\n`;
|
||||
const preview = await req('/api/admin/import/customers', { method: 'POST', body: { csv: importCsv, commit: false } });
|
||||
check('csv preview reports errors and valid rows', preview.status === 200 && preview.json?.preview === true && preview.json?.valid === 1 && preview.json?.errors?.length === 1, `status=${preview.status} valid=${preview.json?.valid} errors=${preview.json?.errors?.length}`);
|
||||
|
||||
const committed = await req('/api/admin/import/customers', { method: 'POST', body: { csv: importCsv, commit: true } });
|
||||
check('csv commit imports the valid row', committed.status === 200 && (committed.json?.imported ?? 0) >= 1, `status=${committed.status} imported=${committed.json?.imported}`);
|
||||
|
||||
const exported = await raw('/api/admin/export/customers');
|
||||
const csvBody = await exported.text();
|
||||
check('csv export is a text/csv attachment', exported.status === 200 && /text\/csv/.test(exported.headers.get('content-type') || '') && csvBody.includes('email') && csvBody.includes(customer.email), `status=${exported.status}`);
|
||||
|
||||
const audit = await req('/api/admin/audit?action=customer.create');
|
||||
check('audit trail records the create', audit.status === 200 && (audit.json?.entries?.length ?? 0) >= 1, `status=${audit.status} entries=${audit.json?.entries?.length}`);
|
||||
|
||||
const removed = await req(`/api/admin/customers/${id}`, { method: 'DELETE' });
|
||||
check('admin can delete a customer', removed.status === 200, `status=${removed.status}`);
|
||||
|
||||
await req('/api/auth/logout', { method: 'POST' });
|
||||
|
||||
const viewerLogin = await req('/api/auth/login', { method: 'POST', body: { email: 'viewer@example.com', password: 'password' } });
|
||||
check('seeded viewer can sign in', viewerLogin.status === 200 && viewerLogin.json?.user?.role === 'viewer', `status=${viewerLogin.status}`);
|
||||
|
||||
const viewerRead = await req('/api/admin/customers');
|
||||
check('viewer can read the registry', viewerRead.status === 200, `status=${viewerRead.status}`);
|
||||
|
||||
const viewerWrite = await req('/api/admin/customers', { method: 'POST', body: { name: 'Nope', email: `viewer_${stamp}@example.com` } });
|
||||
check('viewer cannot create (read-only)', viewerWrite.status === 403, `status=${viewerWrite.status}`);
|
||||
|
||||
const viewerDelete = await req(`/api/admin/customers/${seeded.json?.items?.[0]?.id}`, { method: 'DELETE' });
|
||||
check('viewer cannot delete (read-only)', viewerDelete.status === 403, `status=${viewerDelete.status}`);
|
||||
|
||||
console.log(failures.length ? `\n${failures.length} check(s) failed` : '\nAll e2e checks passed');
|
||||
process.exit(failures.length ? 1 : 0);
|
||||
}
|
||||
|
||||
run().catch((e) => { console.error(e); process.exit(1); });
|
||||
19
tests/unit/auth.test.ts
Normal file
19
tests/unit/auth.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { hashPassword, verifyPassword } from '@/lib/password';
|
||||
import { slugify } from '@/lib/utils';
|
||||
|
||||
describe('password hashing', () => {
|
||||
it('round-trips a password', async () => {
|
||||
const hash = await hashPassword('password');
|
||||
expect(hash).not.toBe('password');
|
||||
expect(await verifyPassword('password', hash)).toBe(true);
|
||||
expect(await verifyPassword('wrong', hash)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugify', () => {
|
||||
it('produces a url-safe slug', () => {
|
||||
expect(slugify('Hello, World!')).toBe('hello-world');
|
||||
expect(slugify(' Already-slug ')).toBe('already-slug');
|
||||
});
|
||||
});
|
||||
55
tests/unit/csv.test.ts
Normal file
55
tests/unit/csv.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { parseCsv, toCsv, validateCustomerRows } from '@/lib/csv';
|
||||
|
||||
describe('parseCsv', () => {
|
||||
it('parses a header and quoted fields', () => {
|
||||
const { header, rows } = parseCsv('name,email,company\n"Doe, Jane",jane@example.com,"Acme, Inc"\n');
|
||||
expect(header).toEqual(['name', 'email', 'company']);
|
||||
expect(rows).toEqual([{ name: 'Doe, Jane', email: 'jane@example.com', company: 'Acme, Inc' }]);
|
||||
});
|
||||
|
||||
it('unescapes doubled quotes and tolerates CRLF', () => {
|
||||
const { rows } = parseCsv('a,b\r\n"say ""hi""",2\r\n');
|
||||
expect(rows[0]).toEqual({ a: 'say "hi"', b: '2' });
|
||||
});
|
||||
|
||||
it('ignores blank lines', () => {
|
||||
expect(parseCsv('a\n\n1\n').rows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCustomerRows', () => {
|
||||
const result = validateCustomerRows([
|
||||
{ name: 'Jane', email: 'JANE@Example.com', company: 'Acme', status: 'lead' },
|
||||
{ name: '', email: 'x@y.com', company: '', status: 'active' }, // missing name
|
||||
{ name: 'Bob', email: 'not-an-email', company: '', status: 'active' }, // bad email
|
||||
{ name: 'Ann', email: 'ann@example.com', company: '', status: 'vip' }, // bad status
|
||||
{ name: 'Dup', email: 'jane@example.com', company: '', status: 'active' }, // duplicate email
|
||||
{ name: 'Eve', email: 'eve@example.com', company: '', status: '' }, // empty status → active
|
||||
]);
|
||||
|
||||
it('normalizes email casing and fills the default status', () => {
|
||||
expect(result.valid[0]).toEqual({ name: 'Jane', email: 'jane@example.com', company: 'Acme', status: 'lead' });
|
||||
expect(result.valid[1]).toEqual({ name: 'Eve', email: 'eve@example.com', company: '', status: 'active' });
|
||||
});
|
||||
|
||||
it('reports one error per invalid row (line numbers are 1-based + header)', () => {
|
||||
expect(result.errors.map((e) => e.row)).toEqual([3, 4, 5]);
|
||||
});
|
||||
|
||||
it('flags duplicate emails within the file', () => {
|
||||
expect(result.duplicatesInFile).toEqual([6]);
|
||||
expect(result.total).toBe(6);
|
||||
expect(result.valid).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toCsv', () => {
|
||||
it('escapes commas and quotes and keeps the column order', () => {
|
||||
expect(toCsv([{ a: 'x,y', b: 'say "hi"' }], ['a', 'b'])).toBe('a,b\n"x,y","say ""hi"""');
|
||||
});
|
||||
|
||||
it('renders null/undefined as empty fields', () => {
|
||||
expect(toCsv([{ a: null, b: undefined }], ['a', 'b'])).toBe('a,b\n,');
|
||||
});
|
||||
});
|
||||
22
tests/unit/plans.test.ts
Normal file
22
tests/unit/plans.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { getPlan, isPlanId, PLANS, withinLimit } from '@/lib/plans';
|
||||
|
||||
describe('plans', () => {
|
||||
it('recognises valid plan ids only', () => {
|
||||
expect(isPlanId('free')).toBe(true);
|
||||
expect(isPlanId('pro')).toBe(true);
|
||||
expect(isPlanId('enterprise')).toBe(false);
|
||||
expect(isPlanId(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the free plan for unknown values', () => {
|
||||
expect(getPlan('nonsense').id).toBe('free');
|
||||
expect(getPlan('pro').id).toBe('pro');
|
||||
});
|
||||
|
||||
it('enforces usage limits inclusively below the cap', () => {
|
||||
expect(withinLimit('free', 'seats', PLANS.free.limits.seats - 1)).toBe(true);
|
||||
expect(withinLimit('free', 'seats', PLANS.free.limits.seats)).toBe(false);
|
||||
expect(withinLimit('pro', 'apiCalls', 50_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
12
vitest.config.ts
Normal file
12
vitest.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: { '@': fileURLToPath(new URL('.', import.meta.url)) },
|
||||
},
|
||||
test: {
|
||||
include: ['tests/unit/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue