feat: new public website
This commit is contained in:
213
apps/public/src/lib/compare.ts
Normal file
213
apps/public/src/lib/compare.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface CompareSeo {
|
||||
title: string;
|
||||
description: string;
|
||||
noindex?: boolean;
|
||||
}
|
||||
|
||||
export interface CompareCta {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface CompareHero {
|
||||
heading: string;
|
||||
subheading: string;
|
||||
badges: string[];
|
||||
}
|
||||
|
||||
export interface CompareCompetitor {
|
||||
name: string;
|
||||
logo: string;
|
||||
url: string;
|
||||
short_description: string;
|
||||
founded?: number;
|
||||
headquarters?: string;
|
||||
}
|
||||
|
||||
export interface CompareSummary {
|
||||
title: string;
|
||||
intro: string;
|
||||
one_liner: string;
|
||||
best_for_openpanel: string[];
|
||||
best_for_competitor: string[];
|
||||
}
|
||||
|
||||
export interface CompareHighlight {
|
||||
label: string;
|
||||
openpanel: string;
|
||||
competitor: string;
|
||||
}
|
||||
|
||||
export interface CompareHighlights {
|
||||
title: string;
|
||||
intro: string;
|
||||
items: CompareHighlight[];
|
||||
}
|
||||
|
||||
export interface CompareFeature {
|
||||
name: string;
|
||||
openpanel: boolean | string;
|
||||
competitor: boolean | string;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface CompareFeatureGroup {
|
||||
group: string;
|
||||
features: CompareFeature[];
|
||||
}
|
||||
|
||||
export interface CompareFeatureComparison {
|
||||
title: string;
|
||||
intro: string;
|
||||
groups: CompareFeatureGroup[];
|
||||
}
|
||||
|
||||
export interface ComparePricing {
|
||||
title: string;
|
||||
intro: string;
|
||||
openpanel: {
|
||||
model: string;
|
||||
description: string;
|
||||
};
|
||||
competitor: {
|
||||
model: string;
|
||||
description: string;
|
||||
free_tier?: string;
|
||||
pricing_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CompareTrust {
|
||||
data_processing: string;
|
||||
data_location: string;
|
||||
self_hosting: boolean;
|
||||
}
|
||||
|
||||
export interface CompareTrustCompliance {
|
||||
title: string;
|
||||
intro: string;
|
||||
openpanel: CompareTrust;
|
||||
competitor: CompareTrust;
|
||||
}
|
||||
|
||||
export interface CompareUseCase {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface CompareUseCases {
|
||||
title: string;
|
||||
intro: string;
|
||||
items: CompareUseCase[];
|
||||
}
|
||||
|
||||
export interface CompareFaq {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface CompareFaqs {
|
||||
title: string;
|
||||
intro: string;
|
||||
items: CompareFaq[];
|
||||
}
|
||||
|
||||
export interface CompareBenefitsSection {
|
||||
label?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
cta?: CompareCta;
|
||||
benefits: string[];
|
||||
}
|
||||
|
||||
export interface CompareTechnicalItem {
|
||||
label: string;
|
||||
openpanel: string | string[];
|
||||
competitor: string | string[];
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface CompareTechnicalComparison {
|
||||
title: string;
|
||||
intro: string;
|
||||
items: CompareTechnicalItem[];
|
||||
}
|
||||
|
||||
export interface CompareMigrationStep {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface CompareMigration {
|
||||
title: string;
|
||||
intro: string;
|
||||
difficulty: string;
|
||||
estimated_time: string;
|
||||
steps: CompareMigrationStep[];
|
||||
sdk_compatibility: {
|
||||
similar_api: boolean;
|
||||
notes: string;
|
||||
};
|
||||
historical_data: {
|
||||
can_import: boolean;
|
||||
notes: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CompareData {
|
||||
url: string;
|
||||
slug: string;
|
||||
page_type: 'alternative' | 'vs';
|
||||
seo: CompareSeo;
|
||||
hero: CompareHero;
|
||||
competitor: CompareCompetitor;
|
||||
summary_comparison: CompareSummary;
|
||||
highlights: CompareHighlights;
|
||||
feature_comparison: CompareFeatureComparison;
|
||||
technical_comparison?: CompareTechnicalComparison;
|
||||
pricing: ComparePricing;
|
||||
migration?: CompareMigration;
|
||||
trust_and_compliance?: CompareTrustCompliance;
|
||||
use_cases: CompareUseCases;
|
||||
faqs: CompareFaqs;
|
||||
benefits_section?: CompareBenefitsSection;
|
||||
ctas: {
|
||||
primary: CompareCta;
|
||||
secondary: CompareCta;
|
||||
};
|
||||
}
|
||||
|
||||
const contentDir = join(process.cwd(), 'content', 'compare');
|
||||
|
||||
export async function getCompareData(
|
||||
slug: string,
|
||||
): Promise<CompareData | null> {
|
||||
try {
|
||||
const filePath = join(contentDir, `${slug}.json`);
|
||||
const fileContents = readFileSync(filePath, 'utf8');
|
||||
const data = JSON.parse(fileContents) as CompareData;
|
||||
return {
|
||||
...data,
|
||||
url: `/compare/${slug}`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error loading compare data for ${slug}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllCompareSlugs(): Promise<string[]> {
|
||||
try {
|
||||
const files = readdirSync(contentDir);
|
||||
return files
|
||||
.filter((file) => file.endsWith('.json'))
|
||||
.map((file) => file.replace('.json', ''));
|
||||
} catch (error) {
|
||||
console.error('Error reading compare directory:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
37
apps/public/src/lib/dark-mode.ts
Normal file
37
apps/public/src/lib/dark-mode.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useIsDarkMode() {
|
||||
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
// Check localStorage first
|
||||
const savedTheme = window.localStorage.getItem('theme');
|
||||
if (savedTheme !== null) {
|
||||
return savedTheme === 'dark';
|
||||
}
|
||||
|
||||
// Fall back to system preference
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user prefers dark mode
|
||||
const htmlElement = document.documentElement;
|
||||
setIsDarkMode(htmlElement.classList.contains('dark'));
|
||||
|
||||
// Create observer to watch for class changes
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
setIsDarkMode(htmlElement.classList.contains('dark'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Start observing class changes on html element
|
||||
observer.observe(htmlElement, { attributes: true });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDarkMode;
|
||||
}
|
||||
10
apps/public/src/lib/github.ts
Normal file
10
apps/public/src/lib/github.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export async function getGithubRepoInfo() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
'https://api.github.com/repos/Openpanel-dev/openpanel',
|
||||
);
|
||||
return res.json();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
39
apps/public/src/lib/layout.shared.tsx
Normal file
39
apps/public/src/lib/layout.shared.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
|
||||
export const siteName = 'OpenPanel';
|
||||
export const baseUrl =
|
||||
process.env.NODE_ENV === 'production'
|
||||
? 'https://openpanel.dev'
|
||||
: 'http://localhost:3000';
|
||||
export const url = (path: string) => {
|
||||
if (path.startsWith('http')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return `${baseUrl}${path}`;
|
||||
};
|
||||
|
||||
export function baseOptions(): BaseLayoutProps {
|
||||
return {
|
||||
nav: {
|
||||
title: siteName,
|
||||
},
|
||||
links: [],
|
||||
};
|
||||
}
|
||||
|
||||
export const authors = [
|
||||
{
|
||||
name: 'OpenPanel Team',
|
||||
url: 'https://openpanel.com',
|
||||
},
|
||||
{
|
||||
name: 'Carl-Gerhard Lindesvärd',
|
||||
url: 'https://openpanel.com',
|
||||
image: '/twitter-carl.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
export const getAuthor = (author?: string) => {
|
||||
return authors.find((a) => a.name === author)!;
|
||||
};
|
||||
84
apps/public/src/lib/metadata.ts
Normal file
84
apps/public/src/lib/metadata.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { url as baseUrl } from './layout.shared';
|
||||
|
||||
const siteName = 'OpenPanel';
|
||||
const defaultDescription =
|
||||
'OpenPanel is a simple, affordable open-source alternative to Mixpanel for web and product analytics. Get powerful insights without the complexity.';
|
||||
const defaultImage = baseUrl('/ogimage.png');
|
||||
|
||||
export function getOgImageUrl(url: string): string {
|
||||
return `/og/${url.replace(baseUrl('/'), '/')}`;
|
||||
}
|
||||
|
||||
export function getRootMetadata(): Metadata {
|
||||
return getRawMetadata({
|
||||
url: baseUrl('/'),
|
||||
title: `${siteName} | An open-source alternative to Mixpanel`,
|
||||
description: defaultDescription,
|
||||
image: defaultImage,
|
||||
});
|
||||
}
|
||||
|
||||
export function getPageMetadata({
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
}: {
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
}): Metadata {
|
||||
return getRawMetadata({
|
||||
url,
|
||||
title: `${title} | ${siteName}`,
|
||||
description,
|
||||
image: image ?? getOgImageUrl(url),
|
||||
});
|
||||
}
|
||||
|
||||
export function getRawMetadata(
|
||||
{
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
}: { url: string; title: string; description: string; image: string },
|
||||
meta: Metadata = {},
|
||||
): Metadata {
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: baseUrl(url),
|
||||
},
|
||||
icons: {
|
||||
apple: '/apple-touch-icon.png',
|
||||
icon: '/favicon.ico',
|
||||
},
|
||||
manifest: '/site.webmanifest',
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
siteName: siteName,
|
||||
url: baseUrl(url),
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: image,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: title,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: [image],
|
||||
},
|
||||
...meta,
|
||||
};
|
||||
}
|
||||
73
apps/public/src/lib/source.ts
Normal file
73
apps/public/src/lib/source.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
articleCollection,
|
||||
docs,
|
||||
pageCollection,
|
||||
} from 'fumadocs-mdx:collections/server';
|
||||
import { type InferPageType, loader } from 'fumadocs-core/source';
|
||||
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
|
||||
import { toFumadocsSource } from 'fumadocs-mdx/runtime/server';
|
||||
import type { CompareData } from './compare';
|
||||
import { url } from './layout.shared';
|
||||
|
||||
// See https://fumadocs.dev/docs/headless/source-api for more info
|
||||
export const source = loader({
|
||||
baseUrl: '/docs',
|
||||
source: docs.toFumadocsSource(),
|
||||
plugins: [lucideIconsPlugin()],
|
||||
});
|
||||
|
||||
export const articleSource = loader({
|
||||
baseUrl: '/articles',
|
||||
source: toFumadocsSource(articleCollection, []),
|
||||
plugins: [lucideIconsPlugin()],
|
||||
});
|
||||
|
||||
export const pageSource = loader({
|
||||
baseUrl: '/',
|
||||
source: toFumadocsSource(pageCollection, []),
|
||||
});
|
||||
|
||||
export function getPageImage(page: InferPageType<typeof source>) {
|
||||
const segments = [...page.slugs, 'image.png'];
|
||||
|
||||
return {
|
||||
segments,
|
||||
url: `/og/docs/${segments.join('/')}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLLMText(page: InferPageType<typeof source>) {
|
||||
const processed = await page.data.getText('processed');
|
||||
|
||||
return `# ${page.data.title}
|
||||
|
||||
${processed}`;
|
||||
}
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const contentDir = path.join(__dirname, '../../content/compare');
|
||||
|
||||
const files = fs
|
||||
.readdirSync(contentDir)
|
||||
.filter((file) => file.endsWith('.json'));
|
||||
|
||||
export const compareSource: CompareData[] = files
|
||||
.map((file) => {
|
||||
const filePath = path.join(contentDir, file);
|
||||
const fileContents = fs.readFileSync(filePath, 'utf8');
|
||||
try {
|
||||
return JSON.parse(fileContents) as CompareData;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing compare data for ${file}:`, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.flatMap((item) => (item ? [item] : []))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
url: `/compare/${item.slug}`,
|
||||
}));
|
||||
16
apps/public/src/lib/utils.ts
Normal file
16
apps/public/src/lib/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export const formatEventsCount = (value: number) => {
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(0)}K`;
|
||||
}
|
||||
return value.toString();
|
||||
};
|
||||
Reference in New Issue
Block a user