website: Create website (#19)
* Add website * save * chore: validate website in CI and fix workers typing
This commit is contained in:
parent
75f9868500
commit
a0ef7412f2
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@ -36,3 +36,12 @@ jobs:
|
||||
|
||||
- name: Run CI checks
|
||||
run: pnpm run ci:check
|
||||
|
||||
- name: Install website dependencies
|
||||
run: pnpm --dir web install --frozen-lockfile
|
||||
|
||||
- name: Run website type checks
|
||||
run: pnpm --dir web run types:check
|
||||
|
||||
- name: Build website
|
||||
run: pnpm --dir web run build
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
"node_modules",
|
||||
"dist",
|
||||
".output",
|
||||
"web",
|
||||
"src/routeTree.gen.ts",
|
||||
"worker-configuration.d.ts"
|
||||
],
|
||||
|
||||
@ -7,3 +7,4 @@ dist/
|
||||
drizzle/
|
||||
planning/
|
||||
worker-configuration.d.ts
|
||||
web/
|
||||
|
||||
13
knip.jsonc
13
knip.jsonc
@ -1,5 +1,4 @@
|
||||
{
|
||||
"ignoreBinaries": ["portless"],
|
||||
"entry": [
|
||||
// Detect Tanstack Start Routes
|
||||
"src/router.tsx",
|
||||
@ -9,29 +8,20 @@
|
||||
// DB index re-exports schema for convenience
|
||||
"src/db/index.ts",
|
||||
],
|
||||
"project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts"],
|
||||
"project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"],
|
||||
"ignore": [
|
||||
"drizzle-prod.config.ts",
|
||||
"src/client/features/keywords/utils.ts",
|
||||
"src/client/hooks/useDomainSearchHistory.ts",
|
||||
"src/client/hooks/useSearchHistory.ts",
|
||||
"src/server.ts",
|
||||
"src/server/lib/audit/progress-kv.ts",
|
||||
"src/server/lib/audit/types.ts",
|
||||
"src/server/services/PsiIssuesService.ts",
|
||||
"src/server/workflows/SiteAuditWorkflow.ts",
|
||||
"src/serverFunctions/keywords.ts",
|
||||
"src/serverFunctions/psi.ts",
|
||||
"src/types/schemas/audit.ts",
|
||||
"src/types/schemas/psi.ts",
|
||||
],
|
||||
"ignoreFiles": [
|
||||
"src/server/services/keyword-research/helpers.ts",
|
||||
"src/server/services/keyword-research/projects.ts",
|
||||
"src/server/services/keyword-research/research-data.ts",
|
||||
"src/server/services/keyword-research/saved-keywords.ts",
|
||||
"src/server/services/keyword-research/serp.ts",
|
||||
],
|
||||
// Disable Drizzle plugin - it tries to load drizzle.config.ts which imports cloudflare:workers
|
||||
"drizzle": false,
|
||||
"ignoreDependencies": [
|
||||
@ -40,6 +30,5 @@
|
||||
"daisyui",
|
||||
"@tanstack/query-sync-storage-persister",
|
||||
"@tanstack/react-query-persist-client",
|
||||
"portless",
|
||||
],
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["web/**/*"],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
9
web/.gitignore
vendored
Normal file
9
web/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.wrangler
|
||||
.vinxi
|
||||
.output
|
||||
.vercel
|
||||
.source
|
||||
source.generated.ts
|
||||
*.local
|
||||
9
web/.prettierignore
Normal file
9
web/.prettierignore
Normal file
@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.wrangler
|
||||
.vinxi
|
||||
.output
|
||||
.source
|
||||
source.generated.ts
|
||||
pnpm-lock.yaml
|
||||
routeTree.gen.ts
|
||||
1
web/.worktreeinclude
Normal file
1
web/.worktreeinclude
Normal file
@ -0,0 +1 @@
|
||||
web/open-rank
|
||||
47
web/package.json
Normal file
47
web/package.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "open-rank-landing",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build && node scripts/generate-sitemap.js",
|
||||
"build:app": "vite build",
|
||||
"sitemap": "node scripts/generate-sitemap.js",
|
||||
"start": "wrangler dev",
|
||||
"preview": "vite preview",
|
||||
"types:check": "fumadocs-mdx && tsc --noEmit",
|
||||
"format:check": "prettier --check .",
|
||||
"format:write": "prettier --write .",
|
||||
"deploy": "npm run build && wrangler deploy",
|
||||
"deploy:prod": "npm run build && wrangler deploy",
|
||||
"deploy:preview": "npm run build && wrangler deploy --env preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.161.3",
|
||||
"@tanstack/react-router-devtools": "^1.161.3",
|
||||
"@tanstack/react-start": "^1.161.3",
|
||||
"fumadocs-core": "^15.5.1",
|
||||
"fumadocs-mdx": "^11.6.5",
|
||||
"fumadocs-ui": "^15.5.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"vite": "^7.3.1",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.13.3",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.2.13",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.3",
|
||||
"prettier": "^3.6.2",
|
||||
"srvx": "^0.11.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vite-tsconfig-paths": "^6.0.5",
|
||||
"wrangler": "^4.67.0"
|
||||
}
|
||||
}
|
||||
5868
web/pnpm-lock.yaml
generated
Normal file
5868
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
web/public/demo-poster.webp
Normal file
BIN
web/public/demo-poster.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
web/public/demo.gif
Normal file
BIN
web/public/demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 MiB |
BIN
web/public/demo.mp4
Normal file
BIN
web/public/demo.mp4
Normal file
Binary file not shown.
BIN
web/public/demo.webm
Normal file
BIN
web/public/demo.webm
Normal file
Binary file not shown.
4
web/public/robots.txt
Normal file
4
web/public/robots.txt
Normal file
@ -0,0 +1,4 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Sitemap: https://openrank.io/sitemap.xml
|
||||
52
web/scripts/generate-sitemap.js
Normal file
52
web/scripts/generate-sitemap.js
Normal file
@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const DIST_DIR = join(__dirname, "../dist/client");
|
||||
|
||||
const DEFAULT_SITE_URL = "https://openrank.io";
|
||||
const SITE_URL = (process.env.SITE_URL ?? DEFAULT_SITE_URL).replace(/\/+$/, "");
|
||||
|
||||
const STATIC_PATHS = new Set(["/", "/privacy"]);
|
||||
|
||||
function toCanonicalUrl(path) {
|
||||
if (path === "/") {
|
||||
return `${SITE_URL}/`;
|
||||
}
|
||||
|
||||
return `${SITE_URL}${path.replace(/\/+$/, "")}`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!existsSync(DIST_DIR) || !statSync(DIST_DIR).isDirectory()) {
|
||||
throw new Error(`Build output directory does not exist: ${DIST_DIR}`);
|
||||
}
|
||||
|
||||
const urlPaths = new Set(STATIC_PATHS);
|
||||
|
||||
const urls = Array.from(urlPaths)
|
||||
.map((path) => toCanonicalUrl(path))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const lastmod = new Date().toISOString();
|
||||
const sitemapBody = urls
|
||||
.map(
|
||||
(url) =>
|
||||
` <url>\n <loc>${url}</loc>\n <lastmod>${lastmod}</lastmod>\n </url>`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const sitemapXml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${sitemapBody}\n</urlset>\n`;
|
||||
|
||||
const sitemapPath = join(DIST_DIR, "sitemap.xml");
|
||||
writeFileSync(sitemapPath, sitemapXml);
|
||||
|
||||
console.log(`Generated sitemap with ${urls.length} URLs at ${sitemapPath}`);
|
||||
}
|
||||
|
||||
main();
|
||||
17
web/source.config.ts
Normal file
17
web/source.config.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import {
|
||||
defineConfig,
|
||||
defineCollections,
|
||||
frontmatterSchema,
|
||||
} from "fumadocs-mdx/config/zod-3";
|
||||
import { z } from "zod";
|
||||
|
||||
export const blog = defineCollections({
|
||||
type: "doc",
|
||||
dir: "content/blog",
|
||||
schema: (frontmatterSchema as any).extend({
|
||||
author: z.string(),
|
||||
date: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export default defineConfig();
|
||||
24
web/src/components/not-found.tsx
Normal file
24
web/src/components/not-found.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { HomeLayout } from "fumadocs-ui/layouts/home";
|
||||
import { baseOptions } from "@/lib/layout.shared";
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
<HomeLayout {...baseOptions()} className="text-center py-32 justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<h1 className="text-6xl font-bold text-fd-muted-foreground">404</h1>
|
||||
<h2 className="text-2xl font-semibold">Page Not Found</h2>
|
||||
<p className="text-fd-muted-foreground max-w-md">
|
||||
The page you are looking for might have been removed, had its name
|
||||
changed, or is temporarily unavailable.
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="mt-4 px-4 py-2 rounded-lg bg-fd-primary text-fd-primary-foreground font-medium text-sm hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</HomeLayout>
|
||||
);
|
||||
}
|
||||
29
web/src/lib/content.functions.ts
Normal file
29
web/src/lib/content.functions.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { notFound } from "@tanstack/react-router";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { blogSource } from "@/lib/source";
|
||||
|
||||
export const getBlogPost = createServerFn({ method: "GET" })
|
||||
.inputValidator((slugs: string[]) => slugs)
|
||||
.handler(async ({ data: slugs }) => {
|
||||
const page = blogSource.getPage(slugs);
|
||||
if (!page) throw notFound();
|
||||
|
||||
return {
|
||||
path: page.path,
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
url: page.url,
|
||||
};
|
||||
});
|
||||
|
||||
export const getBlogPosts = createServerFn({ method: "GET" }).handler(
|
||||
async () => {
|
||||
const pages = blogSource.getPages();
|
||||
return pages.map((page) => ({
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
url: page.url,
|
||||
slugs: page.slugs,
|
||||
}));
|
||||
},
|
||||
);
|
||||
16
web/src/lib/layout.shared.tsx
Normal file
16
web/src/lib/layout.shared.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
||||
|
||||
export function baseOptions(): BaseLayoutProps {
|
||||
return {
|
||||
nav: {
|
||||
title: <span className="font-semibold">OpenRank</span>,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
text: "GitHub",
|
||||
url: "https://github.com/every-app/open-seo",
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
51
web/src/lib/seo.ts
Normal file
51
web/src/lib/seo.ts
Normal file
@ -0,0 +1,51 @@
|
||||
const DEFAULT_SITE_URL = "https://openrank.io";
|
||||
|
||||
export const SITE_URL = (
|
||||
process.env.SITE_URL ??
|
||||
process.env.VITE_SITE_URL ??
|
||||
DEFAULT_SITE_URL
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
export function toCanonicalPath(path: string): string {
|
||||
if (!path || path === "/") return "/";
|
||||
|
||||
const normalized = path.startsWith("/") ? path : `/${path}`;
|
||||
return normalized.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function toCanonicalUrl(path: string): string {
|
||||
return new URL(toCanonicalPath(path), `${SITE_URL}/`).href;
|
||||
}
|
||||
|
||||
type BuildSeoParams = {
|
||||
title: string;
|
||||
path: string;
|
||||
description?: string;
|
||||
titleSuffix?: string;
|
||||
ogType?: "website" | "article";
|
||||
};
|
||||
|
||||
export function buildPageSeo({
|
||||
title,
|
||||
path,
|
||||
description,
|
||||
titleSuffix,
|
||||
ogType = "website",
|
||||
}: BuildSeoParams) {
|
||||
const fullTitle = titleSuffix ? `${title} - ${titleSuffix}` : title;
|
||||
const canonicalUrl = toCanonicalUrl(path);
|
||||
|
||||
return {
|
||||
meta: [
|
||||
{ title: fullTitle },
|
||||
...(description ? [{ name: "description", content: description }] : []),
|
||||
{ property: "og:type", content: ogType },
|
||||
{ property: "og:title", content: fullTitle },
|
||||
...(description
|
||||
? [{ property: "og:description", content: description }]
|
||||
: []),
|
||||
{ property: "og:url", content: canonicalUrl },
|
||||
],
|
||||
links: [{ rel: "canonical", href: canonicalUrl }],
|
||||
};
|
||||
}
|
||||
15
web/src/lib/source.ts
Normal file
15
web/src/lib/source.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { loader } from "fumadocs-core/source";
|
||||
// Keep this deep import for now: the public "fumadocs-mdx/runtime/vite" entry
|
||||
// resolves to the browser runtime in our SSR build, which breaks sourceAsync.
|
||||
import { fromConfig } from "../../node_modules/fumadocs-mdx/dist/runtime/vite/server.js";
|
||||
import { blog } from "../../source.generated";
|
||||
import type * as Config from "../../source.config";
|
||||
|
||||
const serverCreate = fromConfig<typeof Config>();
|
||||
|
||||
export const blogSource = loader({
|
||||
source: await serverCreate.sourceAsync(blog, {} as Record<string, never>),
|
||||
baseUrl: "/blogs",
|
||||
});
|
||||
|
||||
export { blog };
|
||||
198
web/src/routeTree.gen.ts
Normal file
198
web/src/routeTree.gen.ts
Normal file
@ -0,0 +1,198 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as PrivacyRouteImport } from './routes/privacy'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as BlogsIndexRouteImport } from './routes/blogs/index'
|
||||
import { Route as JsScriptDotjsRouteImport } from './routes/js/script[.]js'
|
||||
import { Route as BlogsSplatRouteImport } from './routes/blogs/$'
|
||||
import { Route as ApiSubscribeRouteImport } from './routes/api/subscribe'
|
||||
import { Route as ApiEventRouteImport } from './routes/api/event'
|
||||
|
||||
const PrivacyRoute = PrivacyRouteImport.update({
|
||||
id: '/privacy',
|
||||
path: '/privacy',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const BlogsIndexRoute = BlogsIndexRouteImport.update({
|
||||
id: '/blogs/',
|
||||
path: '/blogs/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const JsScriptDotjsRoute = JsScriptDotjsRouteImport.update({
|
||||
id: '/js/script.js',
|
||||
path: '/js/script.js',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const BlogsSplatRoute = BlogsSplatRouteImport.update({
|
||||
id: '/blogs/$',
|
||||
path: '/blogs/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiSubscribeRoute = ApiSubscribeRouteImport.update({
|
||||
id: '/api/subscribe',
|
||||
path: '/api/subscribe',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiEventRoute = ApiEventRouteImport.update({
|
||||
id: '/api/event',
|
||||
path: '/api/event',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/privacy': typeof PrivacyRoute
|
||||
'/api/event': typeof ApiEventRoute
|
||||
'/api/subscribe': typeof ApiSubscribeRoute
|
||||
'/blogs/$': typeof BlogsSplatRoute
|
||||
'/js/script.js': typeof JsScriptDotjsRoute
|
||||
'/blogs/': typeof BlogsIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/privacy': typeof PrivacyRoute
|
||||
'/api/event': typeof ApiEventRoute
|
||||
'/api/subscribe': typeof ApiSubscribeRoute
|
||||
'/blogs/$': typeof BlogsSplatRoute
|
||||
'/js/script.js': typeof JsScriptDotjsRoute
|
||||
'/blogs': typeof BlogsIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/privacy': typeof PrivacyRoute
|
||||
'/api/event': typeof ApiEventRoute
|
||||
'/api/subscribe': typeof ApiSubscribeRoute
|
||||
'/blogs/$': typeof BlogsSplatRoute
|
||||
'/js/script.js': typeof JsScriptDotjsRoute
|
||||
'/blogs/': typeof BlogsIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/privacy'
|
||||
| '/api/event'
|
||||
| '/api/subscribe'
|
||||
| '/blogs/$'
|
||||
| '/js/script.js'
|
||||
| '/blogs/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/privacy'
|
||||
| '/api/event'
|
||||
| '/api/subscribe'
|
||||
| '/blogs/$'
|
||||
| '/js/script.js'
|
||||
| '/blogs'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/privacy'
|
||||
| '/api/event'
|
||||
| '/api/subscribe'
|
||||
| '/blogs/$'
|
||||
| '/js/script.js'
|
||||
| '/blogs/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
PrivacyRoute: typeof PrivacyRoute
|
||||
ApiEventRoute: typeof ApiEventRoute
|
||||
ApiSubscribeRoute: typeof ApiSubscribeRoute
|
||||
BlogsSplatRoute: typeof BlogsSplatRoute
|
||||
JsScriptDotjsRoute: typeof JsScriptDotjsRoute
|
||||
BlogsIndexRoute: typeof BlogsIndexRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/privacy': {
|
||||
id: '/privacy'
|
||||
path: '/privacy'
|
||||
fullPath: '/privacy'
|
||||
preLoaderRoute: typeof PrivacyRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/blogs/': {
|
||||
id: '/blogs/'
|
||||
path: '/blogs'
|
||||
fullPath: '/blogs/'
|
||||
preLoaderRoute: typeof BlogsIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/js/script.js': {
|
||||
id: '/js/script.js'
|
||||
path: '/js/script.js'
|
||||
fullPath: '/js/script.js'
|
||||
preLoaderRoute: typeof JsScriptDotjsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/blogs/$': {
|
||||
id: '/blogs/$'
|
||||
path: '/blogs/$'
|
||||
fullPath: '/blogs/$'
|
||||
preLoaderRoute: typeof BlogsSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/subscribe': {
|
||||
id: '/api/subscribe'
|
||||
path: '/api/subscribe'
|
||||
fullPath: '/api/subscribe'
|
||||
preLoaderRoute: typeof ApiSubscribeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/event': {
|
||||
id: '/api/event'
|
||||
path: '/api/event'
|
||||
fullPath: '/api/event'
|
||||
preLoaderRoute: typeof ApiEventRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
PrivacyRoute: PrivacyRoute,
|
||||
ApiEventRoute: ApiEventRoute,
|
||||
ApiSubscribeRoute: ApiSubscribeRoute,
|
||||
BlogsSplatRoute: BlogsSplatRoute,
|
||||
JsScriptDotjsRoute: JsScriptDotjsRoute,
|
||||
BlogsIndexRoute: BlogsIndexRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
12
web/src/router.tsx
Normal file
12
web/src/router.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import { NotFound } from "@/components/not-found";
|
||||
|
||||
export function getRouter() {
|
||||
return createTanStackRouter({
|
||||
routeTree,
|
||||
defaultPreload: "intent",
|
||||
scrollRestoration: true,
|
||||
defaultNotFoundComponent: NotFound,
|
||||
});
|
||||
}
|
||||
56
web/src/routes/__root.tsx
Normal file
56
web/src/routes/__root.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import {
|
||||
createRootRoute,
|
||||
HeadContent,
|
||||
Outlet,
|
||||
Scripts,
|
||||
} from "@tanstack/react-router";
|
||||
import * as React from "react";
|
||||
import appCss from "@/styles/app.css?url";
|
||||
import { RootProvider } from "fumadocs-ui/provider/tanstack";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
charSet: "utf-8",
|
||||
},
|
||||
{
|
||||
name: "viewport",
|
||||
content: "width=device-width, initial-scale=1",
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{ rel: "stylesheet", href: appCss },
|
||||
{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" },
|
||||
],
|
||||
}),
|
||||
component: RootComponent,
|
||||
});
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<RootDocument>
|
||||
<Outlet />
|
||||
</RootDocument>
|
||||
);
|
||||
}
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<HeadContent />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){function loadAnalytics(){if(window.__openrankAnalyticsLoaded)return;window.__openrankAnalyticsLoaded=true;window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)};plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init({endpoint:'/api/event'});var script=document.createElement('script');script.defer=true;script.src='/js/script.js';document.head.appendChild(script)}function schedule(){if('requestIdleCallback'in window){window.requestIdleCallback(loadAnalytics,{timeout:2000});return}window.setTimeout(loadAnalytics,2000)}if(document.readyState==='complete'){schedule();return}window.addEventListener('load',schedule,{once:true})})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className="flex flex-col min-h-screen bg-white">
|
||||
<RootProvider>{children}</RootProvider>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
20
web/src/routes/api/event.ts
Normal file
20
web/src/routes/api/event.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
const PLAUSIBLE_EVENT_URL = "https://plausible.io/api/event";
|
||||
|
||||
export const Route = createFileRoute("/api/event")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const proxyRequest = new Request(PLAUSIBLE_EVENT_URL, request);
|
||||
proxyRequest.headers.delete("cookie");
|
||||
|
||||
const upstreamResponse = await fetch(proxyRequest);
|
||||
return new Response(upstreamResponse.body, {
|
||||
status: upstreamResponse.status,
|
||||
headers: upstreamResponse.headers,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
86
web/src/routes/api/subscribe.ts
Normal file
86
web/src/routes/api/subscribe.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { z } from "zod";
|
||||
|
||||
const subscribeSchema = z.object({
|
||||
email: z.string().email("Please enter a valid email address"),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/api/subscribe")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const body = await request.json();
|
||||
const parsed = subscribeSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: parsed.error.issues[0]?.message }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const loopsApiKey = (env as any).LOOPS_API_KEY as string | undefined;
|
||||
|
||||
if (!loopsApiKey) {
|
||||
console.error("Missing LOOPS_API_KEY");
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Service temporarily unavailable" }),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const loopsResponse = await fetch(
|
||||
"https://app.loops.so/api/v1/contacts/create",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${loopsApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: parsed.data.email,
|
||||
source: "openrank-waitlist",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (loopsResponse.status === 409) {
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!loopsResponse.ok) {
|
||||
const loopsError = await loopsResponse.json().catch(() => null);
|
||||
console.error("Loops contact creation error:", loopsError);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Failed to subscribe. Please try again.",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Subscribe endpoint error:", err);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Failed to subscribe. Please try again.",
|
||||
}),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
76
web/src/routes/blogs/$.tsx
Normal file
76
web/src/routes/blogs/$.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { HomeLayout } from "fumadocs-ui/layouts/home";
|
||||
import { createClientLoader } from "fumadocs-mdx/runtime/vite";
|
||||
import { DocsBody } from "fumadocs-ui/page";
|
||||
import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||
import { baseOptions } from "@/lib/layout.shared";
|
||||
import { Suspense } from "react";
|
||||
import { getBlogPost } from "@/lib/content.functions";
|
||||
import { blog } from "../../../source.generated";
|
||||
import { buildPageSeo } from "@/lib/seo";
|
||||
|
||||
export const Route = createFileRoute("/blogs/$")({
|
||||
loader: async ({ params }: { params: { _splat?: string } }) => {
|
||||
const slugs = params._splat?.split("/") ?? [];
|
||||
const data = await getBlogPost({ data: slugs });
|
||||
await clientMdxLoader.preload(data.path);
|
||||
return data;
|
||||
},
|
||||
head: ({ loaderData }: { loaderData?: unknown }) => {
|
||||
const data = loaderData as
|
||||
| { title?: string; description?: string; url?: string }
|
||||
| undefined;
|
||||
const title = data?.title ?? "OpenRank Blog";
|
||||
const description = data?.description;
|
||||
return buildPageSeo({
|
||||
title,
|
||||
description,
|
||||
path: data?.url ?? "/blogs",
|
||||
titleSuffix: "OpenRank Blog",
|
||||
ogType: "article",
|
||||
});
|
||||
},
|
||||
component: BlogPost,
|
||||
});
|
||||
|
||||
const clientMdxLoader = createClientLoader(blog, {
|
||||
id: "blog",
|
||||
component({ default: MDX }) {
|
||||
return (
|
||||
<DocsBody>
|
||||
<MDX
|
||||
components={{
|
||||
...defaultMdxComponents,
|
||||
}}
|
||||
/>
|
||||
</DocsBody>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function BlogPost() {
|
||||
const data = Route.useLoaderData() as {
|
||||
path: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
const Content = clientMdxLoader.getComponent(data.path);
|
||||
|
||||
return (
|
||||
<HomeLayout {...baseOptions()}>
|
||||
<article className="max-w-3xl mx-auto px-6 py-12 md:py-24">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-4">{data.title}</h1>
|
||||
{data.description && (
|
||||
<p className="text-lg text-fd-muted-foreground">
|
||||
{data.description}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
<Suspense>
|
||||
<Content />
|
||||
</Suspense>
|
||||
</article>
|
||||
</HomeLayout>
|
||||
);
|
||||
}
|
||||
58
web/src/routes/blogs/index.tsx
Normal file
58
web/src/routes/blogs/index.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { HomeLayout } from "fumadocs-ui/layouts/home";
|
||||
import { baseOptions } from "@/lib/layout.shared";
|
||||
import { getBlogPosts } from "@/lib/content.functions";
|
||||
import { buildPageSeo } from "@/lib/seo";
|
||||
|
||||
const blogIndexDescription =
|
||||
"Updates and guides from OpenRank.";
|
||||
|
||||
export const Route = createFileRoute("/blogs/")({
|
||||
head: () =>
|
||||
buildPageSeo({
|
||||
title: "OpenRank Blog",
|
||||
description: blogIndexDescription,
|
||||
path: "/blogs",
|
||||
}),
|
||||
component: BlogIndex,
|
||||
loader: async () => await getBlogPosts(),
|
||||
});
|
||||
|
||||
function BlogIndex() {
|
||||
const posts = Route.useLoaderData();
|
||||
|
||||
return (
|
||||
<HomeLayout {...baseOptions()}>
|
||||
<div className="max-w-3xl mx-auto px-6 py-12 md:py-24">
|
||||
<h1 className="text-4xl font-bold mb-8">Blog</h1>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<p className="text-fd-muted-foreground">
|
||||
No blog posts yet. Check back soon.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{posts.map((post) => (
|
||||
<article key={post.url} className="border-b pb-8 last:border-b-0">
|
||||
<Link
|
||||
to="/blogs/$"
|
||||
params={{ _splat: post.slugs.join("/") }}
|
||||
className="group"
|
||||
>
|
||||
<h2 className="text-2xl font-semibold group-hover:text-fd-primary transition-colors mb-2">
|
||||
{post.title}
|
||||
</h2>
|
||||
{post.description && (
|
||||
<p className="text-fd-muted-foreground">
|
||||
{post.description}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HomeLayout>
|
||||
);
|
||||
}
|
||||
248
web/src/routes/index.tsx
Normal file
248
web/src/routes/index.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toCanonicalUrl } from "@/lib/seo";
|
||||
|
||||
const homeTitle = "OpenRank - Own Your SEO";
|
||||
const homeDescription =
|
||||
"Own your SEO. Pay only for what you use. No subscriptions. Open source alternative to Semrush and Ahrefs.";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: homeTitle },
|
||||
{ name: "description", content: homeDescription },
|
||||
{ property: "og:type", content: "website" },
|
||||
{ property: "og:title", content: homeTitle },
|
||||
{ property: "og:description", content: homeDescription },
|
||||
{ property: "og:url", content: toCanonicalUrl("/") },
|
||||
{ name: "twitter:card", content: "summary_large_image" },
|
||||
{ name: "twitter:title", content: homeTitle },
|
||||
{ name: "twitter:description", content: homeDescription },
|
||||
],
|
||||
links: [{ rel: "canonical", href: toCanonicalUrl("/") }],
|
||||
}),
|
||||
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 };
|
||||
}
|
||||
|
||||
function FooterLinks({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-6 ${className || ""}`}>
|
||||
<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>
|
||||
<Link to="/privacy">Privacy</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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">OpenRank</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",
|
||||
"Competitor Domain Insights",
|
||||
"Site Audits",
|
||||
"Backlinks, 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="OpenRank product demo"
|
||||
>
|
||||
<source src="/demo.webm" type="video/webm" />
|
||||
<source src="/demo.mp4" type="video/mp4" />
|
||||
<img
|
||||
src="/demo-poster.webp"
|
||||
alt="OpenRank 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 OpenRank
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 pt-8 border-t border-neutral-200">
|
||||
<FooterLinks className="text-xs text-neutral-600 [&_a]:hover:text-neutral-900 [&_a]:transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
30
web/src/routes/js/script[.]js.ts
Normal file
30
web/src/routes/js/script[.]js.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
const PLAUSIBLE_SCRIPT_URL = "https://plausible.io/js/pa-DllmchvGzcNdY2jEy1-Hc.js";
|
||||
|
||||
export const Route = createFileRoute("/js/script.js")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async () => {
|
||||
const upstreamResponse = await fetch(PLAUSIBLE_SCRIPT_URL);
|
||||
|
||||
if (!upstreamResponse.ok) {
|
||||
return new Response("Failed to load analytics script", {
|
||||
status: 502,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const headers = new Headers(upstreamResponse.headers);
|
||||
headers.set("cache-control", "public, max-age=86400, immutable");
|
||||
|
||||
return new Response(upstreamResponse.body, {
|
||||
status: upstreamResponse.status,
|
||||
headers,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
72
web/src/routes/privacy.tsx
Normal file
72
web/src/routes/privacy.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { buildPageSeo } from "@/lib/seo";
|
||||
|
||||
export const Route = createFileRoute("/privacy")({
|
||||
head: () =>
|
||||
buildPageSeo({
|
||||
title: "Privacy Policy",
|
||||
description: "OpenRank privacy policy",
|
||||
path: "/privacy",
|
||||
titleSuffix: "OpenRank",
|
||||
}),
|
||||
component: Privacy,
|
||||
});
|
||||
|
||||
function Privacy() {
|
||||
return (
|
||||
<div>
|
||||
<header className="px-6 py-4 border-b border-zinc-200">
|
||||
<Link to="/" className="font-semibold text-zinc-900 hover:opacity-80">
|
||||
OpenRank
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<article className="max-w-3xl mx-auto px-6 py-12 md:py-24 prose prose-gray text-zinc-900 prose-headings:text-zinc-900 prose-p:text-zinc-700 prose-a:text-zinc-900">
|
||||
<h1>Privacy Policy</h1>
|
||||
<p className="text-fd-muted-foreground">Last updated: March 2026</p>
|
||||
|
||||
<h2>What we collect</h2>
|
||||
<p>
|
||||
When you sign up for our waitlist, we collect your email address. This
|
||||
is stored securely via Loops.so, our email service provider.
|
||||
</p>
|
||||
|
||||
<h2>How we use it</h2>
|
||||
<p>
|
||||
We use your email address to send launch announcements, product updates,
|
||||
and related OpenRank marketing emails. You can unsubscribe at any time
|
||||
using the unsubscribe link in any email. We do not sell your data or
|
||||
share it with third parties beyond our service providers.
|
||||
</p>
|
||||
|
||||
<h2>Website analytics</h2>
|
||||
<p>
|
||||
We use Plausible Analytics to understand aggregate website traffic and
|
||||
usage patterns (for example, page views and referring sites). Plausible
|
||||
is cookie-free and does not use cross-site tracking. We route analytics
|
||||
requests through our own domain before forwarding them to Plausible.
|
||||
</p>
|
||||
|
||||
<h2>Data storage</h2>
|
||||
<p>
|
||||
Your email address is stored in Loops.so's infrastructure. You can request
|
||||
deletion of your data at any time by emailing{" "}
|
||||
<a href="mailto:privacy@everyapp.dev">privacy@everyapp.dev</a>.
|
||||
</p>
|
||||
|
||||
<h2>Self-hosted usage</h2>
|
||||
<p>
|
||||
If you self-host OpenRank, no data is sent to us. The self-hosted
|
||||
version communicates directly with DataForSEO's APIs using your own
|
||||
credentials.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Questions about this policy? Email{" "}
|
||||
<a href="mailto:privacy@everyapp.dev">privacy@everyapp.dev</a>.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
web/src/styles/app.css
Normal file
12
web/src/styles/app.css
Normal file
@ -0,0 +1,12 @@
|
||||
@import "tailwindcss";
|
||||
@import "fumadocs-ui/css/neutral.css";
|
||||
@import "fumadocs-ui/css/preset.css";
|
||||
|
||||
@theme {
|
||||
--color-brand: #1a1a1a;
|
||||
--color-brand-muted: #6b7280;
|
||||
--color-brand-accent: #2563eb;
|
||||
--color-surface: #fafafa;
|
||||
--color-surface-raised: #ffffff;
|
||||
--color-border-subtle: #e5e7eb;
|
||||
}
|
||||
4
web/src/types/cloudflare-workers.d.ts
vendored
Normal file
4
web/src/types/cloudflare-workers.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module "cloudflare:workers" {
|
||||
const env: Env;
|
||||
export { env };
|
||||
}
|
||||
24
web/tsconfig.json
Normal file
24
web/tsconfig.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["vite/client"],
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"target": "ES2022",
|
||||
"allowJs": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"fumadocs-mdx:collections/*": [".source/*"]
|
||||
},
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
34
web/vite.config.ts
Normal file
34
web/vite.config.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||
import { cloudflare } from "@cloudflare/vite-plugin";
|
||||
import { defineConfig } from "vite";
|
||||
import tsConfigPaths from "vite-tsconfig-paths";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import mdx from "fumadocs-mdx/vite";
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 4322,
|
||||
},
|
||||
ssr: {
|
||||
resolve: {
|
||||
conditions: ["worker", "import", "module", "default"],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
mdx(await import("./source.config")),
|
||||
tailwindcss(),
|
||||
tsConfigPaths({
|
||||
projects: ["./tsconfig.json"],
|
||||
}),
|
||||
cloudflare({
|
||||
viteEnvironment: { name: "ssr" },
|
||||
}),
|
||||
tanstackStart({
|
||||
prerender: {
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
react(),
|
||||
],
|
||||
});
|
||||
5
web/worker-configuration.d.ts
vendored
Normal file
5
web/worker-configuration.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
interface Env {
|
||||
RESEND_API_KEY: string;
|
||||
RESEND_AUDIENCE_ID: string;
|
||||
RESEND_SEGMENT_ID: string;
|
||||
}
|
||||
28
web/wrangler.jsonc
Normal file
28
web/wrangler.jsonc
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "open-rank-landing",
|
||||
"compatibility_date": "2026-02-19",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat"
|
||||
],
|
||||
"main": "@tanstack/react-start/server-entry",
|
||||
"assets": {
|
||||
"directory": "./dist/client",
|
||||
"html_handling": "drop-trailing-slash",
|
||||
},
|
||||
"workers_dev": true,
|
||||
"preview_urls": true,
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "openrank.io",
|
||||
"custom_domain": true,
|
||||
},
|
||||
],
|
||||
"env": {
|
||||
"preview": {
|
||||
"name": "open-rank-landing-preview",
|
||||
"workers_dev": true,
|
||||
"preview_urls": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user