website: convert CTA to sign-up (#108)
* landing: convert main CTA to sign-up, add shared marketing layout - Replace email waitlist form with "Try now" sign-up button as primary CTA - Add shared _marketing layout route with consistent nav (Pricing, GitHub, Sign in) - Move email form to bottom of homepage as "Stay in the loop" newsletter - Update self-host card copy for live managed version - Reorder self-host card below video on mobile via flex order - Add /pricing to sitemap and SiteFooter * change footer and add social card * landing: move newsletter and footer to shared marketing layout Move divider line above the newsletter form instead of between the form and footer links.
This commit is contained in:
parent
937665ef95
commit
4137b386ab
Binary file not shown.
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 147 KiB |
@ -12,7 +12,7 @@ const DIST_DIR = join(__dirname, "../dist/client");
|
|||||||
const DEFAULT_SITE_URL = "https://openseo.so";
|
const DEFAULT_SITE_URL = "https://openseo.so";
|
||||||
const SITE_URL = (process.env.SITE_URL ?? DEFAULT_SITE_URL).replace(/\/+$/, "");
|
const SITE_URL = (process.env.SITE_URL ?? DEFAULT_SITE_URL).replace(/\/+$/, "");
|
||||||
|
|
||||||
const STATIC_PATHS = new Set(["/", "/privacy", "/terms-and-conditions"]);
|
const STATIC_PATHS = new Set(["/", "/pricing", "/privacy", "/terms-and-conditions"]);
|
||||||
|
|
||||||
function toCanonicalUrl(path) {
|
function toCanonicalUrl(path) {
|
||||||
if (path === "/") {
|
if (path === "/") {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export function SiteFooter({ className }: { className?: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
|
<Link to="/" className="font-semibold">OpenSEO</Link>
|
||||||
<a
|
<a
|
||||||
href="https://github.com/every-app/open-seo"
|
href="https://github.com/every-app/open-seo"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@ -18,6 +19,7 @@ export function SiteFooter({ className }: { className?: string }) {
|
|||||||
>
|
>
|
||||||
Discord
|
Discord
|
||||||
</a>
|
</a>
|
||||||
|
<Link to="/pricing">Pricing</Link>
|
||||||
<Link to="/privacy">Privacy</Link>
|
<Link to="/privacy">Privacy</Link>
|
||||||
<Link to="/terms-and-conditions">Terms</Link>
|
<Link to="/terms-and-conditions">Terms</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -11,13 +11,14 @@
|
|||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as TermsAndConditionsRouteImport } from './routes/terms-and-conditions'
|
import { Route as TermsAndConditionsRouteImport } from './routes/terms-and-conditions'
|
||||||
import { Route as PrivacyRouteImport } from './routes/privacy'
|
import { Route as PrivacyRouteImport } from './routes/privacy'
|
||||||
import { Route as PricingRouteImport } from './routes/pricing'
|
import { Route as MarketingRouteImport } from './routes/_marketing'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
|
||||||
import { Route as BlogsIndexRouteImport } from './routes/blogs/index'
|
import { Route as BlogsIndexRouteImport } from './routes/blogs/index'
|
||||||
|
import { Route as MarketingIndexRouteImport } from './routes/_marketing/index'
|
||||||
import { Route as JsScriptDotjsRouteImport } from './routes/js/script[.]js'
|
import { Route as JsScriptDotjsRouteImport } from './routes/js/script[.]js'
|
||||||
import { Route as BlogsSplatRouteImport } from './routes/blogs/$'
|
import { Route as BlogsSplatRouteImport } from './routes/blogs/$'
|
||||||
import { Route as ApiSubscribeRouteImport } from './routes/api/subscribe'
|
import { Route as ApiSubscribeRouteImport } from './routes/api/subscribe'
|
||||||
import { Route as ApiEventRouteImport } from './routes/api/event'
|
import { Route as ApiEventRouteImport } from './routes/api/event'
|
||||||
|
import { Route as MarketingPricingRouteImport } from './routes/_marketing/pricing'
|
||||||
|
|
||||||
const TermsAndConditionsRoute = TermsAndConditionsRouteImport.update({
|
const TermsAndConditionsRoute = TermsAndConditionsRouteImport.update({
|
||||||
id: '/terms-and-conditions',
|
id: '/terms-and-conditions',
|
||||||
@ -29,14 +30,8 @@ const PrivacyRoute = PrivacyRouteImport.update({
|
|||||||
path: '/privacy',
|
path: '/privacy',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const PricingRoute = PricingRouteImport.update({
|
const MarketingRoute = MarketingRouteImport.update({
|
||||||
id: '/pricing',
|
id: '/_marketing',
|
||||||
path: '/pricing',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
|
||||||
id: '/',
|
|
||||||
path: '/',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const BlogsIndexRoute = BlogsIndexRouteImport.update({
|
const BlogsIndexRoute = BlogsIndexRouteImport.update({
|
||||||
@ -44,6 +39,11 @@ const BlogsIndexRoute = BlogsIndexRouteImport.update({
|
|||||||
path: '/blogs/',
|
path: '/blogs/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const MarketingIndexRoute = MarketingIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => MarketingRoute,
|
||||||
|
} as any)
|
||||||
const JsScriptDotjsRoute = JsScriptDotjsRouteImport.update({
|
const JsScriptDotjsRoute = JsScriptDotjsRouteImport.update({
|
||||||
id: '/js/script.js',
|
id: '/js/script.js',
|
||||||
path: '/js/script.js',
|
path: '/js/script.js',
|
||||||
@ -64,12 +64,17 @@ const ApiEventRoute = ApiEventRouteImport.update({
|
|||||||
path: '/api/event',
|
path: '/api/event',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const MarketingPricingRoute = MarketingPricingRouteImport.update({
|
||||||
|
id: '/pricing',
|
||||||
|
path: '/pricing',
|
||||||
|
getParentRoute: () => MarketingRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof MarketingIndexRoute
|
||||||
'/pricing': typeof PricingRoute
|
|
||||||
'/privacy': typeof PrivacyRoute
|
'/privacy': typeof PrivacyRoute
|
||||||
'/terms-and-conditions': typeof TermsAndConditionsRoute
|
'/terms-and-conditions': typeof TermsAndConditionsRoute
|
||||||
|
'/pricing': typeof MarketingPricingRoute
|
||||||
'/api/event': typeof ApiEventRoute
|
'/api/event': typeof ApiEventRoute
|
||||||
'/api/subscribe': typeof ApiSubscribeRoute
|
'/api/subscribe': typeof ApiSubscribeRoute
|
||||||
'/blogs/$': typeof BlogsSplatRoute
|
'/blogs/$': typeof BlogsSplatRoute
|
||||||
@ -77,35 +82,36 @@ export interface FileRoutesByFullPath {
|
|||||||
'/blogs/': typeof BlogsIndexRoute
|
'/blogs/': typeof BlogsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
|
||||||
'/pricing': typeof PricingRoute
|
|
||||||
'/privacy': typeof PrivacyRoute
|
'/privacy': typeof PrivacyRoute
|
||||||
'/terms-and-conditions': typeof TermsAndConditionsRoute
|
'/terms-and-conditions': typeof TermsAndConditionsRoute
|
||||||
|
'/pricing': typeof MarketingPricingRoute
|
||||||
'/api/event': typeof ApiEventRoute
|
'/api/event': typeof ApiEventRoute
|
||||||
'/api/subscribe': typeof ApiSubscribeRoute
|
'/api/subscribe': typeof ApiSubscribeRoute
|
||||||
'/blogs/$': typeof BlogsSplatRoute
|
'/blogs/$': typeof BlogsSplatRoute
|
||||||
'/js/script.js': typeof JsScriptDotjsRoute
|
'/js/script.js': typeof JsScriptDotjsRoute
|
||||||
|
'/': typeof MarketingIndexRoute
|
||||||
'/blogs': typeof BlogsIndexRoute
|
'/blogs': typeof BlogsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/_marketing': typeof MarketingRouteWithChildren
|
||||||
'/pricing': typeof PricingRoute
|
|
||||||
'/privacy': typeof PrivacyRoute
|
'/privacy': typeof PrivacyRoute
|
||||||
'/terms-and-conditions': typeof TermsAndConditionsRoute
|
'/terms-and-conditions': typeof TermsAndConditionsRoute
|
||||||
|
'/_marketing/pricing': typeof MarketingPricingRoute
|
||||||
'/api/event': typeof ApiEventRoute
|
'/api/event': typeof ApiEventRoute
|
||||||
'/api/subscribe': typeof ApiSubscribeRoute
|
'/api/subscribe': typeof ApiSubscribeRoute
|
||||||
'/blogs/$': typeof BlogsSplatRoute
|
'/blogs/$': typeof BlogsSplatRoute
|
||||||
'/js/script.js': typeof JsScriptDotjsRoute
|
'/js/script.js': typeof JsScriptDotjsRoute
|
||||||
|
'/_marketing/': typeof MarketingIndexRoute
|
||||||
'/blogs/': typeof BlogsIndexRoute
|
'/blogs/': typeof BlogsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/pricing'
|
|
||||||
| '/privacy'
|
| '/privacy'
|
||||||
| '/terms-and-conditions'
|
| '/terms-and-conditions'
|
||||||
|
| '/pricing'
|
||||||
| '/api/event'
|
| '/api/event'
|
||||||
| '/api/subscribe'
|
| '/api/subscribe'
|
||||||
| '/blogs/$'
|
| '/blogs/$'
|
||||||
@ -113,31 +119,31 @@ export interface FileRouteTypes {
|
|||||||
| '/blogs/'
|
| '/blogs/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
|
||||||
| '/pricing'
|
|
||||||
| '/privacy'
|
| '/privacy'
|
||||||
| '/terms-and-conditions'
|
| '/terms-and-conditions'
|
||||||
|
| '/pricing'
|
||||||
| '/api/event'
|
| '/api/event'
|
||||||
| '/api/subscribe'
|
| '/api/subscribe'
|
||||||
| '/blogs/$'
|
| '/blogs/$'
|
||||||
| '/js/script.js'
|
| '/js/script.js'
|
||||||
|
| '/'
|
||||||
| '/blogs'
|
| '/blogs'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/_marketing'
|
||||||
| '/pricing'
|
|
||||||
| '/privacy'
|
| '/privacy'
|
||||||
| '/terms-and-conditions'
|
| '/terms-and-conditions'
|
||||||
|
| '/_marketing/pricing'
|
||||||
| '/api/event'
|
| '/api/event'
|
||||||
| '/api/subscribe'
|
| '/api/subscribe'
|
||||||
| '/blogs/$'
|
| '/blogs/$'
|
||||||
| '/js/script.js'
|
| '/js/script.js'
|
||||||
|
| '/_marketing/'
|
||||||
| '/blogs/'
|
| '/blogs/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
MarketingRoute: typeof MarketingRouteWithChildren
|
||||||
PricingRoute: typeof PricingRoute
|
|
||||||
PrivacyRoute: typeof PrivacyRoute
|
PrivacyRoute: typeof PrivacyRoute
|
||||||
TermsAndConditionsRoute: typeof TermsAndConditionsRoute
|
TermsAndConditionsRoute: typeof TermsAndConditionsRoute
|
||||||
ApiEventRoute: typeof ApiEventRoute
|
ApiEventRoute: typeof ApiEventRoute
|
||||||
@ -163,18 +169,11 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof PrivacyRouteImport
|
preLoaderRoute: typeof PrivacyRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/pricing': {
|
'/_marketing': {
|
||||||
id: '/pricing'
|
id: '/_marketing'
|
||||||
path: '/pricing'
|
path: ''
|
||||||
fullPath: '/pricing'
|
|
||||||
preLoaderRoute: typeof PricingRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/': {
|
|
||||||
id: '/'
|
|
||||||
path: '/'
|
|
||||||
fullPath: '/'
|
fullPath: '/'
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof MarketingRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/blogs/': {
|
'/blogs/': {
|
||||||
@ -184,6 +183,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof BlogsIndexRouteImport
|
preLoaderRoute: typeof BlogsIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/_marketing/': {
|
||||||
|
id: '/_marketing/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof MarketingIndexRouteImport
|
||||||
|
parentRoute: typeof MarketingRoute
|
||||||
|
}
|
||||||
'/js/script.js': {
|
'/js/script.js': {
|
||||||
id: '/js/script.js'
|
id: '/js/script.js'
|
||||||
path: '/js/script.js'
|
path: '/js/script.js'
|
||||||
@ -212,12 +218,32 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiEventRouteImport
|
preLoaderRoute: typeof ApiEventRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/_marketing/pricing': {
|
||||||
|
id: '/_marketing/pricing'
|
||||||
|
path: '/pricing'
|
||||||
|
fullPath: '/pricing'
|
||||||
|
preLoaderRoute: typeof MarketingPricingRouteImport
|
||||||
|
parentRoute: typeof MarketingRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MarketingRouteChildren {
|
||||||
|
MarketingPricingRoute: typeof MarketingPricingRoute
|
||||||
|
MarketingIndexRoute: typeof MarketingIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const MarketingRouteChildren: MarketingRouteChildren = {
|
||||||
|
MarketingPricingRoute: MarketingPricingRoute,
|
||||||
|
MarketingIndexRoute: MarketingIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MarketingRouteWithChildren = MarketingRoute._addFileChildren(
|
||||||
|
MarketingRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
MarketingRoute: MarketingRouteWithChildren,
|
||||||
PricingRoute: PricingRoute,
|
|
||||||
PrivacyRoute: PrivacyRoute,
|
PrivacyRoute: PrivacyRoute,
|
||||||
TermsAndConditionsRoute: TermsAndConditionsRoute,
|
TermsAndConditionsRoute: TermsAndConditionsRoute,
|
||||||
ApiEventRoute: ApiEventRoute,
|
ApiEventRoute: ApiEventRoute,
|
||||||
|
|||||||
158
web/src/routes/_marketing.tsx
Normal file
158
web/src/routes/_marketing.tsx
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
|
|
||||||
|
function GitHubIcon({ size = 18 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_marketing")({
|
||||||
|
component: MarketingLayout,
|
||||||
|
});
|
||||||
|
|
||||||
|
function MarketingLayout() {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="bg-white text-neutral-900 min-h-screen"
|
||||||
|
style={{
|
||||||
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="max-w-3xl mx-auto px-6 py-16 md:py-24">
|
||||||
|
<nav className="flex items-center justify-between mb-20">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="text-sm font-semibold hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
OpenSEO
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<Link
|
||||||
|
to="/pricing"
|
||||||
|
className="text-sm text-neutral-600 hover:text-neutral-900 transition-colors"
|
||||||
|
>
|
||||||
|
Pricing
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href="https://github.com/every-app/open-seo"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-neutral-600 hover:text-neutral-900 transition-colors"
|
||||||
|
>
|
||||||
|
<GitHubIcon size={16} />
|
||||||
|
<span>GitHub</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://app.openseo.so/sign-in"
|
||||||
|
className="h-8 px-3.5 text-sm font-medium border border-neutral-300 text-neutral-900 rounded-md hover:border-neutral-900 transition-colors inline-flex items-center"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<Outlet />
|
||||||
|
<MarketingFooter />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useNewsletter() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [status, setStatus] = useState<
|
||||||
|
"idle" | "loading" | "success" | "error"
|
||||||
|
>("idle");
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setStatus("loading");
|
||||||
|
setErrorMessage("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/subscribe", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
(data as { error?: string }).error || "Something went wrong",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setStatus("success");
|
||||||
|
setEmail("");
|
||||||
|
} catch (err) {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMessage(
|
||||||
|
err instanceof Error ? err.message : "Something went wrong",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { email, setEmail, status, errorMessage, handleSubmit };
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarketingFooter() {
|
||||||
|
const nl = useNewsletter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Newsletter */}
|
||||||
|
<div className="mt-12 pt-8 border-t border-neutral-200">
|
||||||
|
<p className="text-sm font-semibold text-neutral-900">
|
||||||
|
Stay in the loop
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-neutral-600 mt-1 leading-relaxed">
|
||||||
|
Product updates, new features, and the occasional
|
||||||
|
behind-the-scenes.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
{nl.status === "success" ? (
|
||||||
|
<p className="text-sm text-neutral-900">
|
||||||
|
You're on the list.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={nl.handleSubmit} autoComplete="on">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
id="newsletter-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={nl.email}
|
||||||
|
onChange={(e) => nl.setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="flex-1 min-w-0 h-10 px-3 text-sm border border-neutral-300 rounded-md bg-white placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-900 transition-all"
|
||||||
|
disabled={nl.status === "loading"}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={nl.status === "loading"}
|
||||||
|
className="h-10 px-5 text-sm font-medium bg-neutral-900 text-white rounded-md hover:bg-neutral-800 transition-colors disabled:opacity-50 shrink-0"
|
||||||
|
>
|
||||||
|
{nl.status === "loading" ? "..." : "Subscribe"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{nl.status === "error" && (
|
||||||
|
<p className="text-red-600 text-xs mt-2">
|
||||||
|
{nl.errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<SiteFooter className="text-xs text-neutral-600 [&_a]:hover:text-neutral-900 [&_a]:transition-colors" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
web/src/routes/_marketing/index.tsx
Normal file
126
web/src/routes/_marketing/index.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { buildPageSeo } from "@/lib/seo";
|
||||||
|
|
||||||
|
const homeTitle = "OpenSEO - Open Source SEO Platform";
|
||||||
|
const homeDescription =
|
||||||
|
"Own your SEO. OpenSEO helps teams manage keyword research, backlink analysis, competitor monitoring, and site audits without expensive monthly SEO software subscriptions.";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_marketing/")({
|
||||||
|
head: () =>
|
||||||
|
buildPageSeo({
|
||||||
|
title: homeTitle,
|
||||||
|
description: homeDescription,
|
||||||
|
path: "/",
|
||||||
|
imageAlt: "OpenSEO keyword research dashboard preview",
|
||||||
|
}),
|
||||||
|
component: Home,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Page ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Headline */}
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight leading-tight">
|
||||||
|
Own your SEO
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-neutral-700 mt-4 leading-relaxed">
|
||||||
|
Open source alternative to Semrush and Ahrefs
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<ul className="mt-5 space-y-3">
|
||||||
|
{[
|
||||||
|
"Keyword Research",
|
||||||
|
"Backlink Analysis",
|
||||||
|
"Competitor Insights",
|
||||||
|
"Site Audits",
|
||||||
|
"Rank Tracking and more (Coming soon)",
|
||||||
|
].map((item) => (
|
||||||
|
<li key={item} className="flex gap-2.5 text-sm text-neutral-800">
|
||||||
|
<span className="text-neutral-500 mt-[2px]">—</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<a
|
||||||
|
href="https://app.openseo.so/sign-up"
|
||||||
|
className="inline-flex items-center justify-center h-10 px-5 text-sm font-medium bg-neutral-900 text-white rounded-md hover:bg-neutral-800 transition-colors"
|
||||||
|
>
|
||||||
|
Try now
|
||||||
|
</a>
|
||||||
|
<p className="text-xs text-neutral-500 mt-3">
|
||||||
|
No credit card required.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-col gap-8">
|
||||||
|
{/* Self-host — after video on mobile, before on desktop */}
|
||||||
|
<div className="order-2 md:order-1 rounded-lg border border-neutral-200 bg-neutral-50 px-5 py-5">
|
||||||
|
<p className="text-sm font-semibold text-neutral-900">
|
||||||
|
Self-host via Docker or Cloudflare
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-neutral-600 mt-2 leading-relaxed">
|
||||||
|
100% open source (MIT). Bring your own DataForSEO api key. Fork and vibe code custom features for your workflow.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://github.com/every-app/open-seo"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 mt-3 text-sm font-medium text-neutral-900 hover:text-neutral-700 transition-colors"
|
||||||
|
>
|
||||||
|
View on GitHub
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
<hr className="mt-4" />
|
||||||
|
<p className="text-sm text-neutral-600 mt-2 leading-relaxed">
|
||||||
|
Don't want to self-host?{" "}
|
||||||
|
<a
|
||||||
|
href="https://app.openseo.so/sign-up"
|
||||||
|
className="font-medium text-neutral-900 hover:text-neutral-700 transition-colors"
|
||||||
|
>
|
||||||
|
Try the managed version.
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Demo */}
|
||||||
|
<div className="order-1 md:order-2">
|
||||||
|
<video
|
||||||
|
className="w-full rounded-md border border-neutral-200"
|
||||||
|
width={1280}
|
||||||
|
height={808}
|
||||||
|
poster="/demo-poster.webp"
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
preload="none"
|
||||||
|
aria-label="OpenSEO product demo"
|
||||||
|
>
|
||||||
|
<source src="/demo.webm" type="video/webm" />
|
||||||
|
<source src="/demo.mp4" type="video/mp4" />
|
||||||
|
<img
|
||||||
|
src="/demo-poster.webp"
|
||||||
|
alt="OpenSEO product demo"
|
||||||
|
width={1280}
|
||||||
|
height={808}
|
||||||
|
className="w-full rounded-md border border-neutral-200"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</video>
|
||||||
|
<p className="text-[11px] text-neutral-600 mt-2">
|
||||||
|
Keyword research in OpenSEO
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
web/src/routes/_marketing/pricing.tsx
Normal file
155
web/src/routes/_marketing/pricing.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { buildPageSeo } from "@/lib/seo";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_marketing/pricing")({
|
||||||
|
head: () =>
|
||||||
|
buildPageSeo({
|
||||||
|
title: "Pricing",
|
||||||
|
description:
|
||||||
|
"OpenSEO is free to self-host. The managed service is $10/month and includes Usage Credits.",
|
||||||
|
path: "/pricing",
|
||||||
|
titleSuffix: "OpenSEO",
|
||||||
|
}),
|
||||||
|
component: Pricing,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Pricing() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Headline */}
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Pricing</h1>
|
||||||
|
|
||||||
|
{/* Managed */}
|
||||||
|
<section className="mt-12">
|
||||||
|
<h2 className="text-xl font-semibold">Managed</h2>
|
||||||
|
<div className="mt-6 rounded-lg border border-neutral-200 overflow-hidden">
|
||||||
|
{/* Base Plan */}
|
||||||
|
<div className="px-5 py-5">
|
||||||
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
|
<p className="font-semibold">Base Plan</p>
|
||||||
|
<p className="text-lg font-semibold tabular-nums">$10/month</p>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{[
|
||||||
|
"Access to OpenSEO's managed service",
|
||||||
|
"Includes $10.00 of Usage Credits each billing cycle",
|
||||||
|
"Monthly included credits reset each cycle",
|
||||||
|
].map((item) => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
className="flex gap-2.5 text-sm text-neutral-700"
|
||||||
|
>
|
||||||
|
<span className="text-neutral-400 mt-[2px] shrink-0">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage Credits — add-on, visually connected to Base Plan */}
|
||||||
|
<div className="border-t border-dashed border-neutral-200 px-5 py-5">
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<p className="text-sm font-semibold">Usage Credits</p>
|
||||||
|
<span className="text-xs text-neutral-400 font-medium">
|
||||||
|
add-on
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-neutral-600 leading-relaxed">
|
||||||
|
Credits are consumed as you use the app. We use powerful
|
||||||
|
providers for AI and SEO data. We bill based on usage of those
|
||||||
|
APIs and charge a small premium.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{[
|
||||||
|
"Purchase top-up credits if you use up your monthly credits",
|
||||||
|
"Top-up credits roll over and don't expire",
|
||||||
|
].map((item) => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
className="flex gap-2.5 text-sm text-neutral-700"
|
||||||
|
>
|
||||||
|
<span className="text-neutral-400 mt-[2px] shrink-0">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://app.openseo.so/sign-up"
|
||||||
|
className="inline-flex items-center justify-center mt-4 rounded-lg bg-neutral-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-neutral-800 transition-colors"
|
||||||
|
>
|
||||||
|
Get Started{" "}<span aria-hidden="true" className="ml-1.5">→</span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Self-hosted */}
|
||||||
|
<section className="mt-12">
|
||||||
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
|
<h2 className="text-xl font-semibold">Self-hosted</h2>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-neutral-600 leading-relaxed">
|
||||||
|
Deploy OpenSEO yourself via Docker or Cloudflare Workers. Bring your
|
||||||
|
own API keys and pay DataForSEO and other API providers directly.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://github.com/every-app/open-seo"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 mt-3 text-sm font-medium text-neutral-900 hover:text-neutral-600 transition-colors"
|
||||||
|
>
|
||||||
|
View on GitHub
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ */}
|
||||||
|
<section className="mt-12">
|
||||||
|
<h2 className="text-xl font-semibold">FAQ</h2>
|
||||||
|
<dl className="mt-4 space-y-6">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium">
|
||||||
|
What features use credits vs. what's unlimited?
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm text-neutral-600 leading-relaxed">
|
||||||
|
Credits are consumed by features that query DataForSEO's API —
|
||||||
|
backlinks, keyword volume, competitor data, and site audits.
|
||||||
|
Your projects, settings, and any data already fetched don't cost
|
||||||
|
credits.
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium">
|
||||||
|
Do unused credits roll over?
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm text-neutral-600 leading-relaxed">
|
||||||
|
Top-up credits roll over indefinitely. The Usage Credits
|
||||||
|
included with your Base Plan reset each billing cycle.
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium">Can I cancel anytime?</dt>
|
||||||
|
<dd className="mt-1 text-sm text-neutral-600 leading-relaxed">
|
||||||
|
Yes. Cancel from your billing portal at any time. Your access
|
||||||
|
continues through the end of the current billing period.{" "}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium">
|
||||||
|
Do I need a subscription or just usage credits?
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm text-neutral-600 leading-relaxed">
|
||||||
|
While top-up Usage Credits roll over and don't expire, you need
|
||||||
|
an active subscription in order to use OpenSEO.
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,221 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { SiteFooter } from "@/components/site-footer";
|
|
||||||
import { buildPageSeo } from "@/lib/seo";
|
|
||||||
|
|
||||||
const homeTitle = "OpenSEO - Open Source SEO Platform";
|
|
||||||
const homeDescription =
|
|
||||||
"Own your SEO. OpenSEO helps teams manage keyword research, backlink analysis, competitor monitoring, and site audits without expensive monthly SEO software subscriptions.";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
|
||||||
head: () =>
|
|
||||||
buildPageSeo({
|
|
||||||
title: homeTitle,
|
|
||||||
description: homeDescription,
|
|
||||||
path: "/",
|
|
||||||
imageAlt: "OpenSEO keyword research dashboard preview",
|
|
||||||
}),
|
|
||||||
component: Home,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Shared ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function GitHubIcon({ size = 18 }: { size?: number }) {
|
|
||||||
return (
|
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useWaitlist() {
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [status, setStatus] = useState<
|
|
||||||
"idle" | "loading" | "success" | "error"
|
|
||||||
>("idle");
|
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setStatus("loading");
|
|
||||||
setErrorMessage("");
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/subscribe", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ email }),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(
|
|
||||||
(data as { error?: string }).error || "Something went wrong",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setStatus("success");
|
|
||||||
setEmail("");
|
|
||||||
} catch (err) {
|
|
||||||
setStatus("error");
|
|
||||||
setErrorMessage(
|
|
||||||
err instanceof Error ? err.message : "Something went wrong",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { email, setEmail, status, errorMessage, handleSubmit };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Page ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function Home() {
|
|
||||||
const wl = useWaitlist();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
className="bg-white text-neutral-900 min-h-screen"
|
|
||||||
style={{
|
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="max-w-3xl mx-auto px-6 py-16 md:py-24">
|
|
||||||
{/* Nav */}
|
|
||||||
<nav className="flex items-center justify-between mb-20">
|
|
||||||
<span className="text-sm font-semibold">OpenSEO</span>
|
|
||||||
<a
|
|
||||||
href="https://github.com/every-app/open-seo"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 text-sm font-medium text-neutral-700 hover:text-neutral-900 transition-colors"
|
|
||||||
aria-label="GitHub"
|
|
||||||
>
|
|
||||||
<GitHubIcon size={16} />
|
|
||||||
<span>GitHub</span>
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Headline */}
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight leading-tight">
|
|
||||||
Own your SEO
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="text-neutral-700 mt-4 leading-relaxed">
|
|
||||||
Open source alternative to Semrush and Ahrefs
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Features */}
|
|
||||||
<ul className="mt-5 space-y-3">
|
|
||||||
{[
|
|
||||||
"Keyword Research",
|
|
||||||
"Backlink Analysis",
|
|
||||||
"Competitor Insights",
|
|
||||||
"Site Audits",
|
|
||||||
"Rank Tracking and more (Coming soon)",
|
|
||||||
].map((item) => (
|
|
||||||
<li key={item} className="flex gap-2.5 text-sm text-neutral-800">
|
|
||||||
<span className="text-neutral-500 mt-[2px]">—</span>
|
|
||||||
{item}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<div className="mt-6">
|
|
||||||
{wl.status === "success" ? (
|
|
||||||
<p className="text-sm text-neutral-900">You're on the list.</p>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={wl.handleSubmit} autoComplete="on">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
id="waitlist-email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
value={wl.email}
|
|
||||||
onChange={(e) => wl.setEmail(e.target.value)}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
className="flex-1 min-w-0 h-10 px-3 text-sm border border-neutral-300 rounded-md bg-white placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-900 transition-all"
|
|
||||||
disabled={wl.status === "loading"}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={wl.status === "loading"}
|
|
||||||
className="h-10 px-5 text-sm font-medium bg-neutral-900 text-white rounded-md hover:bg-neutral-800 transition-colors disabled:opacity-50 shrink-0"
|
|
||||||
>
|
|
||||||
{wl.status === "loading" ? "..." : "Notify me"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{wl.status === "error" && (
|
|
||||||
<p className="text-red-600 text-xs mt-2">{wl.errorMessage}</p>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-neutral-600 mt-3">
|
|
||||||
Get notified when the managed version is ready and when we add new
|
|
||||||
features.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 rounded-lg border border-neutral-200 bg-neutral-50 px-5 py-5">
|
|
||||||
<p className="text-sm font-semibold text-neutral-900">
|
|
||||||
Self-host today via Docker or Cloudflare
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-neutral-600 mt-2 leading-relaxed">
|
|
||||||
Bring your own DataForSEO API key. Pay by usage, not per month. 100%
|
|
||||||
open source (MIT).
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://github.com/every-app/open-seo"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 mt-3 text-sm font-medium text-neutral-900 hover:text-neutral-700 transition-colors"
|
|
||||||
>
|
|
||||||
View on GitHub
|
|
||||||
<span aria-hidden="true">→</span>
|
|
||||||
</a>
|
|
||||||
<hr className="mt-4" />
|
|
||||||
<p className="text-sm text-neutral-600 mt-2 leading-relaxed">
|
|
||||||
If you don't want to self host or the DataForSEO minimum commitments
|
|
||||||
are too high, sign up in the form above and we'll notify you when
|
|
||||||
the managed version is released.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Demo */}
|
|
||||||
<div className="mt-8">
|
|
||||||
<video
|
|
||||||
className="w-full rounded-md border border-neutral-200"
|
|
||||||
width={1280}
|
|
||||||
height={808}
|
|
||||||
poster="/demo-poster.webp"
|
|
||||||
autoPlay
|
|
||||||
muted
|
|
||||||
loop
|
|
||||||
playsInline
|
|
||||||
preload="none"
|
|
||||||
aria-label="OpenSEO product demo"
|
|
||||||
>
|
|
||||||
<source src="/demo.webm" type="video/webm" />
|
|
||||||
<source src="/demo.mp4" type="video/mp4" />
|
|
||||||
<img
|
|
||||||
src="/demo-poster.webp"
|
|
||||||
alt="OpenSEO product demo"
|
|
||||||
width={1280}
|
|
||||||
height={808}
|
|
||||||
className="w-full rounded-md border border-neutral-200"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
</video>
|
|
||||||
<p className="text-[11px] text-neutral-600 mt-2">
|
|
||||||
Keyword research in OpenSEO
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="mt-8 pt-8 border-t border-neutral-200">
|
|
||||||
<SiteFooter className="text-xs text-neutral-600 [&_a]:hover:text-neutral-900 [&_a]:transition-colors" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,212 +0,0 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
||||||
import { buildPageSeo } from "@/lib/seo";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/pricing")({
|
|
||||||
head: () =>
|
|
||||||
buildPageSeo({
|
|
||||||
title: "Pricing",
|
|
||||||
description:
|
|
||||||
"OpenSEO is free to self-host. The managed service is $10/month and includes Usage Credits.",
|
|
||||||
path: "/pricing",
|
|
||||||
titleSuffix: "OpenSEO",
|
|
||||||
}),
|
|
||||||
component: Pricing,
|
|
||||||
});
|
|
||||||
|
|
||||||
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={to}
|
|
||||||
className="text-sm text-neutral-600 hover:text-neutral-900 transition-colors"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Pricing() {
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
className="bg-white text-neutral-900 min-h-screen"
|
|
||||||
style={{
|
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="max-w-3xl mx-auto px-6 py-16 md:py-24">
|
|
||||||
{/* Nav */}
|
|
||||||
<nav className="flex items-center justify-between mb-20">
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="text-sm font-semibold hover:opacity-80 transition-opacity"
|
|
||||||
>
|
|
||||||
OpenSEO
|
|
||||||
</Link>
|
|
||||||
<a
|
|
||||||
href="https://github.com/every-app/open-seo"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm font-medium text-neutral-700 hover:text-neutral-900 transition-colors"
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Headline */}
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Pricing</h1>
|
|
||||||
|
|
||||||
{/* Managed */}
|
|
||||||
<section className="mt-12">
|
|
||||||
<h2 className="text-xl font-semibold">Managed</h2>
|
|
||||||
<div className="mt-6 rounded-lg border border-neutral-200 overflow-hidden">
|
|
||||||
{/* Base Plan */}
|
|
||||||
<div className="px-5 py-5">
|
|
||||||
<div className="flex items-baseline justify-between gap-4">
|
|
||||||
<p className="font-semibold">Base Plan</p>
|
|
||||||
<p className="text-lg font-semibold tabular-nums">$10/month</p>
|
|
||||||
</div>
|
|
||||||
<ul className="mt-3 space-y-2">
|
|
||||||
{[
|
|
||||||
"Access to OpenSEO's managed service",
|
|
||||||
"Includes $10.00 of Usage Credits each billing cycle",
|
|
||||||
"Monthly included credits reset each cycle",
|
|
||||||
].map((item) => (
|
|
||||||
<li
|
|
||||||
key={item}
|
|
||||||
className="flex gap-2.5 text-sm text-neutral-700"
|
|
||||||
>
|
|
||||||
<span className="text-neutral-400 mt-[2px] shrink-0">
|
|
||||||
—
|
|
||||||
</span>
|
|
||||||
{item}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Usage Credits — add-on, visually connected to Base Plan */}
|
|
||||||
<div className="border-t border-dashed border-neutral-200 px-5 py-5">
|
|
||||||
<div className="flex items-baseline gap-3">
|
|
||||||
<p className="text-sm font-semibold">Usage Credits</p>
|
|
||||||
<span className="text-xs text-neutral-400 font-medium">
|
|
||||||
add-on
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm text-neutral-600 leading-relaxed">
|
|
||||||
Credits are consumed as you use the app. We use powerful
|
|
||||||
providers for AI and SEO data. We bill based on usage of those
|
|
||||||
APIs and charge a small premium.
|
|
||||||
</p>
|
|
||||||
<ul className="mt-3 space-y-2">
|
|
||||||
{[
|
|
||||||
"Purchase top-up credits if you use up your monthly credits",
|
|
||||||
"Top-up credits roll over and don't expire",
|
|
||||||
].map((item) => (
|
|
||||||
<li
|
|
||||||
key={item}
|
|
||||||
className="flex gap-2.5 text-sm text-neutral-700"
|
|
||||||
>
|
|
||||||
<span className="text-neutral-400 mt-[2px] shrink-0">
|
|
||||||
—
|
|
||||||
</span>
|
|
||||||
{item}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href="https://app.openseo.so/sign-up"
|
|
||||||
className="inline-flex items-center justify-center mt-4 rounded-lg bg-neutral-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-neutral-800 transition-colors"
|
|
||||||
>
|
|
||||||
Get Started{" "}<span aria-hidden="true" className="ml-1.5">→</span>
|
|
||||||
</a>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Self-hosted */}
|
|
||||||
<section className="mt-12">
|
|
||||||
<div className="flex items-baseline justify-between gap-4">
|
|
||||||
<h2 className="text-xl font-semibold">Self-hosted</h2>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm text-neutral-600 leading-relaxed">
|
|
||||||
Deploy OpenSEO yourself via Docker or Cloudflare Workers. Bring your
|
|
||||||
own API keys and pay DataForSEO and other API providers directly.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://github.com/every-app/open-seo"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 mt-3 text-sm font-medium text-neutral-900 hover:text-neutral-600 transition-colors"
|
|
||||||
>
|
|
||||||
View on GitHub
|
|
||||||
<span aria-hidden="true">→</span>
|
|
||||||
</a>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* FAQ */}
|
|
||||||
<section className="mt-12">
|
|
||||||
<h2 className="text-xl font-semibold">FAQ</h2>
|
|
||||||
<dl className="mt-4 space-y-6">
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium">
|
|
||||||
What features use credits vs. what's unlimited?
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-neutral-600 leading-relaxed">
|
|
||||||
Credits are consumed by features that query DataForSEO's API —
|
|
||||||
backlinks, keyword volume, competitor data, and site audits.
|
|
||||||
Your projects, settings, and any data already fetched don't cost
|
|
||||||
credits.
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium">
|
|
||||||
Do unused credits roll over?
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-neutral-600 leading-relaxed">
|
|
||||||
Top-up credits roll over indefinitely. The Usage Credits
|
|
||||||
included with your Base Plan reset each billing cycle.
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium">Can I cancel anytime?</dt>
|
|
||||||
<dd className="mt-1 text-sm text-neutral-600 leading-relaxed">
|
|
||||||
Yes. Cancel from your billing portal at any time. Your access
|
|
||||||
continues through the end of the current billing period.{" "}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-sm font-medium">
|
|
||||||
Do I need a subscription or just usage credits?
|
|
||||||
</dt>
|
|
||||||
<dd className="mt-1 text-sm text-neutral-600 leading-relaxed">
|
|
||||||
While top-up Usage Credits roll over and don't expire, you need
|
|
||||||
an active subscription in order to use OpenSEO.
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="mt-16 pt-8 border-t border-neutral-200">
|
|
||||||
<div className="flex items-center gap-6 text-xs text-neutral-600 [&_a]:hover:text-neutral-900 [&_a]:transition-colors">
|
|
||||||
<a
|
|
||||||
href="https://github.com/every-app/open-seo"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://discord.gg/c9uGs3cFXr"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Discord
|
|
||||||
</a>
|
|
||||||
<NavLink to="/pricing">Pricing</NavLink>
|
|
||||||
<NavLink to="/privacy">Privacy</NavLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user