mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #55 from ArnasDon/fix/opengraph-image-build-crash
feat(seo): full SEO pass (reapply with OG-image build fix)
This commit is contained in:
commit
6a38fce6f6
24
src/app/(auth)/layout.tsx
Normal file
24
src/app/(auth)/layout.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Shared metadata for auth pages (login / signup / forgot-password).
|
||||||
|
// None of these should be indexed — they'd compete with the marketing
|
||||||
|
// landing in SERPs and offer nothing to a searcher who hasn't already
|
||||||
|
// signed up. Each page still gets its own <title> via its own
|
||||||
|
// metadata.title override below the route group layout.
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
robots: {
|
||||||
|
index: false,
|
||||||
|
follow: false,
|
||||||
|
nocache: true,
|
||||||
|
googleBot: {
|
||||||
|
index: false,
|
||||||
|
follow: false,
|
||||||
|
noimageindex: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AuthLayout({ children }: { children: ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
53
src/app/(dashboard)/dashboard-shell.tsx
Normal file
53
src/app/(dashboard)/dashboard-shell.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AuthProvider, useAuth } from "@/hooks/use-auth";
|
||||||
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
|
import { Header } from "@/components/layout/header";
|
||||||
|
|
||||||
|
// Auth-gated dashboard shell. Extracted from the layout so the layout
|
||||||
|
// itself can stay a server component and export metadata (noindex) —
|
||||||
|
// client components can't export Next's metadata object.
|
||||||
|
|
||||||
|
function DashboardShellInner({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !user) {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
}, [user, loading, router]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-slate-950">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||||
|
<p className="text-sm text-slate-400">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-slate-950">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<DashboardShellInner>{children}</DashboardShellInner>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,53 +1,28 @@
|
|||||||
"use client";
|
import type { Metadata } from "next";
|
||||||
|
import { DashboardShell } from "./dashboard-shell";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
// Server layout whose only job is to declare "do not index" metadata
|
||||||
import { useRouter } from "next/navigation";
|
// for the authed app. robots.ts already disallows these paths at the
|
||||||
import { AuthProvider, useAuth } from "@/hooks/use-auth";
|
// crawler-level and middleware redirects unauthenticated visitors, so
|
||||||
import { Sidebar } from "@/components/layout/sidebar";
|
// this is belt-and-suspenders — but SEO-critical if a URL ever leaks
|
||||||
import { Header } from "@/components/layout/header";
|
// via a link shared externally.
|
||||||
|
export const metadata: Metadata = {
|
||||||
function DashboardShell({ children }: { children: React.ReactNode }) {
|
robots: {
|
||||||
const { user, loading } = useAuth();
|
index: false,
|
||||||
const router = useRouter();
|
follow: false,
|
||||||
|
nocache: true,
|
||||||
useEffect(() => {
|
googleBot: {
|
||||||
if (!loading && !user) {
|
index: false,
|
||||||
router.push("/login");
|
follow: false,
|
||||||
}
|
noimageindex: true,
|
||||||
}, [user, loading, router]);
|
},
|
||||||
|
},
|
||||||
if (loading) {
|
};
|
||||||
return (
|
|
||||||
<div className="flex h-screen items-center justify-center bg-slate-950">
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
|
||||||
<p className="text-sm text-slate-400">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen overflow-hidden bg-slate-950">
|
|
||||||
<Sidebar />
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
|
||||||
<Header />
|
|
||||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return <DashboardShell>{children}</DashboardShell>;
|
||||||
<AuthProvider>
|
|
||||||
<DashboardShell>{children}</DashboardShell>
|
|
||||||
</AuthProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,88 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import {
|
||||||
|
OG_IMAGE_ALT,
|
||||||
|
SITE_DESCRIPTION,
|
||||||
|
SITE_KEYWORDS,
|
||||||
|
SITE_NAME,
|
||||||
|
SITE_TAGLINE,
|
||||||
|
SITE_URL,
|
||||||
|
} from "@/lib/seo/site-config";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
variable: "--font-sans",
|
variable: "--font-sans",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Consolidated site-wide metadata. Page-specific metadata (landing,
|
||||||
|
// auth pages, etc.) override the narrower fields (title, description,
|
||||||
|
// robots); defaults defined here are inherited everywhere else so we
|
||||||
|
// never ship a page without OpenGraph / Twitter coverage.
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "WaCRM - WhatsApp CRM",
|
metadataBase: new URL(SITE_URL),
|
||||||
description: "WhatsApp CRM for managing conversations, contacts, and pipelines",
|
title: {
|
||||||
|
default: `${SITE_NAME} — ${SITE_TAGLINE}`,
|
||||||
|
template: `%s — ${SITE_NAME}`,
|
||||||
|
},
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
keywords: SITE_KEYWORDS,
|
||||||
|
applicationName: SITE_NAME,
|
||||||
|
authors: [{ name: SITE_NAME, url: SITE_URL }],
|
||||||
|
creator: SITE_NAME,
|
||||||
|
publisher: SITE_NAME,
|
||||||
|
category: "Business Software",
|
||||||
|
alternates: {
|
||||||
|
canonical: "/",
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
siteName: SITE_NAME,
|
||||||
|
title: `${SITE_NAME} — ${SITE_TAGLINE}`,
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
url: SITE_URL,
|
||||||
|
locale: "en_US",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "/opengraph-image",
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: OG_IMAGE_ALT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: `${SITE_NAME} — ${SITE_TAGLINE}`,
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
images: ["/opengraph-image"],
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
googleBot: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
"max-image-preview": "large",
|
||||||
|
"max-snippet": -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
icons: {
|
||||||
|
icon: [{ url: "/icon" }],
|
||||||
|
shortcut: ["/favicon.ico"],
|
||||||
|
},
|
||||||
|
formatDetection: {
|
||||||
|
email: false,
|
||||||
|
address: false,
|
||||||
|
telephone: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dark theme color for the mobile browser chrome.
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#020617",
|
||||||
|
colorScheme: "dark",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
118
src/app/opengraph-image.tsx
Normal file
118
src/app/opengraph-image.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { ImageResponse } from 'next/og'
|
||||||
|
import { SITE_NAME, SITE_TAGLINE } from '@/lib/seo/site-config'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated OG image served at /opengraph-image.
|
||||||
|
* Next renders this JSX to a PNG at build time, caches it, and wires
|
||||||
|
* the URL into the default `<meta property="og:image">` for every
|
||||||
|
* page that inherits the root metadata.
|
||||||
|
*
|
||||||
|
* Facebook / LinkedIn / Slack / Twitter all fetch this when the link
|
||||||
|
* is shared, so keep it readable at thumbnail sizes (no tiny text,
|
||||||
|
* high contrast).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const alt = `${SITE_NAME} — ${SITE_TAGLINE}`
|
||||||
|
export const size = { width: 1200, height: 630 }
|
||||||
|
export const contentType = 'image/png'
|
||||||
|
|
||||||
|
export default async function Image() {
|
||||||
|
// Satori (the renderer behind next/og) is strict about two things
|
||||||
|
// that the first version of this file violated at BUILD time —
|
||||||
|
// which meant `next build` failed on /opengraph-image during
|
||||||
|
// static generation and the whole production build blew up.
|
||||||
|
//
|
||||||
|
// 1. Every <div> with more than one child must explicitly declare
|
||||||
|
// display: flex / contents / none.
|
||||||
|
// 2. Any Unicode glyph outside the default font's coverage fails
|
||||||
|
// with "Failed to download dynamic font". ✓ was the worst
|
||||||
|
// offender — replaced below with plain text dot separators.
|
||||||
|
return new ImageResponse(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 80,
|
||||||
|
background:
|
||||||
|
'linear-gradient(135deg, #020617 0%, #0f172a 55%, #064e3b 100%)',
|
||||||
|
color: 'white',
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 18 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 14,
|
||||||
|
background: '#10b981',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 34,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
W
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', fontSize: 36, fontWeight: 700, letterSpacing: -0.5 }}>
|
||||||
|
{SITE_NAME}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
marginTop: 60,
|
||||||
|
fontSize: 84,
|
||||||
|
fontWeight: 800,
|
||||||
|
lineHeight: 1.02,
|
||||||
|
letterSpacing: -2,
|
||||||
|
maxWidth: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Run your WhatsApp business from </span>
|
||||||
|
<span style={{ color: '#34d399' }}>one inbox.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
marginTop: 28,
|
||||||
|
fontSize: 28,
|
||||||
|
color: '#94a3b8',
|
||||||
|
maxWidth: 900,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Shared inbox, sales pipelines, broadcasts, and no-code automations — built on the official WhatsApp Business API.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 56,
|
||||||
|
display: 'flex',
|
||||||
|
gap: 24,
|
||||||
|
fontSize: 22,
|
||||||
|
color: '#cbd5e1',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Shared inbox</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>Automations</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>Pipelines</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>Analytics</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
size,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -12,11 +12,22 @@ import { InboxMock } from '@/components/landing/mock/inbox-mock'
|
|||||||
import { PipelineMock } from '@/components/landing/mock/pipeline-mock'
|
import { PipelineMock } from '@/components/landing/mock/pipeline-mock'
|
||||||
import { AutomationMock } from '@/components/landing/mock/automation-mock'
|
import { AutomationMock } from '@/components/landing/mock/automation-mock'
|
||||||
import { AnalyticsMock } from '@/components/landing/mock/analytics-mock'
|
import { AnalyticsMock } from '@/components/landing/mock/analytics-mock'
|
||||||
|
import { JsonLd } from '@/components/seo/json-ld'
|
||||||
|
import { landingPageLd } from '@/lib/seo/structured-data'
|
||||||
|
import { SITE_DESCRIPTION, SITE_NAME, SITE_TAGLINE } from '@/lib/seo/site-config'
|
||||||
|
|
||||||
|
// Landing-specific metadata. Most fields are inherited from the root
|
||||||
|
// layout's metadata — we override title (so the hero copy shows in
|
||||||
|
// SERPs / tab titles) and set an explicit canonical for "/" to avoid
|
||||||
|
// trailing-slash duplicate-content signals.
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'WaCRM — Run your WhatsApp business from one inbox',
|
title: {
|
||||||
description:
|
absolute: `${SITE_NAME} — ${SITE_TAGLINE}`,
|
||||||
'Shared inbox, contact hub, sales pipelines, broadcasts, and no-code automations for WhatsApp. Built on the official WhatsApp Business API.',
|
},
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
alternates: {
|
||||||
|
canonical: '/',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Marketing landing. Visible at / for everyone (auth redirect that
|
// Marketing landing. Visible at / for everyone (auth redirect that
|
||||||
@ -25,6 +36,9 @@ export const metadata: Metadata = {
|
|||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-950 text-slate-100">
|
<div className="bg-slate-950 text-slate-100">
|
||||||
|
{/* JSON-LD — WebSite, Organization, SoftwareApplication, FAQPage.
|
||||||
|
Emitted before any visible content so crawlers hit it first. */}
|
||||||
|
<JsonLd data={landingPageLd()} />
|
||||||
<LandingNav />
|
<LandingNav />
|
||||||
<main>
|
<main>
|
||||||
<Hero />
|
<Hero />
|
||||||
|
|||||||
35
src/app/robots.ts
Normal file
35
src/app/robots.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import type { MetadataRoute } from 'next'
|
||||||
|
import { SITE_URL } from '@/lib/seo/site-config'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* robots.txt served at /robots.txt. We explicitly disallow every
|
||||||
|
* gated area so crawlers don't spend their budget on pages they'll
|
||||||
|
* bounce off. Auth pages are closed off too — no SEO value, and
|
||||||
|
* we don't want "Sign in to WaCRM" competing with the landing page
|
||||||
|
* in SERPs.
|
||||||
|
*/
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
userAgent: '*',
|
||||||
|
allow: '/',
|
||||||
|
disallow: [
|
||||||
|
'/api/',
|
||||||
|
'/dashboard',
|
||||||
|
'/inbox',
|
||||||
|
'/contacts',
|
||||||
|
'/pipelines',
|
||||||
|
'/broadcasts',
|
||||||
|
'/automations',
|
||||||
|
'/settings',
|
||||||
|
'/login',
|
||||||
|
'/signup',
|
||||||
|
'/forgot-password',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||||
|
host: SITE_URL,
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/app/sitemap.ts
Normal file
20
src/app/sitemap.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { MetadataRoute } from 'next'
|
||||||
|
import { SITE_URL } from '@/lib/seo/site-config'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sitemap served at /sitemap.xml. Only lists public, indexable pages
|
||||||
|
* — auth pages are explicitly noindex, and every route under /app is
|
||||||
|
* gated. The landing anchors (#features, #how-it-works, #faq) don't
|
||||||
|
* belong here; fragments aren't a sitemap concept.
|
||||||
|
*/
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const now = new Date()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: `${SITE_URL}/`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: 'weekly',
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -3,38 +3,9 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { ChevronDown } from 'lucide-react'
|
import { ChevronDown } from 'lucide-react'
|
||||||
import { Section, SectionHeader } from './section'
|
import { Section, SectionHeader } from './section'
|
||||||
|
import { FAQ_ITEMS } from '@/lib/seo/faq-data'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
// Answering real objections a small-business owner has before signing
|
|
||||||
// up. Order matters — setup friction is usually the #1 concern, cost
|
|
||||||
// #2, data ownership #3.
|
|
||||||
const QA = [
|
|
||||||
{
|
|
||||||
q: 'Do I need my own WhatsApp Business API access?',
|
|
||||||
a: "Yes. WaCRM plugs into your existing Meta WhatsApp Business setup — you bring the phone number and access token, we provide the CRM tooling around it. Any Meta-approved BSP (Business Solution Provider) works.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: 'Can my whole team share one WhatsApp number?',
|
|
||||||
a: 'Yes. Assign conversations to specific agents, track who is responding to what, and hand off threads without losing context. All your agents work from a single shared inbox.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: 'How fast is setup?',
|
|
||||||
a: 'Most teams are live in under 30 minutes once their WhatsApp Business number has been approved by Meta. Paste your credentials in Settings, import contacts if you have them, and start replying.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: 'Who owns the data?',
|
|
||||||
a: 'You do. Everything lives in your own Supabase project — contacts, conversations, deals, automation logs. Export it anytime; there is no lock-in on the data layer.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: 'Can I send bulk messages and automated replies?',
|
|
||||||
a: 'Yes. Broadcasts send Meta-approved templates to segmented contact lists with delivery tracking. Automations run no-code flows triggered by new contacts, keywords, tag changes, and more.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
q: 'What about message templates?',
|
|
||||||
a: 'Templates you create in Meta are synced automatically. Use them from the inbox, broadcasts, or inside an automation step.',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function FAQ() {
|
export function FAQ() {
|
||||||
const [openIdx, setOpenIdx] = useState<number | null>(0)
|
const [openIdx, setOpenIdx] = useState<number | null>(0)
|
||||||
return (
|
return (
|
||||||
@ -46,7 +17,7 @@ export function FAQ() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mx-auto max-w-3xl divide-y divide-slate-800 rounded-xl border border-slate-800 bg-slate-900/40">
|
<div className="mx-auto max-w-3xl divide-y divide-slate-800 rounded-xl border border-slate-800 bg-slate-900/40">
|
||||||
{QA.map((item, i) => {
|
{FAQ_ITEMS.map((item, i) => {
|
||||||
const isOpen = openIdx === i
|
const isOpen = openIdx === i
|
||||||
return (
|
return (
|
||||||
<div key={item.q}>
|
<div key={item.q}>
|
||||||
|
|||||||
22
src/components/seo/json-ld.tsx
Normal file
22
src/components/seo/json-ld.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Emits one <script type="application/ld+json"> per schema node.
|
||||||
|
* Google recommends separate tags per schema type over one combined
|
||||||
|
* @graph blob, to make it easier for them to parse selectively.
|
||||||
|
*
|
||||||
|
* Uses dangerouslySetInnerHTML because React escapes the < / > in
|
||||||
|
* script bodies otherwise, which breaks JSON-LD in subtle ways.
|
||||||
|
* The input is JSON.stringify'd locally — no user input flows here.
|
||||||
|
*/
|
||||||
|
export function JsonLd({ data }: { data: Record<string, unknown>[] }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{data.map((node, i) => (
|
||||||
|
<script
|
||||||
|
key={i}
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(node) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/lib/seo/faq-data.ts
Normal file
36
src/lib/seo/faq-data.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* FAQ content used by both the rendered FAQ accordion on the landing
|
||||||
|
* page AND the FAQPage JSON-LD emitted for rich-result eligibility
|
||||||
|
* on search engines. Single source so they never drift.
|
||||||
|
*/
|
||||||
|
export interface FaqItem {
|
||||||
|
q: string
|
||||||
|
a: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FAQ_ITEMS: FaqItem[] = [
|
||||||
|
{
|
||||||
|
q: 'Do I need my own WhatsApp Business API access?',
|
||||||
|
a: 'Yes. WaCRM plugs into your existing Meta WhatsApp Business setup — you bring the phone number and access token, we provide the CRM tooling around it. Any Meta-approved BSP (Business Solution Provider) works.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'Can my whole team share one WhatsApp number?',
|
||||||
|
a: 'Yes. Assign conversations to specific agents, track who is responding to what, and hand off threads without losing context. All your agents work from a single shared inbox.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'How fast is setup?',
|
||||||
|
a: 'Most teams are live in under 30 minutes once their WhatsApp Business number has been approved by Meta. Paste your credentials in Settings, import contacts if you have them, and start replying.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'Who owns the data?',
|
||||||
|
a: 'You do. Everything lives in your own Supabase project — contacts, conversations, deals, automation logs. Export it anytime; there is no lock-in on the data layer.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'Can I send bulk messages and automated replies?',
|
||||||
|
a: 'Yes. Broadcasts send Meta-approved templates to segmented contact lists with delivery tracking. Automations run no-code flows triggered by new contacts, keywords, tag changes, and more.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: 'What about message templates?',
|
||||||
|
a: 'Templates you create in Meta are synced automatically. Use them from the inbox, broadcasts, or inside an automation step.',
|
||||||
|
},
|
||||||
|
]
|
||||||
56
src/lib/seo/site-config.ts
Normal file
56
src/lib/seo/site-config.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Single source of truth for anything the SEO layer needs to know
|
||||||
|
* about the site — base URL, brand name, headline copy, social
|
||||||
|
* handles. Import from here everywhere (metadata, JSON-LD, sitemap,
|
||||||
|
* robots, OG image) so a rename or domain change is a one-file edit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Production origin — used to resolve all absolute URLs in metadata. */
|
||||||
|
export const SITE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, '') || 'https://wacrm.tech'
|
||||||
|
|
||||||
|
export const SITE_NAME = 'WaCRM'
|
||||||
|
|
||||||
|
export const SITE_TAGLINE = 'Run your WhatsApp business from one inbox'
|
||||||
|
|
||||||
|
/** Default description — reused by layout metadata and structured data. */
|
||||||
|
export const SITE_DESCRIPTION =
|
||||||
|
'WaCRM is a WhatsApp CRM for small teams: shared inbox, contact hub, sales pipelines, broadcasts, and no-code automations — built on the official WhatsApp Business API.'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyword targets. Search engines largely ignore the meta keywords
|
||||||
|
* field, but a couple still use it and it's a cheap inclusion.
|
||||||
|
*/
|
||||||
|
export const SITE_KEYWORDS = [
|
||||||
|
'WhatsApp CRM',
|
||||||
|
'WhatsApp Business CRM',
|
||||||
|
'WhatsApp shared inbox',
|
||||||
|
'WhatsApp Business API',
|
||||||
|
'WhatsApp automation',
|
||||||
|
'WhatsApp broadcast',
|
||||||
|
'customer messaging platform',
|
||||||
|
'sales pipeline CRM',
|
||||||
|
'team inbox',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Public hero image for OG / Twitter previews. */
|
||||||
|
export const OG_IMAGE_PATH = '/opengraph-image'
|
||||||
|
export const OG_IMAGE_ALT = `${SITE_NAME} — ${SITE_TAGLINE}`
|
||||||
|
|
||||||
|
/** Organization info surfaced in JSON-LD. */
|
||||||
|
export const ORG_INFO = {
|
||||||
|
name: SITE_NAME,
|
||||||
|
legalName: 'WaCRM',
|
||||||
|
url: SITE_URL,
|
||||||
|
logo: `${SITE_URL}/icon`,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: absolute-URL a relative path using SITE_URL.
|
||||||
|
* Use this instead of string concat so trailing-slash handling stays
|
||||||
|
* consistent.
|
||||||
|
*/
|
||||||
|
export function absoluteUrl(path = '/'): string {
|
||||||
|
const trimmed = path.startsWith('/') ? path : `/${path}`
|
||||||
|
return `${SITE_URL}${trimmed}`
|
||||||
|
}
|
||||||
110
src/lib/seo/structured-data.ts
Normal file
110
src/lib/seo/structured-data.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import {
|
||||||
|
absoluteUrl,
|
||||||
|
ORG_INFO,
|
||||||
|
SITE_DESCRIPTION,
|
||||||
|
SITE_NAME,
|
||||||
|
SITE_URL,
|
||||||
|
} from './site-config'
|
||||||
|
import { FAQ_ITEMS } from './faq-data'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builders for the JSON-LD blobs we inject on the landing page. Each
|
||||||
|
* returns a plain object — the consumer wraps it in <script
|
||||||
|
* type="application/ld+json">. Keeping them typed as `Record<string,
|
||||||
|
* unknown>` so schema.org shapes don't leak into the rest of the app.
|
||||||
|
*/
|
||||||
|
type Ld = Record<string, unknown>
|
||||||
|
|
||||||
|
/** Generic site-level node. Helps search engines map the domain. */
|
||||||
|
export function websiteLd(): Ld {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebSite',
|
||||||
|
name: SITE_NAME,
|
||||||
|
url: SITE_URL,
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
inLanguage: 'en',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publisher / brand. Paired with the WebSite node. */
|
||||||
|
export function organizationLd(): Ld {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: ORG_INFO.name,
|
||||||
|
legalName: ORG_INFO.legalName,
|
||||||
|
url: ORG_INFO.url,
|
||||||
|
logo: ORG_INFO.logo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core product node. SoftwareApplication is the recommended type for
|
||||||
|
* web-based SaaS in Google's Rich Results guidelines. The free-tier
|
||||||
|
* `offers` block enables a "Free" badge in SERPs where eligible.
|
||||||
|
*/
|
||||||
|
export function softwareApplicationLd(): Ld {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'SoftwareApplication',
|
||||||
|
name: SITE_NAME,
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
applicationCategory: 'BusinessApplication',
|
||||||
|
operatingSystem: 'Web',
|
||||||
|
url: SITE_URL,
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
availability: 'https://schema.org/InStock',
|
||||||
|
},
|
||||||
|
featureList: [
|
||||||
|
'Shared WhatsApp inbox',
|
||||||
|
'Contact management with tags and custom fields',
|
||||||
|
'Sales pipelines with kanban board',
|
||||||
|
'Broadcast campaigns via approved WhatsApp templates',
|
||||||
|
'No-code workflow automations',
|
||||||
|
'Real-time analytics dashboard',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FAQPage — eligible for the FAQ rich result on Google.
|
||||||
|
* We build this from the same source the UI renders, so answers
|
||||||
|
* in SERP match answers on the page.
|
||||||
|
*/
|
||||||
|
export function faqPageLd(): Ld {
|
||||||
|
return {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
mainEntity: FAQ_ITEMS.map((item) => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: item.q,
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: item.a,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: an array of the nodes we want on the landing page.
|
||||||
|
* The consumer component iterates and emits one <script> per node
|
||||||
|
* (Google prefers one schema per script tag).
|
||||||
|
*/
|
||||||
|
export function landingPageLd(): Ld[] {
|
||||||
|
return [
|
||||||
|
websiteLd(),
|
||||||
|
organizationLd(),
|
||||||
|
softwareApplicationLd(),
|
||||||
|
faqPageLd(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept exported for discoverability; unused by runtime code but handy
|
||||||
|
// for anyone building additional pages that need an absolute URL in
|
||||||
|
// their JSON-LD.
|
||||||
|
export { absoluteUrl }
|
||||||
Loading…
Reference in New Issue
Block a user