14 | {/*
*/}
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | {
26 |
30 | }
31 | users online
32 |
33 |
34 |
35 | Users online in past 5 minutes
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/client/src/app/[site]/components/shared/icons/CountryFlag.tsx:
--------------------------------------------------------------------------------
1 | import * as CountryFlags from "country-flag-icons/react/3x2";
2 | import React from "react";
3 | import { getCountryName } from "../../../../../lib/utils";
4 | import { cn } from "@/lib/utils";
5 |
6 | export function CountryFlag({
7 | country,
8 | className,
9 | }: {
10 | country: string;
11 | className?: string;
12 | }) {
13 | return (
14 | <>
15 | {CountryFlags[country as keyof typeof CountryFlags]
16 | ? React.createElement(
17 | CountryFlags[country as keyof typeof CountryFlags],
18 | {
19 | title: getCountryName(country),
20 | className: cn("w-5", className),
21 | }
22 | )
23 | : null}
24 | >
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/client/src/app/[site]/components/shared/icons/OperatingSystem.tsx:
--------------------------------------------------------------------------------
1 | import { Compass } from "lucide-react";
2 | import Image from "next/image";
3 |
4 | const OS_TO_LOGO: Record
= {
5 | Windows: "Windows.svg",
6 | "Windows Phone": "Windows.svg",
7 | Android: "Android.svg",
8 | android: "Android.svg",
9 | Linux: "Tux.svg",
10 | macOS: "macOS.svg",
11 | iOS: "Apple.svg",
12 | "Chrome OS": "Chrome.svg",
13 | "Chromecast Linux": "Chrome.svg",
14 | "Chromecast Fuchsia": "Chrome.svg",
15 | Ubuntu: "Ubuntu.svg",
16 | HarmonyOS: "HarmonyOS.svg",
17 | OpenHarmony: "OpenHarmony.png",
18 | PlayStation: "PlayStation.svg",
19 | Tizen: "Tizen.png",
20 | Symbian: "Symbian.svg",
21 | Debian: "Debian.svg",
22 | Fedora: "Fedora.svg",
23 | Nintendo: "Nintendo.svg",
24 | Xbox: "Xbox.svg",
25 | };
26 |
27 | export function OperatingSystem({ os = "" }: { os?: string }) {
28 | return (
29 | <>
30 | {OS_TO_LOGO[os] ? (
31 |
38 | ) : (
39 |
40 | )}
41 | >
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/client/src/app/[site]/events/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4 | import { EVENT_FILTERS } from "@/lib/store";
5 | import { useGetEventNames } from "../../../api/analytics/useGetEventNames";
6 | import { DisabledOverlay } from "../../../components/DisabledOverlay";
7 | import { useSetPageTitle } from "../../../hooks/useSetPageTitle";
8 | import { SubHeader } from "../components/SubHeader/SubHeader";
9 | import { EventList } from "./components/EventList";
10 | import { EventLog } from "./components/EventLog";
11 |
12 | export default function EventsPage() {
13 | useSetPageTitle("Rybbit · Events");
14 |
15 | const { data: eventNamesData, isLoading: isLoadingEventNames } =
16 | useGetEventNames();
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
25 | Custom Events
26 |
27 |
28 |
33 |
34 |
35 |
36 |
37 |
38 | Event Log
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/client/src/app/[site]/goals/components/CreateGoalButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Plus } from "lucide-react";
4 | import { Button } from "../../../../components/ui/button";
5 | import GoalFormModal from "./GoalFormModal";
6 |
7 | interface CreateGoalButtonProps {
8 | siteId: number;
9 | }
10 |
11 | export default function CreateGoalButton({ siteId }: CreateGoalButtonProps) {
12 | return (
13 | <>
14 |
18 |
19 | Add Goal
20 |
21 | }
22 | />
23 | >
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/client/src/app/[site]/main/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useGetSite } from "../../../api/admin/sites";
3 | import { useSetPageTitle } from "../../../hooks/useSetPageTitle";
4 | import { useStore } from "../../../lib/store";
5 | import { SubHeader } from "../components/SubHeader/SubHeader";
6 | import { MainSection } from "./components/MainSection/MainSection";
7 | import { Countries } from "./components/sections/Countries";
8 | import { Devices } from "./components/sections/Devices";
9 | import { Events } from "./components/sections/Events";
10 | import { Pages } from "./components/sections/Pages";
11 | import { Referrers } from "./components/sections/Referrers";
12 | import { Weekdays } from "./components/sections/Weekdays";
13 |
14 | export default function MainPage() {
15 | const { site } = useStore();
16 |
17 | if (!site) {
18 | return null;
19 | }
20 |
21 | return ;
22 | }
23 |
24 | function MainPageContent() {
25 | useSetPageTitle("Rybbit · Main");
26 |
27 | return (
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/client/src/app/[site]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | // This is a simple fallback page that should never be seen
4 | // All redirects should be handled by the middleware
5 | export default function SiteRedirect() {
6 | return (
7 |
8 |
9 |
10 |
Redirecting...
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/client/src/app/[site]/pages/components/PageListSkeleton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Card, CardContent } from "@/components/ui/card";
4 | import { Skeleton } from "@/components/ui/skeleton";
5 |
6 | type PageListSkeletonProps = {
7 | count?: number;
8 | };
9 |
10 | export function PageListSkeleton({ count = 5 }: PageListSkeletonProps) {
11 | return (
12 | <>
13 | {Array.from({ length: count }).map((_, index) => (
14 |
15 |
16 |
17 | {/* Left side: Page title/path skeleton */}
18 |
19 |
20 |
21 |
22 |
23 | {/* Right side: Sparkline and count skeleton */}
24 |
25 | {/* Sparkline chart placeholder */}
26 |
27 |
28 | {/* Session count placeholder */}
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | ))}
38 | >
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/client/src/app/[site]/performance/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { SubHeader } from "../components/SubHeader/SubHeader";
4 | import { PerformanceChart } from "./components/PerformanceChart";
5 | import { PerformanceOverview } from "./components/PerformanceOverview";
6 | import { PerformanceByDimensions } from "./components/PerformanceByDimensions";
7 | import { DisabledOverlay } from "../../../components/DisabledOverlay";
8 |
9 | export default function PerformancePage() {
10 | return (
11 |
12 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/client/src/app/[site]/performance/performanceStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | export type PerformanceMetric = "lcp" | "cls" | "inp" | "fcp" | "ttfb";
4 |
5 | export type PercentileLevel = "p50" | "p75" | "p90" | "p99";
6 |
7 | type PerformanceStore = {
8 | selectedPercentile: PercentileLevel;
9 | setSelectedPercentile: (percentile: PercentileLevel) => void;
10 | selectedPerformanceMetric: PerformanceMetric;
11 | setSelectedPerformanceMetric: (metric: PerformanceMetric) => void;
12 | };
13 |
14 | export const usePerformanceStore = create((set) => ({
15 | selectedPercentile: "p90",
16 | setSelectedPercentile: (percentile) =>
17 | set({ selectedPercentile: percentile }),
18 | selectedPerformanceMetric: "lcp",
19 | setSelectedPerformanceMetric: (metric) =>
20 | set({ selectedPerformanceMetric: metric }),
21 | }));
22 |
--------------------------------------------------------------------------------
/client/src/app/[site]/realtime/realtimeStore.ts:
--------------------------------------------------------------------------------
1 | import { atom } from "jotai";
2 |
3 | export type MinutesType =
4 | | "5"
5 | | "15"
6 | | "30"
7 | | "60"
8 | | "120"
9 | | "240"
10 | | "480"
11 | | "720"
12 | | "1440";
13 |
14 | export const minutesAtom = atom("30");
15 |
--------------------------------------------------------------------------------
/client/src/app/[site]/reports/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useSetPageTitle } from "../../../hooks/useSetPageTitle";
4 |
5 | export default function ReportsPage() {
6 | useSetPageTitle("Rybbit · Reports");
7 |
8 | return (
9 |
10 |
Reports
11 |
12 | Reports and analytics content will go here.
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/client/src/app/[site]/sessions/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { DisabledOverlay } from "../../../components/DisabledOverlay";
4 | import { useSetPageTitle } from "../../../hooks/useSetPageTitle";
5 | import { SESSION_PAGE_FILTERS } from "../../../lib/store";
6 | import { SubHeader } from "../components/SubHeader/SubHeader";
7 | import SessionsList from "@/components/Sessions/SessionsList";
8 |
9 | export default function SessionsPage() {
10 | useSetPageTitle("Rybbit · Sessions");
11 |
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/client/src/app/account/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useSetPageTitle } from "../../hooks/useSetPageTitle";
4 | import { StandardPage } from "../../components/StandardPage";
5 | import { AccountInner } from "./components/AccountInner";
6 | import { authClient } from "../../lib/auth";
7 |
8 | export default function AccountPage() {
9 | useSetPageTitle("Rybbit · Account");
10 | const session = authClient.useSession();
11 |
12 | return (
13 |
14 |
15 |
16 |
17 | Account Settings
18 |
19 |
20 | Manage your personal account settings
21 |
22 |
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/client/src/app/admin/components/shared/AdminLayout.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ReactNode } from "react";
4 | import { redirect } from "next/navigation";
5 | import { AlertCircle } from "lucide-react";
6 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
7 | import { Button } from "@/components/ui/button";
8 | import { useAdminPermission } from "../../hooks/useAdminPermission";
9 |
10 | interface AdminLayoutProps {
11 | children: ReactNode;
12 | title: string;
13 | showStopImpersonating?: boolean;
14 | }
15 |
16 | export function AdminLayout({
17 | children,
18 | title,
19 | showStopImpersonating = false,
20 | }: AdminLayoutProps) {
21 | const { isAdmin, isImpersonating, isCheckingAdmin, stopImpersonating } =
22 | useAdminPermission();
23 |
24 | // If not admin, show access denied
25 | if (!isAdmin && !isCheckingAdmin) {
26 | redirect("/");
27 | }
28 |
29 | if (isCheckingAdmin) {
30 | return (
31 |
34 | );
35 | }
36 |
37 | return (
38 |
39 |
40 |
{title}
41 | {showStopImpersonating && isImpersonating && (
42 |
43 | Stop Impersonating
44 |
45 | )}
46 |
47 | {children}
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/client/src/app/admin/components/shared/AdminTablePagination.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Table } from "@tanstack/react-table";
4 | import { TablePagination } from "@/components/pagination";
5 |
6 | interface AdminTablePaginationProps {
7 | table: Table;
8 | data: { items: TData[]; total: number } | undefined;
9 | pagination: { pageIndex: number; pageSize: number };
10 | setPagination: (value: { pageIndex: number; pageSize: number }) => void;
11 | isLoading: boolean;
12 | itemName: string;
13 | }
14 |
15 | export function AdminTablePagination({
16 | table,
17 | data,
18 | pagination,
19 | setPagination,
20 | isLoading,
21 | itemName,
22 | }: AdminTablePaginationProps) {
23 | return (
24 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/client/src/app/admin/components/shared/ErrorAlert.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { AlertCircle } from "lucide-react";
4 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
5 |
6 | interface ErrorAlertProps {
7 | title?: string;
8 | message?: string;
9 | className?: string;
10 | }
11 |
12 | export function ErrorAlert({
13 | title = "Error",
14 | message = "An error occurred. Please try again later.",
15 | className = "mb-4",
16 | }: ErrorAlertProps) {
17 | return (
18 |
19 |
20 | {title}
21 | {message}
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/client/src/app/admin/components/shared/SearchInput.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Search } from "lucide-react";
4 | import { Input } from "@/components/ui/input";
5 |
6 | interface SearchInputProps {
7 | value: string;
8 | onChange: (value: string) => void;
9 | placeholder?: string;
10 | className?: string;
11 | }
12 |
13 | export function SearchInput({
14 | value,
15 | onChange,
16 | placeholder = "Search...",
17 | className = "max-w-sm",
18 | }: SearchInputProps) {
19 | return (
20 |
21 |
22 | onChange(e.target.value)}
26 | className="pl-9 bg-neutral-900 border-neutral-700"
27 | />
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/client/src/app/admin/components/shared/SortableHeader.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
5 | import { Column } from "@tanstack/react-table";
6 |
7 | interface SortableHeaderProps {
8 | column: Column;
9 | children: React.ReactNode;
10 | className?: string;
11 | }
12 |
13 | export function SortableHeader({
14 | column,
15 | children,
16 | className = "p-0 hover:bg-transparent",
17 | }: SortableHeaderProps) {
18 | const sortDirection = column.getIsSorted();
19 |
20 | return (
21 | column.toggleSorting(sortDirection === "asc")}
24 | className={className}
25 | >
26 | {children}
27 | {sortDirection === "asc" ? (
28 |
29 | ) : sortDirection === "desc" ? (
30 |
31 | ) : (
32 |
33 | )}
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/client/src/app/admin/components/users/UserTableSkeleton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { TableCell, TableRow } from "@/components/ui/table";
4 |
5 | interface SkeletonProps {
6 | rowCount?: number;
7 | columnCount?: number;
8 | }
9 |
10 | export function UserTableSkeleton({ rowCount = 50 }: SkeletonProps) {
11 | return (
12 | <>
13 | {Array.from({ length: rowCount }).map((_, index) => (
14 |
15 | {/* User ID column */}
16 |
17 |
18 |
19 | {/* Name column */}
20 |
21 |
22 |
23 | {/* Email column */}
24 |
25 |
26 |
27 | {/* Role column */}
28 |
29 |
30 |
31 | {/* Created At column */}
32 |
33 |
34 |
35 | {/* Action column */}
36 |
37 |
38 |
39 |
40 | ))}
41 | >
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/client/src/app/apple-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/src/app/apple-icon.png
--------------------------------------------------------------------------------
/client/src/app/auth/subscription/success/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 | import { useRouter } from "next/navigation";
5 | import { useSearchParams } from "next/navigation";
6 |
7 | export default function StripeSuccessPage() {
8 | const router = useRouter();
9 |
10 | useEffect(() => {
11 | // Log the redirect for debugging purposes
12 | console.log("Redirecting from Stripe success page");
13 |
14 | // Add a small delay to ensure the page has fully loaded before redirecting
15 | const redirectTimer = setTimeout(() => {
16 | // Redirect to the subscription settings page
17 | router.push("/organization/subscription");
18 | }, 1000);
19 |
20 | // Clean up the timer if the component unmounts
21 | return () => clearTimeout(redirectTimer);
22 | }, [router]);
23 |
24 | return (
25 |
26 |
27 |
Payment Successful!
28 |
31 |
32 | Your subscription has been processed successfully.
33 |
34 |
35 | Redirecting you to your subscription details...
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/client/src/app/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | export function Footer() {
4 | const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION;
5 |
6 | return (
7 |
8 |
© 2025 Rybbit
9 |
13 | v{APP_VERSION}
14 |
15 |
16 | Docs
17 |
18 |
22 | Github
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/client/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/src/app/favicon.ico
--------------------------------------------------------------------------------
/client/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import QueryProvider from "@/providers/QueryProvider";
4 | import { Inter } from "next/font/google";
5 | import { Toaster } from "../components/ui/sonner";
6 | import { TooltipProvider } from "../components/ui/tooltip";
7 | import { cn } from "../lib/utils";
8 | import "./globals.css";
9 | import Script from "next/script";
10 | import { useStopImpersonation } from "@/hooks/useStopImpersonation";
11 | import { ReactScan } from "./ReactScan";
12 | import { OrganizationInitializer } from "../components/OrganizationInitializer";
13 | import { AuthenticationGuard } from "../components/AuthenticationGuard";
14 |
15 | const inter = Inter({ subsets: ["latin"] });
16 |
17 | export default function RootLayout({
18 | children,
19 | }: {
20 | children: React.ReactNode;
21 | }) {
22 | // Use the hook to expose stopImpersonating globally
23 | useStopImpersonation();
24 |
25 | return (
26 |
27 |
28 |
29 |
35 |
36 |
37 |
38 | {children}
39 |
40 |
41 |
42 |
43 | {globalThis?.location?.hostname === "app.rybbit.io" && (
44 |
49 | )}
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/client/src/app/organization/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 | import { useRouter } from "next/navigation";
5 |
6 | export default function OrganizationPage() {
7 | const router = useRouter();
8 |
9 | useEffect(() => {
10 | // Redirect to members page by default
11 | router.replace("/organization/members");
12 | }, [router]);
13 |
14 | return null;
15 | }
16 |
--------------------------------------------------------------------------------
/client/src/app/subscribe/components/FAQSection.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | interface FAQItem {
4 | question: string;
5 | answer: string;
6 | }
7 |
8 | const FAQ_ITEMS: FAQItem[] = [
9 | {
10 | question: "What counts as an event?",
11 | answer:
12 | "An event is either a pageview or a custom event that you create on your website. Pageviews are tracked automatically, while custom events can be defined to track specific user interactions.",
13 | },
14 | {
15 | question: "Can I change my plan later?",
16 | answer:
17 | "Absolutely. You can upgrade, downgrade, or cancel your plan at any time through your account settings.",
18 | },
19 | {
20 | question: "What happens if I go over my event limit?",
21 | answer:
22 | "We'll notify you when you're approaching your limit. You can either upgrade to a higher plan or continue with your current plan (events beyond the limit won't be tracked).",
23 | },
24 | ];
25 |
26 | export function FAQSection() {
27 | return (
28 |
29 |
Frequently Asked Questions
30 |
31 | {FAQ_ITEMS.map((faq, index) => (
32 |
36 |
{faq.question}
37 |
{faq.answer}
38 |
39 | ))}
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/client/src/app/subscribe/components/PricingHeader.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export function PricingHeader() {
4 | return (
5 |
6 |
7 | Simple, Transparent Pricing
8 |
9 |
10 | Privacy-friendly analytics with all the features you need to grow
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/client/src/components/AuthenticationGuard.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 | import { redirect, usePathname } from "next/navigation";
5 | import { userStore } from "../lib/userStore";
6 | import { useGetSiteIsPublic } from "../api/admin/sites";
7 |
8 | const publicRoutes = ["/login", "/signup", "/invitation", "/reset-password"];
9 |
10 | export function AuthenticationGuard() {
11 | const { user, isPending } = userStore();
12 | const pathname = usePathname();
13 |
14 | // Extract potential siteId from path like /{siteId} or /{siteId}/something
15 | const pathSegments = pathname.split("/").filter(Boolean);
16 | const potentialSiteId =
17 | pathSegments.length > 0 && !isNaN(Number(pathSegments[0]))
18 | ? pathSegments[0]
19 | : undefined;
20 |
21 | // Use Tanstack Query to check if site is public
22 | const { data: isPublicSite, isLoading: isCheckingPublic } =
23 | useGetSiteIsPublic(potentialSiteId);
24 |
25 | useEffect(() => {
26 | // Only redirect if:
27 | // 1. We're not checking public status anymore
28 | // 2. User is not logged in
29 | // 3. Not on a public route
30 | // 4. Not on a public site
31 | if (
32 | !isPending &&
33 | !isCheckingPublic &&
34 | !user &&
35 | !publicRoutes.includes(pathname) &&
36 | !isPublicSite
37 | ) {
38 | redirect("/login");
39 | }
40 | }, [isPending, user, pathname, isCheckingPublic, isPublicSite]);
41 |
42 | return null; // This component doesn't render anything
43 | }
44 |
--------------------------------------------------------------------------------
/client/src/components/CodeSnippet.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Check, Copy } from "lucide-react";
4 | import * as React from "react";
5 |
6 | import { Button } from "@/components/ui/button";
7 | import { cn } from "@/lib/utils";
8 |
9 | interface CodeSnippetProps extends React.HTMLAttributes {
10 | code: string;
11 | language?: string;
12 | }
13 |
14 | export function CodeSnippet({
15 | code,
16 | language,
17 | className,
18 | ...props
19 | }: CodeSnippetProps) {
20 | const [hasCopied, setHasCopied] = React.useState(false);
21 |
22 | const copyToClipboard = React.useCallback(async () => {
23 | await navigator.clipboard.writeText(code);
24 | setHasCopied(true);
25 | setTimeout(() => setHasCopied(false), 2000);
26 | }, [code]);
27 |
28 | return (
29 |
30 |
34 | {language && (
35 | {language}
36 | )}
37 | {code}
38 |
39 |
45 | {hasCopied ? : }
46 | Copy code
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/client/src/components/DateSelector/types.ts:
--------------------------------------------------------------------------------
1 | export type DateMode = {
2 | mode: "day";
3 | day: string;
4 | };
5 |
6 | export type DateRangeMode = {
7 | mode: "range";
8 | startDate: string;
9 | endDate: string;
10 | wellKnown?:
11 | | "Last 3 days"
12 | | "Last 7 days"
13 | | "Last 14 days"
14 | | "Last 30 days"
15 | | "Last 60 days";
16 | };
17 |
18 | export type WeekMode = {
19 | mode: "week";
20 | week: string;
21 | };
22 |
23 | export type MonthMode = {
24 | mode: "month";
25 | month: string;
26 | };
27 |
28 | export type YearMode = {
29 | mode: "year";
30 | year: string;
31 | };
32 |
33 | export type AllTimeMode = {
34 | mode: "all-time";
35 | };
36 |
37 | export type Past24HoursMode = {
38 | mode: "last-24-hours";
39 | };
40 |
41 | export type Time =
42 | | DateMode
43 | | DateRangeMode
44 | | WeekMode
45 | | MonthMode
46 | | YearMode
47 | | AllTimeMode
48 | | Past24HoursMode;
49 |
--------------------------------------------------------------------------------
/client/src/components/Favicon.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { cn } from "../lib/utils";
3 |
4 | export function Favicon({
5 | domain,
6 | className,
7 | }: {
8 | domain: string;
9 | className?: string;
10 | }) {
11 | const [imageError, setImageError] = useState(false);
12 | const firstLetter = domain.charAt(0).toUpperCase();
13 |
14 | if (imageError) {
15 | return (
16 |
19 | {firstLetter}
20 |
21 | );
22 | }
23 |
24 | return (
25 | setImageError(true)}
30 | />
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/client/src/components/FreePlanBanner.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowRight } from "lucide-react";
2 | import Link from "next/link";
3 | import { useCurrentSite } from "../api/admin/sites";
4 | import { DEFAULT_EVENT_LIMIT } from "../lib/subscription/constants";
5 | import { Button } from "./ui/button";
6 |
7 | export function FreePlanBanner() {
8 | const { site, subscription } = useCurrentSite();
9 |
10 | if (!site) return null;
11 |
12 | // Format numbers with commas
13 | const formatNumber = (num: number = 0) => {
14 | return num.toLocaleString();
15 | };
16 |
17 | if (subscription?.eventLimit === DEFAULT_EVENT_LIMIT) {
18 | return (
19 |
20 |
21 |
22 | Free plan: Using{" "}
23 |
24 | {formatNumber(subscription?.monthlyEventCount || 0)}
25 | {" "}
26 | of {formatNumber(subscription?.eventLimit || 0)} {" "}
27 | events
28 |
29 |
30 | {site.isOwner && (
31 |
32 |
33 | Upgrade
34 |
35 |
36 | )}
37 |
38 | );
39 | }
40 |
41 | return null;
42 | }
43 |
--------------------------------------------------------------------------------
/client/src/components/Loaders.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "../lib/utils";
2 |
3 | export function ThreeDotLoader({ className }: { className?: string }) {
4 | return (
5 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/client/src/components/MobileSidebar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { usePathname } from "next/navigation";
4 | import { useGetSite } from "../api/admin/sites";
5 | import { Sidebar } from "../app/[site]/components/Sidebar/Sidebar";
6 | import { Button } from "./ui/button";
7 | import {
8 | Sheet,
9 | SheetContent,
10 | SheetHeader,
11 | SheetTitle,
12 | SheetTrigger,
13 | } from "./ui/sheet";
14 |
15 | import { Menu } from "lucide-react";
16 | import { VisuallyHidden } from "radix-ui";
17 | import { Favicon } from "./Favicon";
18 |
19 | export function MobileSidebar() {
20 | const pathname = usePathname();
21 | const { data: site } = useGetSite(Number(pathname.split("/")[1]));
22 |
23 | return (
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | Rybbit Sidebar
34 |
35 |
36 |
37 |
38 |
39 |
40 | {site && }
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/client/src/components/NothingFound.tsx:
--------------------------------------------------------------------------------
1 | import { PlusCircle } from "lucide-react";
2 |
3 | export function NothingFound({
4 | title,
5 | description,
6 | action,
7 | icon,
8 | }: {
9 | title: string;
10 | description?: string;
11 | action?: React.ReactNode;
12 | icon?: React.ReactNode;
13 | }) {
14 | return (
15 |
16 | {icon && (
17 |
18 | {icon}
19 |
20 | )}
21 |
{title}
22 | {description && (
23 |
{description}
24 | )}
25 | {action && action}
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/client/src/components/OrganizationInitializer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 | import { authClient } from "../lib/auth";
5 | import { useUserOrganizations } from "../api/admin/organizations";
6 |
7 | export function OrganizationInitializer() {
8 | const { data: organizations } = useUserOrganizations();
9 | const { data: activeOrganization, isPending: isPendingActiveOrganization } =
10 | authClient.useActiveOrganization();
11 |
12 | useEffect(() => {
13 | if (
14 | !isPendingActiveOrganization &&
15 | !activeOrganization &&
16 | organizations?.length
17 | ) {
18 | authClient.organization.setActive({
19 | organizationId: organizations?.[0]?.id,
20 | });
21 | }
22 | }, [isPendingActiveOrganization, activeOrganization, organizations]);
23 |
24 | return null; // This component doesn't render anything
25 | }
26 |
--------------------------------------------------------------------------------
/client/src/components/StandardPage.tsx:
--------------------------------------------------------------------------------
1 | import { TopBar } from "./TopBar";
2 |
3 | export function StandardPage({ children }: { children: React.ReactNode }) {
4 | return (
5 |
6 |
7 |
8 | {children}
9 |
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/client/src/components/ThemeToggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Moon, Sun } from "lucide-react";
4 | import { useTheme } from "next-themes";
5 |
6 | import { Button } from "@/components/ui/button";
7 |
8 | export function ThemeToggle() {
9 | const { theme, setTheme } = useTheme();
10 |
11 | return (
12 | setTheme(theme === "light" ? "dark" : "light")}
16 | >
17 |
18 |
19 | Toggle theme
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/client/src/components/auth/AuthButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { ReactNode } from "react";
5 |
6 | interface AuthButtonProps {
7 | isLoading: boolean;
8 | loadingText?: string;
9 | children: ReactNode;
10 | className?: string;
11 | onClick?: () => void;
12 | type?: "button" | "submit" | "reset";
13 | variant?:
14 | | "default"
15 | | "destructive"
16 | | "outline"
17 | | "secondary"
18 | | "ghost"
19 | | "link"
20 | | "success";
21 | }
22 |
23 | export function AuthButton({
24 | isLoading,
25 | loadingText = "Loading...",
26 | children,
27 | className = "",
28 | onClick,
29 | type = "submit",
30 | variant = "success",
31 | }: AuthButtonProps) {
32 | return (
33 |
40 | {isLoading ? loadingText : children}
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/client/src/components/auth/AuthError.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { AlertCircle } from "lucide-react";
4 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
5 |
6 | interface AuthErrorProps {
7 | error?: string;
8 | title?: string;
9 | }
10 |
11 | export function AuthError({ error, title = "Error" }: AuthErrorProps) {
12 | if (!error) return null;
13 |
14 | return (
15 |
16 |
17 | {title}
18 | {error}
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/client/src/components/auth/AuthInput.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Input } from "@/components/ui/input";
4 | import { Label } from "@/components/ui/label";
5 | import { ReactNode } from "react";
6 |
7 | interface AuthInputProps {
8 | id: string;
9 | label: string;
10 | type: string;
11 | placeholder: string;
12 | value: string;
13 | onChange: (e: React.ChangeEvent) => void;
14 | required?: boolean;
15 | className?: string;
16 | rightElement?: ReactNode;
17 | }
18 |
19 | export function AuthInput({
20 | id,
21 | label,
22 | type,
23 | placeholder,
24 | value,
25 | onChange,
26 | required = false,
27 | className = "",
28 | rightElement,
29 | }: AuthInputProps) {
30 | return (
31 |
32 |
33 |
{label}
34 | {rightElement &&
{rightElement}
}
35 |
36 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/client/src/components/nivo.ts:
--------------------------------------------------------------------------------
1 | import { useTheme } from "next-themes";
2 |
3 | export function useNivoTheme() {
4 | const { theme } = useTheme();
5 | const chartTheme = {
6 | axis: {
7 | ticks: {
8 | text: {
9 | fill: theme === "dark" ? "#ffffff" : "#000000",
10 | },
11 | },
12 | legend: {
13 | text: {
14 | fill: theme === "dark" ? "#ffffff" : "#000000",
15 | },
16 | },
17 | },
18 | grid: {
19 | line: {
20 | stroke: theme === "dark" ? "#737373" : "#d4d4d4",
21 | strokeWidth: 1,
22 | },
23 | },
24 | legends: {
25 | text: {
26 | fill: theme === "dark" ? "#ffffff" : "#000000",
27 | },
28 | },
29 | tooltip: {
30 | container: {
31 | background: theme === "dark" ? "hsl(var(--background))" : "white",
32 | color: theme === "dark" ? "#ffffff" : "#000000",
33 | fontSize: 12,
34 | },
35 | },
36 | };
37 | return chartTheme;
38 | }
39 |
--------------------------------------------------------------------------------
/client/src/components/subscription/HelpSection.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "../ui/button";
2 |
3 | interface HelpSectionProps {
4 | router: {
5 | push: (url: string) => void;
6 | };
7 | }
8 |
9 | export function HelpSection({ router }: HelpSectionProps) {
10 | return (
11 |
12 |
Need Help?
13 |
14 | For billing questions or subscription support, please contact our
15 | customer service team.
16 |
17 |
router.push("/contact")}>
18 | Contact Support
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/client/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | )
18 | }
19 | )
20 | Input.displayName = "Input"
21 |
22 | export { Input }
23 |
--------------------------------------------------------------------------------
/client/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as LabelPrimitive from "@radix-ui/react-label";
5 | import { cva, type VariantProps } from "class-variance-authority";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-muted-foreground"
11 | );
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ));
24 | Label.displayName = LabelPrimitive.Root.displayName;
25 |
26 | export { Label };
27 |
--------------------------------------------------------------------------------
/client/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as PopoverPrimitive from "@radix-ui/react-popover";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Popover = PopoverPrimitive.Root;
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger;
11 |
12 | const PopoverAnchor = PopoverPrimitive.Anchor;
13 |
14 | const PopoverContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
18 |
19 |
29 |
30 | ));
31 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
32 |
33 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
34 |
--------------------------------------------------------------------------------
/client/src/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as ProgressPrimitive from "@radix-ui/react-progress";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Progress = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, value, ...props }, ref) => (
12 |
20 |
24 |
25 | ));
26 | Progress.displayName = ProgressPrimitive.Root.displayName;
27 |
28 | export { Progress };
29 |
--------------------------------------------------------------------------------
/client/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = "horizontal", decorative = true, ...props },
14 | ref
15 | ) => (
16 |
27 | )
28 | );
29 | Separator.displayName = SeparatorPrimitive.Root.displayName;
30 |
31 | export { Separator };
32 |
--------------------------------------------------------------------------------
/client/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | );
13 | }
14 |
15 | export { Skeleton };
16 |
--------------------------------------------------------------------------------
/client/src/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SliderPrimitive from "@radix-ui/react-slider";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Slider = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
21 |
22 |
23 |
24 |
25 | ));
26 | Slider.displayName = SliderPrimitive.Root.displayName;
27 |
28 | export { Slider };
29 |
--------------------------------------------------------------------------------
/client/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useTheme } from "next-themes";
4 | import { Toaster as Sonner } from "sonner";
5 |
6 | type ToasterProps = React.ComponentProps;
7 |
8 | const Toaster = ({ ...props }: ToasterProps) => {
9 | const { theme = "system" } = useTheme();
10 |
11 | return (
12 |
29 | );
30 | };
31 |
32 | export { Toaster };
33 |
--------------------------------------------------------------------------------
/client/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SwitchPrimitives from "@radix-ui/react-switch";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ));
27 | Switch.displayName = SwitchPrimitives.Root.displayName;
28 |
29 | export { Switch };
30 |
--------------------------------------------------------------------------------
/client/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Textarea = React.forwardRef<
6 | HTMLTextAreaElement,
7 | React.ComponentProps<"textarea">
8 | >(({ className, ...props }, ref) => {
9 | return (
10 |
18 | )
19 | })
20 | Textarea.displayName = "Textarea"
21 |
22 | export { Textarea }
23 |
--------------------------------------------------------------------------------
/client/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider;
9 |
10 | const Tooltip = ({
11 | delayDuration = 300,
12 | ...props
13 | }: React.ComponentProps) => (
14 |
15 | );
16 |
17 | const TooltipTrigger = TooltipPrimitive.Trigger;
18 |
19 | const TooltipContent = React.forwardRef<
20 | React.ElementRef,
21 | React.ComponentPropsWithoutRef
22 | >(({ className, sideOffset = 4, ...props }, ref) => (
23 |
24 |
33 |
34 | ));
35 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
36 |
37 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
38 |
--------------------------------------------------------------------------------
/client/src/hooks/useInView.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 |
3 | interface UseInViewOptions extends IntersectionObserverInit {
4 | /**
5 | * Once an element has been seen, keep it as "in view" to prevent
6 | * flickering when scrolling in and out quickly
7 | */
8 | persistVisibility?: boolean;
9 | }
10 |
11 | export function useInView(
12 | options?: UseInViewOptions
13 | ) {
14 | const { persistVisibility = true, ...observerOptions } = options || {};
15 | const ref = useRef(null);
16 | const [isInView, setIsInView] = useState(false);
17 | const wasPreviouslyVisible = useRef(false);
18 |
19 | useEffect(() => {
20 | const element = ref.current;
21 | if (!element) return;
22 |
23 | const observer = new IntersectionObserver(([entry]) => {
24 | if (entry.isIntersecting) {
25 | // Always update state when entering viewport
26 | setIsInView(true);
27 | wasPreviouslyVisible.current = true;
28 | } else if (!persistVisibility || !wasPreviouslyVisible.current) {
29 | // Only update state when leaving viewport if we're not persisting visibility
30 | // or if we haven't been visible before
31 | setIsInView(false);
32 | }
33 | }, observerOptions);
34 |
35 | observer.observe(element);
36 |
37 | return () => {
38 | observer.disconnect();
39 | };
40 | }, [observerOptions, persistVisibility]);
41 |
42 | return { ref, isInView };
43 | }
44 |
--------------------------------------------------------------------------------
/client/src/hooks/useSetPageTitle.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 |
3 | export const useSetPageTitle = (title: string) => {
4 | useEffect(() => {
5 | if (title) document.title = title;
6 | }, [title]);
7 | };
8 |
--------------------------------------------------------------------------------
/client/src/hooks/useStopImpersonation.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { authClient } from "@/lib/auth";
3 |
4 | // Add stopImpersonating to Window interface
5 | declare global {
6 | interface Window {
7 | stopImpersonating: () => Promise;
8 | }
9 | }
10 |
11 | export function useStopImpersonation() {
12 | useEffect(() => {
13 | if (typeof window !== "undefined") {
14 | window.stopImpersonating = async () => {
15 | try {
16 | await authClient.admin.stopImpersonating();
17 | console.log("Successfully stopped impersonating. Reloading page...");
18 | window.location.href = "/admin";
19 | return true;
20 | } catch (err) {
21 | console.error("Failed to stop impersonation:", err);
22 | return false;
23 | }
24 | };
25 | }
26 | }, []);
27 | }
28 |
--------------------------------------------------------------------------------
/client/src/lib/auth.ts:
--------------------------------------------------------------------------------
1 | import {
2 | adminClient,
3 | organizationClient,
4 | emailOTPClient,
5 | } from "better-auth/client/plugins";
6 | import { createAuthClient } from "better-auth/react";
7 |
8 | export const authClient = createAuthClient({
9 | baseURL: process.env.NEXT_PUBLIC_BACKEND_URL,
10 | plugins: [adminClient(), organizationClient(), emailOTPClient()],
11 | fetchOptions: {
12 | credentials: "include",
13 | },
14 | socialProviders: ["google", "github", "twitter"],
15 | });
16 |
--------------------------------------------------------------------------------
/client/src/lib/configs.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { authedFetchWithError } from "../api/utils";
3 | import { BACKEND_URL } from "./const";
4 |
5 | interface Configs {
6 | disableSignup: boolean;
7 | }
8 |
9 | export function useConfigs() {
10 | const { data, isLoading, error } = useQuery({
11 | queryKey: ["configs"],
12 | queryFn: () => authedFetchWithError(`${BACKEND_URL}/config`),
13 | });
14 |
15 | return {
16 | configs: data,
17 | isLoading,
18 | error,
19 | };
20 | }
21 |
--------------------------------------------------------------------------------
/client/src/lib/const.ts:
--------------------------------------------------------------------------------
1 | export const BACKEND_URL =
2 | process.env.NEXT_PUBLIC_BACKEND_URL === "http://localhost:3001"
3 | ? "http://localhost:3001/api"
4 | : `${process.env.NEXT_PUBLIC_BACKEND_URL}/api`;
5 | export const IS_CLOUD = process.env.NEXT_PUBLIC_CLOUD === "true";
6 | export const IS_DEMO = process.env.NEXT_PUBLIC_DEMO === "true";
7 |
--------------------------------------------------------------------------------
/client/src/lib/geo.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 |
3 | const countriesGeoUrl = "/countries.json";
4 | const subdivisionsGeoUrl = "/subdivisions.json";
5 |
6 | export type Subdivisions = {
7 | type: string;
8 | features: Array<{
9 | type: string;
10 | properties: {
11 | name: string;
12 | iso_3166_2: string;
13 | admin: string;
14 | border: number;
15 | };
16 | geometry: {
17 | type: string;
18 | coordinates: Array>>;
19 | };
20 | }>;
21 | };
22 |
23 | export type Country = {
24 | type: string;
25 | features: Array<{
26 | type: string;
27 | properties: {
28 | ISO_A2: string;
29 | ADMIN: string;
30 | ISO_A3: string;
31 | BORDER: number;
32 | };
33 | geometry: {
34 | type: string;
35 | coordinates: Array>>;
36 | };
37 | }>;
38 | };
39 |
40 | export const useSubdivisions = () => {
41 | return useQuery({
42 | queryKey: ["subdivisions"],
43 | queryFn: () => fetch(subdivisionsGeoUrl).then((res) => res.json()),
44 | });
45 | };
46 |
47 | export const useCountries = () => {
48 | return useQuery({
49 | queryKey: ["countries"],
50 | queryFn: () => fetch(countriesGeoUrl).then((res) => res.json()),
51 | });
52 | };
53 |
54 | export const useGetRegionName = () => {
55 | const { data: subdivisions } = useSubdivisions();
56 |
57 | return {
58 | getRegionName: (region: string) => {
59 | return subdivisions?.features.find(
60 | (feature) => feature.properties.iso_3166_2 === region
61 | )?.properties.name;
62 | },
63 | };
64 | };
65 |
--------------------------------------------------------------------------------
/client/src/lib/nivo.ts:
--------------------------------------------------------------------------------
1 | import { PartialTheme } from "@nivo/theming";
2 |
3 | export const nivoTheme: PartialTheme = {
4 | axis: {
5 | legend: {
6 | text: {
7 | fill: "hsl(var(--neutral-400))",
8 | },
9 | },
10 | ticks: {
11 | line: {},
12 | text: {
13 | fill: "hsl(var(--neutral-400))",
14 | },
15 | },
16 | },
17 | grid: {
18 | line: {
19 | stroke: "hsl(var(--neutral-800))",
20 | strokeWidth: 1,
21 | },
22 | },
23 | tooltip: {
24 | basic: {
25 | fontFamily: "Roboto Mono",
26 | },
27 | container: {
28 | backdropFilter: "blur( 7px )",
29 | background: "rgb(40, 40, 40, 0.8)",
30 | color: "rgb(255, 255, 255)",
31 | },
32 | },
33 | crosshair: { line: { stroke: "hsl(var(--neutral-50))" } },
34 | annotations: {
35 | text: {
36 | fill: "hsl(var(--neutral-400))",
37 | },
38 | },
39 | text: {
40 | fill: "hsl(var(--neutral-400))",
41 | },
42 | labels: {
43 | text: {
44 | fill: "hsl(var(--neutral-400))",
45 | },
46 | },
47 | };
48 |
--------------------------------------------------------------------------------
/client/src/lib/subscription/constants.ts:
--------------------------------------------------------------------------------
1 | export const TRIAL_EVENT_LIMIT = 1_000_000;
2 | export const TRIAL_DURATION_DAYS = 14;
3 |
4 | // Default limit for the free tier
5 | export const DEFAULT_EVENT_LIMIT = 3_000;
6 |
--------------------------------------------------------------------------------
/client/src/lib/subscription/planUtils.tsx:
--------------------------------------------------------------------------------
1 | // Helper function to format dates
2 | export const formatDate = (dateString: string | Date | null | undefined) => {
3 | if (!dateString) return "N/A";
4 | const date = dateString instanceof Date ? dateString : new Date(dateString);
5 | return new Intl.DateTimeFormat("en-US", {
6 | year: "numeric",
7 | month: "long",
8 | day: "numeric",
9 | }).format(date);
10 | };
11 |
--------------------------------------------------------------------------------
/client/src/lib/userStore.ts:
--------------------------------------------------------------------------------
1 | import { User } from "better-auth";
2 | import { create } from "zustand";
3 | import { authClient } from "./auth";
4 |
5 | export const userStore = create<{
6 | user: User | null;
7 | isPending: boolean;
8 | setSession: (user: User) => void;
9 | setIsPending: (isPending: boolean) => void;
10 | }>((set) => ({
11 | user: null,
12 | isPending: true,
13 | setSession: (user) => set({ user }),
14 | setIsPending: (isPending) => set({ isPending }),
15 | }));
16 |
17 | authClient.getSession().then(({ data: session }) => {
18 | userStore.setState({
19 | user: session?.user,
20 | isPending: false,
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/client/src/providers/QueryProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
5 | import { useState } from "react";
6 |
7 | export default function QueryProvider({
8 | children,
9 | }: {
10 | children: React.ReactNode;
11 | }) {
12 | const [queryClient] = useState(() => new QueryClient());
13 |
14 | return (
15 |
16 | {children}
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/client/src/providers/ThemeProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ThemeProvider as NextThemesProvider } from "next-themes";
5 |
6 | export function ThemeProvider({ children }: { children: React.ReactNode }) {
7 | return (
8 |
14 | {children}
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/client/src/types/admin.ts:
--------------------------------------------------------------------------------
1 | export type AdminUser = {
2 | id: string;
3 | name: string | null;
4 | email: string;
5 | role?: string;
6 | createdAt: string | Date;
7 | };
8 |
9 | export interface UsersResponse {
10 | data?: {
11 | users: AdminUser[];
12 | total: number;
13 | limit?: number;
14 | offset?: number;
15 | };
16 | }
17 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | .next
2 | public/_pagefind
3 |
--------------------------------------------------------------------------------
/docs/Caddyfile:
--------------------------------------------------------------------------------
1 | # Caddyfile for docs
2 |
3 | { # Global options
4 | auto_https off # Disable automatic HTTPS
5 | local_certs # Use local certificates for development
6 | debug # Enable debug logging
7 | }
8 |
9 | localhost:80 { # Enable compression
10 | encode zstd gzip
11 |
12 | # Proxy all requests to the docs service
13 | reverse_proxy docs_app:3000 {
14 | # Add health checks and timeouts
15 | health_timeout 5s
16 | health_status 200
17 | }
18 |
19 | # Security headers
20 | header {
21 | X-Content-Type-Options nosniff
22 | X-Frame-Options DENY
23 | Referrer-Policy strict-origin-when-cross-origin
24 | }
25 |
26 | # Debug logs
27 | log {
28 | output stdout
29 | format console
30 | level DEBUG
31 | }
32 |
33 | }
34 |
--------------------------------------------------------------------------------
/docs/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-alpine AS base
2 |
3 | # Install dependencies only when needed
4 | FROM base AS deps
5 | RUN apk add --no-cache libc6-compat
6 | WORKDIR /app
7 |
8 | # Install dependencies based on the preferred package manager
9 | COPY package.json package-lock.json* ./
10 | RUN npm ci --legacy-peer-deps
11 |
12 | # Rebuild the source code only when needed
13 | FROM base AS builder
14 | WORKDIR /app
15 | COPY --from=deps /app/node_modules ./node_modules
16 | COPY . .
17 |
18 | # Next.js collects completely anonymous telemetry data about general usage.
19 | ENV NEXT_TELEMETRY_DISABLED 1
20 |
21 | RUN npm run build
22 |
23 | # Production image, copy all the files and run next
24 | FROM base AS runner
25 | WORKDIR /app
26 |
27 | ENV NODE_ENV production
28 | ENV NEXT_TELEMETRY_DISABLED 1
29 |
30 | RUN addgroup --system --gid 1001 nodejs
31 | RUN adduser --system --uid 1001 nextjs
32 |
33 | COPY --from=builder /app/public ./public
34 |
35 | # Set the correct permission for prerender cache
36 | RUN mkdir .next
37 | RUN chown nextjs:nodejs .next
38 |
39 | # Automatically leverage output traces to reduce image size
40 | # https://nextjs.org/docs/advanced-features/output-file-tracing
41 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
42 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
43 |
44 | USER nextjs
45 |
46 | EXPOSE 3000
47 |
48 | ENV PORT 3000
49 | ENV HOSTNAME "0.0.0.0"
50 |
51 | # Start Next.js
52 | CMD ["node", "server.js"]
--------------------------------------------------------------------------------
/docs/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "src/app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/docs/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | caddy:
3 | image: caddy:latest
4 | container_name: docs_caddy
5 | restart: unless-stopped
6 | ports:
7 | - "8080:80" # Using different port to avoid conflict with main app
8 | volumes:
9 | - ./Caddyfile:/etc/caddy/Caddyfile # Mount Caddy config file
10 | - docs_caddy_data:/data # Mount persistent data volume for certs etc.
11 | - docs_caddy_config:/config # Mount persistent config volume
12 | environment:
13 | # Fallback to localhost if domain not set
14 | - DOMAIN_NAME=${DOMAIN_NAME:-localhost}
15 | depends_on:
16 | - docs
17 |
18 | docs:
19 | container_name: docs_app
20 | build:
21 | context: .
22 | dockerfile: Dockerfile
23 | restart: unless-stopped
24 |
25 | volumes:
26 | docs_caddy_data: # Persistent volume for Caddy's certificates and state
27 | docs_caddy_config: # Persistent volume for Caddy's configuration cache
--------------------------------------------------------------------------------
/docs/mdx-components.js:
--------------------------------------------------------------------------------
1 | import { useMDXComponents as getDocsMDXComponents } from "nextra-theme-docs";
2 |
3 | const docsComponents = getDocsMDXComponents();
4 |
5 | export const useMDXComponents = (components) => ({
6 | ...docsComponents,
7 | ...components,
8 | });
9 |
--------------------------------------------------------------------------------
/docs/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
6 |
--------------------------------------------------------------------------------
/docs/next.config.mjs:
--------------------------------------------------------------------------------
1 | import nextra from "nextra";
2 |
3 | const withNextra = nextra({
4 | latex: true,
5 | search: {
6 | codeblocks: false,
7 | },
8 | contentDirBasePath: "/docs",
9 | });
10 |
11 | export default withNextra({
12 | reactStrictMode: true,
13 | output: "standalone",
14 | });
15 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rybbit-docs",
3 | "license": "MIT",
4 | "private": true,
5 | "scripts": {
6 | "build": "next build",
7 | "dev": "next --turbopack --port 3003",
8 | "postbuild": "pagefind --site .next/server/app --output-path public/_pagefind",
9 | "start": "next start --port 3003"
10 | },
11 | "dependencies": {
12 | "@radix-ui/react-accordion": "1.2.10",
13 | "@radix-ui/react-slider": "1.3.4",
14 | "@tailwindcss/postcss": "4.1.6",
15 | "boring-avatars": "1.11.2",
16 | "clsx": "2.1.1",
17 | "country-flag-icons": "1.5.19",
18 | "gray-matter": "^4.0.3",
19 | "lucide-react": "0.510.0",
20 | "motion": "12.11.0",
21 | "next": "^15.3.2",
22 | "next-mdx-remote": "^5.0.0",
23 | "nextra": "^4.2.17",
24 | "nextra-theme-docs": "4.2.17",
25 | "posthog-js": "^1.249.0",
26 | "react": "^19.1.0",
27 | "react-dom": "^19.1.0",
28 | "react-tweet": "3.2.2",
29 | "tailwind-merge": "3.3.0",
30 | "tailwindcss": "4.1.6"
31 | },
32 | "devDependencies": {
33 | "@types/node": "20.14.8",
34 | "@types/react": "19.1.4",
35 | "pagefind": "1.3.0",
36 | "typescript": "5.8.3"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/docs/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: {
3 | "@tailwindcss/postcss": {},
4 | },
5 | };
6 | export default config;
7 |
--------------------------------------------------------------------------------
/docs/public/blog/5kstars.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/blog/5kstars.png
--------------------------------------------------------------------------------
/docs/public/browsers/360.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/browsers/360.png
--------------------------------------------------------------------------------
/docs/public/browsers/Android.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/docs/public/browsers/Avast.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/browsers/Avast.png
--------------------------------------------------------------------------------
/docs/public/browsers/Baidu.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/public/browsers/Chrome.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/public/browsers/Facebook.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
9 |
10 |
11 |
12 |
13 |
15 |
17 |
18 |
--------------------------------------------------------------------------------
/docs/public/browsers/HeyTap.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/browsers/HeyTap.png
--------------------------------------------------------------------------------
/docs/public/browsers/Iron.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/browsers/Iron.png
--------------------------------------------------------------------------------
/docs/public/browsers/Lenovo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/browsers/Lenovo.png
--------------------------------------------------------------------------------
/docs/public/browsers/Line.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/public/browsers/Naver.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/browsers/Naver.webp
--------------------------------------------------------------------------------
/docs/public/browsers/Oculus.svg:
--------------------------------------------------------------------------------
1 |
2 | Oculus icon
--------------------------------------------------------------------------------
/docs/public/browsers/Opera.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/docs/public/browsers/OperaGX.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/docs/public/browsers/PaleMoon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/browsers/PaleMoon.png
--------------------------------------------------------------------------------
/docs/public/browsers/QQ.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/browsers/QQ.webp
--------------------------------------------------------------------------------
/docs/public/browsers/Silk.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/browsers/Silk.png
--------------------------------------------------------------------------------
/docs/public/browsers/Sleipnir.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/browsers/Sleipnir.webp
--------------------------------------------------------------------------------
/docs/public/browsers/Sogou.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/browsers/Sogou.png
--------------------------------------------------------------------------------
/docs/public/browsers/Vivo.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/browsers/Vivo.webp
--------------------------------------------------------------------------------
/docs/public/browsers/Wolvic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/browsers/Wolvic.png
--------------------------------------------------------------------------------
/docs/public/eu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/eu.png
--------------------------------------------------------------------------------
/docs/public/eu.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/docs/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/favicon.ico
--------------------------------------------------------------------------------
/docs/public/globe.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/globe.jpg
--------------------------------------------------------------------------------
/docs/public/main.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/main.jpg
--------------------------------------------------------------------------------
/docs/public/operating-systems/Android.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/docs/public/operating-systems/Chrome.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/public/operating-systems/OpenHarmony.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/operating-systems/OpenHarmony.png
--------------------------------------------------------------------------------
/docs/public/operating-systems/Tizen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/operating-systems/Tizen.png
--------------------------------------------------------------------------------
/docs/public/operating-systems/Ubuntu.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/docs/public/operating-systems/Windows.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/public/operating-systems/macOS.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/docs/public/platforms/angular.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
10 |
11 |
12 |
13 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/docs/public/platforms/gatsby.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/public/platforms/gtm.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/docs/public/platforms/nuxt.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/public/platforms/react.svg:
--------------------------------------------------------------------------------
1 |
2 | React Logo
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/docs/public/platforms/remix.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/platforms/remix.png
--------------------------------------------------------------------------------
/docs/public/platforms/remix.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/docs/public/platforms/vue.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/docs/public/platforms/webflow.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/docs/public/platforms/wordpress.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/public/rybbit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/rybbit.png
--------------------------------------------------------------------------------
/docs/public/settings.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/public/settings.jpg
--------------------------------------------------------------------------------
/docs/src/app/_ignored/_meta.js:
--------------------------------------------------------------------------------
1 | // This file will be NOT treated as `_meta` file, since directory starts with underscore
2 | export default {}
3 |
--------------------------------------------------------------------------------
/docs/src/app/_ignored/page.mdx:
--------------------------------------------------------------------------------
1 | This file will be NOT treated as page, since directory starts with underscore
2 |
--------------------------------------------------------------------------------
/docs/src/app/_meta.js:
--------------------------------------------------------------------------------
1 | export default {
2 | index: {
3 | display: "hidden",
4 | },
5 | docs: {
6 | type: "page",
7 | title: "Documentation",
8 | },
9 | pricing: {
10 | type: "page",
11 | title: "Pricing",
12 | },
13 | blog: {
14 | type: "page",
15 | title: "Blog",
16 | },
17 | };
18 |
--------------------------------------------------------------------------------
/docs/src/app/apple-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/src/app/apple-icon.png
--------------------------------------------------------------------------------
/docs/src/app/components/Country.jsx:
--------------------------------------------------------------------------------
1 | import * as CountryFlags from "country-flag-icons/react/3x2";
2 | import React from "react";
3 |
4 | const getCountryName = (countryCode) => {
5 | return CountryFlags[countryCode ]?.name;
6 | };
7 |
8 | export function CountryFlag({
9 | country,
10 | }) {
11 | return (
12 | <>
13 | {CountryFlags[country]
14 | ? React.createElement(
15 | CountryFlags[country],
16 | {
17 | title: getCountryName(country),
18 | className: "w-5",
19 | }
20 | )
21 | : null}
22 | >
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/docs/src/app/components/Logo.jsx:
--------------------------------------------------------------------------------
1 | import { Tilt_Warp } from "next/font/google";
2 | import Image from 'next/image';
3 | import { cn } from '../../lib/utils';
4 |
5 | const tilt_wrap = Tilt_Warp({
6 | subsets: ["latin"],
7 | weight: "400",
8 | });
9 |
10 | export function Logo() {
11 | return (
12 |
13 |
14 | rybbit.
15 |
16 | );
17 | }
18 |
19 | export function SmallLogo() {
20 | return (
21 |
22 |
23 | rybbit.
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/docs/src/app/components/OperatingSystem.jsx:
--------------------------------------------------------------------------------
1 | import { Compass } from "lucide-react";
2 | import Image from "next/image";
3 |
4 | const OS_TO_LOGO= {
5 | Windows: "Windows.svg",
6 | Android: "Android.svg",
7 | android: "Android.svg",
8 | Linux: "Tux.svg",
9 | macOS: "macOS.svg",
10 | iOS: "Apple.svg",
11 | "Chrome OS": "Chrome.svg",
12 | Ubuntu: "Ubuntu.svg",
13 | HarmonyOS: "HarmonyOS.svg",
14 | OpenHarmony: "OpenHarmony.png",
15 | PlayStation: "PlayStation.svg",
16 | Tizen: "Tizen.png",
17 | };
18 |
19 | export function OperatingSystem({ os = "" }) {
20 | return (
21 | <>
22 | {OS_TO_LOGO[os] ? (
23 |
30 | ) : (
31 |
32 | )}
33 | >
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/docs/src/app/docs/[[...mdxPath]]/page.jsx:
--------------------------------------------------------------------------------
1 | import { generateStaticParamsFor, importPage } from 'nextra/pages'
2 | import { useMDXComponents as getMDXComponents } from '../../../../mdx-components'
3 |
4 | export const generateStaticParams = generateStaticParamsFor('mdxPath')
5 |
6 | export async function generateMetadata(props) {
7 | const params = await props.params
8 | const { metadata } = await importPage(params.mdxPath)
9 | return metadata
10 | }
11 |
12 | const Wrapper = getMDXComponents().wrapper
13 |
14 | export default async function Page(props) {
15 | const params = await props.params
16 | const result = await importPage(params.mdxPath)
17 | const { default: MDXContent, toc, metadata } = result
18 | return (
19 |
20 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/docs/src/app/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/src/app/icon.ico
--------------------------------------------------------------------------------
/docs/src/app/opengraph-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/docs/src/app/opengraph-image.png
--------------------------------------------------------------------------------
/docs/src/app/providers.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import posthog from "posthog-js";
4 | import { PostHogProvider as PHProvider } from "posthog-js/react";
5 | import { useEffect } from "react";
6 |
7 | export function PostHogProvider({ children }) {
8 | useEffect(() => {
9 | posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
10 | api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
11 | defaults: "2025-05-24",
12 | });
13 | }, []);
14 |
15 | return {children} ;
16 | }
17 |
--------------------------------------------------------------------------------
/docs/src/components/CodeHighlighter.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 | import { highlightCode } from "../lib/highlightCode";
5 |
6 | export default function CodeHighlighter() {
7 | useEffect(() => {
8 | highlightCode();
9 | }, []);
10 |
11 | return null; // This component doesn't render anything
12 | }
13 |
--------------------------------------------------------------------------------
/docs/src/components/magicui/animated-shiny-text.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentPropsWithoutRef, CSSProperties, FC } from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export interface AnimatedShinyTextProps
6 | extends ComponentPropsWithoutRef<"span"> {
7 | shimmerWidth?: number;
8 | }
9 |
10 | export const AnimatedShinyText: FC = ({
11 | children,
12 | className,
13 | shimmerWidth = 100,
14 | ...props
15 | }) => {
16 | return (
17 |
26 | {children}
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/docs/src/content/_meta.js:
--------------------------------------------------------------------------------
1 | export default {
2 | index: "",
3 | roadmap: "",
4 | _3: {
5 | type: "separator",
6 | title: "Tracking",
7 | },
8 | script: "",
9 | "track-events": "",
10 | sdks: "SDKs",
11 | integrations: "",
12 | "hiding-own-traffic": "",
13 | _4: {
14 | type: "separator",
15 | title: "Self-hosting",
16 | },
17 | "self-hosting": "",
18 | "self-hosting-advanced": "",
19 | "self-hosting-nginx": "",
20 | "v1-migration": "",
21 | _6: {
22 | type: "separator",
23 | title: "Settings",
24 | },
25 | "inviting-users": "",
26 | "public-site": "",
27 | "enhanced-privacy": "",
28 | "changing-domains": "",
29 | "deleting-sites": "",
30 | _7: {
31 | type: "separator",
32 | title: "Other",
33 | },
34 | definitions: "",
35 | };
36 |
--------------------------------------------------------------------------------
/docs/src/content/blog/_meta.js:
--------------------------------------------------------------------------------
1 | export default {
2 | "5k-stars":
3 | "How my open source SaaS got 5,000 Github stars in 9 days with zero marketing effort",
4 | };
5 |
--------------------------------------------------------------------------------
/docs/src/content/changing-domains.mdx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 |
4 | # Changing domains
5 |
6 | You can update the domain of your site by going to the settings page on your site's Rybbit dashboard.
7 |
8 | Changing domains will not affect any existing data.
9 |
10 | Currently we only support one domain per site.
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/docs/src/content/deleting-sites.mdx:
--------------------------------------------------------------------------------
1 |
2 | import Image from "next/image";
3 |
4 | # Deleting sites
5 |
6 | You can delete a site by going to the settings page on the site's dashboard and clicking the "Delete Site" button. This will delete all data associated with the site.
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/docs/src/content/enhanced-privacy.mdx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { Callout } from 'nextra/components'
3 |
4 | # Enhanced Privacy
5 |
6 | {/*
7 | If strict GDPR/CCPA compliance is not important to you, leave this option disabled as it makes some parts of the analytics less accurate.
8 | */}
9 |
10 | Rybbit stores user IDs as a hashed combination of IP + user agent. Doing this allows us to identify users across many sessions on the same device, but can be seen as storing personally identifiable data under GDPR.
11 |
12 | You can enable enhanced privacy by going to the settings page of your website dashboard and enabling the "User ID Salting" option.
13 |
14 | When you turn this one, Rybbit will forget about unique users every day. If a user visits your site 10 times in one day, Rybbit will count that as one user with 10 sessions. But if he visits once a day for 10 days, he will be seen as a new user every day.
15 |
16 |
17 |
18 |
19 | ### Downsides of enabling user ID salting:
20 |
21 | - You will no longer see user session history beyond 24 hours.
22 | - Unique user count over a period longer than 24 hours will be inflated.
23 | - The user retention page will effectively be useless.
24 |
25 |
26 |
--------------------------------------------------------------------------------
/docs/src/content/hiding-own-traffic.mdx:
--------------------------------------------------------------------------------
1 | import { Steps } from 'nextra/components'
2 |
3 | # Hiding Your Own Traffic
4 |
5 | You can disable Rybbit tracking in your browser using the localStorage API.
6 |
7 |
8 |
9 | ### 1. Open your browser's developer console
10 |
11 | Press **F12** or right-click the page and select "Inspect"
12 |
13 | Navigate to the "Console" tab
14 |
15 | ### 2. Disable tracking
16 |
17 | Enter this command to disable tracking:
18 |
19 | ```js
20 | localStorage.setItem('disable-rybbit', 'true');
21 | ```
22 |
23 | ### 3. Re-enable tracking (when needed)
24 |
25 | If you want to re-enable tracking later, enter:
26 |
27 | ```js
28 | localStorage.removeItem('disable-rybbit');
29 | ```
30 |
31 |
32 |
33 | This method is useful for developers testing the site or site owners who don't want to track their own visits.
34 |
35 | Once disabled, the Rybbit script will not send any tracking data while preserving the API functions to prevent JavaScript errors.
36 |
--------------------------------------------------------------------------------
/docs/src/content/index.mdx:
--------------------------------------------------------------------------------
1 | import { Bleed } from "nextra/components";
2 |
3 | # Introduction
4 |
5 | **Rybbit** is a powerful open source web and products analytics platform. Check out our [live demo](https://demo.rybbit.io/1) using a real production site that sends over **15,000,000** events a month.
6 |
7 | ## Key Features
8 |
9 | - Extremely comprehensive prebuilt web analytics dashboards
10 | - Cookieless and privacy-friendly
11 | - Ability to make your dashboard public
12 | - Support for organizations
13 | - Cool visualizations
14 | - Advanced product analytics like funnels, user retention, user journeys, and custom reports
15 | - Quick setup on any website within a few minutes
16 |
17 | ## Getting Started
18 |
19 | Choose the option that's right for you:
20 |
21 | - **Cloud Version:** Sign up for the hosted version at [rybbit.io/signup](https://app.rybbit.io/signup). Get up and running in minutes.
22 | - **Self-Host:** Follow our [Self-Hosting Guide](./docs/self-hosting) to set up Rybbit on your own server.
23 |
24 | And then learn how to add the [Tracking Script](./docs/script) to your website to start collecting data.
25 |
26 | ## Code
27 |
28 | Rybbit is open sourced under the AGPL-3.0 license. You can find the code on [GitHub](https://github.com/rybbit-io/rybbit).
29 |
30 | ## Community
31 |
32 | Join our [Discord server](https://discord.gg/DEhGb4hYBj) or talk to me on [X](https://x.com/yang_frog). I'm always looking for feedback and ideas to make Rybbit as awesome as possible.
33 |
--------------------------------------------------------------------------------
/docs/src/content/integrations/framer.mdx:
--------------------------------------------------------------------------------
1 | import { Steps } from "nextra/components"
2 |
3 | # Framer
4 |
5 | Rybbit enables you to gather analytics on user behavior, capture custom events, record sessions, and more within your Framer site.
6 |
7 | ## How to Add Rybbit to Framer
8 |
9 |
10 | ### 1. Retrieve Your Tracking Script
11 |
12 | Navigate to your Rybbit dashboard to obtain your code snippet.
13 |
14 | ```html
15 |
20 | ```
21 |
22 | ### 2. Add the Snippet to Your Framer Project
23 |
24 | - Open your Framer project.
25 | - Go to **Site Settings > General** and scroll down to **Custom Code**.
26 | - Paste your snippet into the `` tag section (start or end).
27 | - Publish your changes.
28 |
29 |
--------------------------------------------------------------------------------
/docs/src/content/integrations/shopify.mdx:
--------------------------------------------------------------------------------
1 | import { Steps } from "nextra/components"
2 |
3 | # Shopify
4 |
5 | Rybbit can be integrated with your Shopify store to capture events, track conversions, and analyze user behavior.
6 |
7 | ## How to Add Rybbit to Shopify
8 |
9 |
10 | ### 1. Retrieve Your Tracking Script
11 |
12 | Navigate to your Rybbit dashboard to obtain your code snippet.
13 |
14 | ```html
15 |
20 | ```
21 |
22 | ### 2. Add the Snippet to Your Shopify Store
23 |
24 | - In Shopify, go to **Online Store > Themes** and click **Edit code** from the settings dropdown beside the **Customize** button.
25 | - Open `theme.liquid` in the **Layout** folder.
26 | - Just before the closing `` tag, insert your snippet.
27 | - Save the file to apply the changes to your live store.
28 |
29 |
--------------------------------------------------------------------------------
/docs/src/content/integrations/webflow.mdx:
--------------------------------------------------------------------------------
1 | import { Steps } from "nextra/components"
2 |
3 | # Webflow
4 |
5 | Rybbit enables you to collect analytics, capture custom events, and more on your Webflow site.
6 |
7 | ## How to Add Rybbit to Webflow
8 |
9 |
10 | ### 1. Retrieve Your Tracking Script
11 |
12 | Navigate to your Rybbit dashboard to obtain your code snippet.
13 |
14 | ```html
15 |
20 | ```
21 |
22 | ### 2. Add the Snippet to Your Webflow Project
23 |
24 | - In your Webflow project, go to **Site settings > Custom code**.
25 | - Paste your snippet into the **Head code** section.
26 | - Save and publish your changes.
27 |
28 |
--------------------------------------------------------------------------------
/docs/src/content/inviting-users.mdx:
--------------------------------------------------------------------------------
1 | import { Steps } from 'nextra/components'
2 | import { Callout } from 'nextra/components'
3 |
4 | # Inviting Users
5 |
6 | Currently only the cloud version of Rybbit supports inviting users.
7 |
8 |
9 |
10 | ### 1. Add the user to your organization
11 |
12 | Go to https://app.rybbit.io/organization "Add Member" button in the top right. Enter the user's email address and select the user's role.
13 |
14 | ### 2. Select the user's role
15 |
16 | You can either make the user an admin or a member.
17 |
18 | **Admins** can invite other users, add websites, edit websites, delete websites, and see all website data.
19 |
20 | **Members** can see all website data, but they cannot edit anything.
21 |
22 | ### 3. Send the invite
23 |
24 | Click the "Invite" button. The user will receive an email with a link to sign up for Rybbit. You can manage all invites in the organizations tab.
25 |
26 |
27 |
28 |
29 | Currently, each user can only be in one organization.
30 |
31 |
32 |
--------------------------------------------------------------------------------
/docs/src/content/public-site.mdx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | # Make your analytics public
4 |
5 | You can make your analytics public by going to the settings page and enabling the "Public Analytics" option. You can then share the page link with anyone you want.
6 |
7 | - External viewers will not be able to edit any of your settings or add/remove/edit any existing reports, funnels, or goals.
8 | - Your other websites will not be affected.
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/docs/src/content/roadmap.mdx:
--------------------------------------------------------------------------------
1 | # Roadmap
2 |
3 | Rybbit is a quickly growing project. We already have quite a few features, but there is much more to come.
4 |
5 | ## Planned features
6 |
7 | - Data export
8 | - Data import from Google Analytics, Plausible, Umami, PostHog, and more
9 | - Custom reports/dashboards
10 | - Stats API
11 | - More tracking script/SDK integration guides
12 | - Better support for server-side event tracking
13 | - Mobile app support
14 | - Custom themes
15 | - Revenue tracking
16 | - Web vitals tracking
17 | - Email reports
18 | - Traffic filters/bot blocking
19 | - Traffic alerts
20 |
21 |
22 | Once again, join our [Discord server](https://discord.gg/DEhGb4hYBj) or follow me on [X](https://x.com/yang_frog) to share your feedback and ideas. We would love to hear from you!
23 |
--------------------------------------------------------------------------------
/docs/src/lib/blog.js:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import path from "path";
3 | import matter from "gray-matter";
4 |
5 | const BLOG_DIR = path.join(process.cwd(), "src/content/blog");
6 |
7 | export async function getAllPosts() {
8 | const files = fs.readdirSync(BLOG_DIR);
9 |
10 | const posts = files
11 | .filter((file) => file.endsWith(".mdx") && !file.startsWith("_"))
12 | .map((file) => {
13 | const slug = file.replace(/\.mdx$/, "");
14 | const filePath = path.join(BLOG_DIR, file);
15 | const fileContents = fs.readFileSync(filePath, "utf8");
16 | const { data } = matter(fileContents);
17 |
18 | return {
19 | slug,
20 | frontMatter: {
21 | ...data,
22 | date: data.date ? new Date(data.date).toISOString() : null,
23 | },
24 | };
25 | })
26 | // Sort by date (newest first)
27 | .sort(
28 | (a, b) => new Date(b.frontMatter.date) - new Date(a.frontMatter.date)
29 | );
30 |
31 | return posts;
32 | }
33 |
34 | export async function getPostBySlug(slug) {
35 | const filePath = path.join(BLOG_DIR, `${slug}.mdx`);
36 | const fileContents = fs.readFileSync(filePath, "utf8");
37 | const { data, content } = matter(fileContents);
38 |
39 | return {
40 | frontMatter: {
41 | ...data,
42 | date: data.date ? new Date(data.date).toISOString() : null,
43 | },
44 | content,
45 | slug,
46 | };
47 | }
48 |
--------------------------------------------------------------------------------
/docs/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/docs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "noEmit": true,
13 | "esModuleInterop": true,
14 | "module": "esnext",
15 | "moduleResolution": "bundler",
16 | "resolveJsonModule": true,
17 | "isolatedModules": true,
18 | "jsx": "preserve",
19 | "incremental": true,
20 | "plugins": [
21 | {
22 | "name": "next"
23 | }
24 | ],
25 | "paths": {
26 | "@/*": [
27 | "./src/*"
28 | ]
29 | }
30 | },
31 | "include": [
32 | "next-env.d.ts",
33 | "**/*.ts",
34 | "**/*.tsx",
35 | ".next/types/**/*.ts",
36 | "src/app/providers.jsx"
37 | ],
38 | "exclude": [
39 | "node_modules"
40 | ]
41 | }
--------------------------------------------------------------------------------
/memory-bank/activeContext.md:
--------------------------------------------------------------------------------
1 | # Active Context
2 |
3 | This file tracks the project's current status, including recent changes, current goals, and open questions.
4 |
5 | ## Current Focus
6 |
7 | - Memory Bank initialization and project context establishment
8 | - Understanding the current state of the Rybbit Analytics platform
9 | - Preparing for future development tasks and architectural decisions
10 |
11 | ## Recent Changes
12 |
13 | 2025-05-31 13:49:39 - Memory Bank system initialized for Rybbit Analytics project
14 |
15 | ## Open Questions/Issues
16 |
17 | - What specific development tasks or improvements are currently prioritized?
18 | - Are there any known technical debt items or performance issues to address?
19 | - What new features or enhancements are planned for the platform?
20 | - Are there any deployment or infrastructure concerns that need attention?
21 |
--------------------------------------------------------------------------------
/memory-bank/decisionLog.md:
--------------------------------------------------------------------------------
1 | # Decision Log
2 |
3 | This file records architectural and implementation decisions using a list format.
4 |
5 | ## Decision
6 |
7 | 2025-05-31 13:49:52 - Memory Bank Architecture Implementation
8 |
9 | ## Rationale
10 |
11 | Implemented a comprehensive Memory Bank system to maintain project context across different modes and sessions. This decision was made to:
12 |
13 | - Ensure continuity of project understanding across different development sessions
14 | - Provide a centralized location for tracking architectural decisions and progress
15 | - Enable better collaboration and knowledge transfer
16 | - Support the complex, multi-component architecture of Rybbit Analytics
17 |
18 | ## Implementation Details
19 |
20 | - Created five core Memory Bank files: productContext.md, activeContext.md, progress.md, decisionLog.md, and systemPatterns.md
21 | - Established initial project context based on projectBrief.md
22 | - Set up tracking mechanisms for ongoing development activities
23 | - Prepared framework for documenting future architectural decisions and patterns
24 |
--------------------------------------------------------------------------------
/memory-bank/systemPatterns.md:
--------------------------------------------------------------------------------
1 | # System Patterns
2 |
3 | This file documents recurring patterns and standards used in the project.
4 | It is optional, but recommended to be updated as the project evolves.
5 |
6 | ## Coding Patterns
7 |
8 | **Frontend Patterns:**
9 |
10 | - Next.js App Router for routing and page structure
11 | - TypeScript throughout for type safety
12 | - Tailwind CSS for utility-first styling
13 | - Shadcn components for consistent UI elements
14 | - Tanstack Query for server state management
15 | - Zustand for client state management
16 | - Luxon for date/time operations
17 |
18 | **Backend Patterns:**
19 |
20 | - Fastify for high-performance HTTP server
21 | - Drizzle ORM for type-safe database operations
22 | - TypeScript for consistent typing across frontend and backend
23 | - API route organization under `/api` directory structure
24 |
25 | ## Architectural Patterns
26 |
27 | **Data Architecture:**
28 |
29 | - PostgreSQL for relational data (users, organizations, sites, configurations)
30 | - ClickHouse for high-volume analytics events and time-series data
31 | - Clear separation between operational and analytical data stores
32 |
33 | **Service Architecture:**
34 |
35 | - Microservice-oriented with clear separation of concerns
36 | - Docker containerization for consistent deployment
37 | - Environment-based configuration management
38 |
39 | **Authentication & Authorization:**
40 |
41 | - Organization-based multi-tenancy
42 | - Role-based access control
43 | - Stripe integration for subscription management
44 |
45 | ## Testing Patterns
46 |
47 | 2025-05-31 13:49:59 - Initial system patterns documented based on project structure analysis
48 |
--------------------------------------------------------------------------------
/mockdata/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mockdata",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "scripts": {
6 | "generate": "node index.js",
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "author": "",
10 | "license": "ISC",
11 | "description": "",
12 | "dependencies": {
13 | "@clickhouse/client": "1.11.1",
14 | "@faker-js/faker": "9.7.0",
15 | "dotenv": "16.5.0",
16 | "luxon": "3.6.1"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/projectBrief.md:
--------------------------------------------------------------------------------
1 | # Rybbit Analytics
2 |
3 | Rybbit is an open source web analytics platform that is meant to be a more intuitive but still extremely powerful alternative to Google Analytics. It launched in May 2025 and has 6k Github stars.
4 |
5 | See README.md for a general overview and docs/src/content for more info
6 |
7 | - docker-compose.yml is the docker setup for Rybbit
8 | - frontend lives in /client and uses Next.js, Tailwind, Shadcn, Nivo.rocks, Tanstack Query, Tanstack Table, Zustand and Luxon
9 | - backend lives in /server and uses Fastify, Luxon, Drizzle, and Stripe
10 | - documentation and landing page live in /docs and uses Nextra
11 | - we use Postgres for all relational data and Clickhouse for events data
12 |
--------------------------------------------------------------------------------
/restart.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Exit immediately if a command exits with a non-zero status.
4 | set -e
5 |
6 | echo "Restarting services..."
7 |
8 | # Stop all services
9 | docker compose down
10 |
11 | # Check if .env file exists
12 | if [ ! -f .env ]; then
13 | echo "Error: .env file not found. Please run setup.sh first."
14 | echo "Usage: ./setup.sh "
15 | exit 1
16 | fi
17 |
18 | # Load environment variables
19 | source .env
20 |
21 | # Start the appropriate services with updated environment variables
22 | if [ "$USE_WEBSERVER" = "false" ]; then
23 | # Start without the caddy service when using --no-webserver
24 | docker compose up -d backend client clickhouse postgres
25 | else
26 | # Start all services including caddy
27 | docker compose up -d
28 | fi
29 |
30 | echo "Services restarted. You can monitor logs with: docker compose logs -f"
--------------------------------------------------------------------------------
/server/.dockerignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | node_modules/
3 | npm-debug.log
4 | yarn-debug.log
5 | yarn-error.log
6 | .pnpm-debug.log
7 |
8 | # Build outputs
9 | dist/
10 | build/
11 |
12 | # Environment and config
13 | .env
14 | .env.local
15 | .env.*.local
16 |
17 | # IDE and editor files
18 | .idea/
19 | .vscode/
20 | *.swp
21 | *.swo
22 |
23 | # System files
24 | .DS_Store
25 | Thumbs.db
--------------------------------------------------------------------------------
/server/Dockerfile:
--------------------------------------------------------------------------------
1 | # syntax=docker/dockerfile:1
2 |
3 | FROM node:20-alpine AS builder
4 |
5 | WORKDIR /app
6 |
7 | # Install dependencies
8 | COPY package*.json ./
9 | RUN npm ci
10 |
11 | # Copy source code
12 | COPY . .
13 |
14 | # Build the application
15 | RUN npm run build
16 |
17 | # Runtime image
18 | FROM node:20-alpine
19 |
20 | WORKDIR /app
21 |
22 | # Install PostgreSQL client for migrations
23 | RUN apk add --no-cache postgresql-client
24 |
25 | # Copy built application and dependencies
26 | COPY --from=builder /app/package*.json ./
27 | COPY --from=builder /app/GeoLite2-City.mmdb ./GeoLite2-City.mmdb
28 | COPY --from=builder /app/dist ./dist
29 | COPY --from=builder /app/node_modules ./node_modules
30 | COPY --from=builder /app/docker-entrypoint.sh /docker-entrypoint.sh
31 | COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
32 | COPY --from=builder /app/public ./public
33 | COPY --from=builder /app/src ./src
34 |
35 | # Make the entrypoint executable
36 | RUN chmod +x /docker-entrypoint.sh
37 |
38 | # Expose the API port
39 | EXPOSE 3001
40 |
41 | # Use our custom entrypoint script
42 | ENTRYPOINT ["/docker-entrypoint.sh"]
43 | CMD ["node", "dist/index.js"]
--------------------------------------------------------------------------------
/server/GeoLite2-City.mmdb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/server/GeoLite2-City.mmdb
--------------------------------------------------------------------------------
/server/docker-entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | # Run migrations explicitly using the npm script, forcing changes
5 | echo "Running database migrations..."
6 | npm run db:push -- --force
7 |
8 | # Start the application
9 | echo "Starting application..."
10 | exec "$@"
--------------------------------------------------------------------------------
/server/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import dotenv from "dotenv";
2 | import { defineConfig } from "drizzle-kit";
3 |
4 | dotenv.config();
5 |
6 | export default defineConfig({
7 | schema: "./src/db/postgres/schema.ts",
8 | out: "./drizzle",
9 | dialect: "postgresql",
10 | dbCredentials: {
11 | host: process.env.POSTGRES_HOST || "postgres",
12 | port: process.env.POSTGRES_PORT || 5432,
13 | database: process.env.POSTGRES_DB || "analytics",
14 | user: process.env.POSTGRES_USER || "frog",
15 | password: process.env.POSTGRES_PASSWORD || "frog",
16 | ssl: false,
17 | },
18 | verbose: true,
19 | });
20 |
--------------------------------------------------------------------------------
/server/src/api/analytics/deleteFunnel.ts:
--------------------------------------------------------------------------------
1 | import { eq } from "drizzle-orm";
2 | import { FastifyReply, FastifyRequest } from "fastify";
3 | import { db } from "../../db/postgres/postgres.js";
4 | import { funnels as funnelsTable } from "../../db/postgres/schema.js";
5 | import { getUserHasAccessToSite } from "../../lib/auth-utils.js";
6 |
7 | export async function deleteFunnel(
8 | request: FastifyRequest<{
9 | Params: {
10 | funnelId: string;
11 | };
12 | }>,
13 | reply: FastifyReply
14 | ) {
15 | const { funnelId } = request.params;
16 |
17 | try {
18 | // First get the funnel to check ownership
19 | const funnel = await db.query.funnels.findFirst({
20 | where: eq(funnelsTable.reportId, parseInt(funnelId)),
21 | });
22 |
23 | if (!funnel) {
24 | return reply.status(404).send({ error: "Funnel not found" });
25 | }
26 |
27 | if (!funnel.siteId) {
28 | return reply
29 | .status(400)
30 | .send({ error: "Invalid funnel: missing site ID" });
31 | }
32 |
33 | // Check user access to site
34 | const userHasAccessToSite = await getUserHasAccessToSite(
35 | request,
36 | funnel.siteId.toString()
37 | );
38 | if (!userHasAccessToSite) {
39 | return reply.status(403).send({ error: "Forbidden" });
40 | }
41 |
42 | // Delete the funnel
43 | await db
44 | .delete(funnelsTable)
45 | .where(eq(funnelsTable.reportId, parseInt(funnelId)));
46 |
47 | return reply.status(200).send({ success: true });
48 | } catch (error) {
49 | console.error("Error deleting funnel:", error);
50 | return reply.status(500).send({ error: "Failed to delete funnel" });
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/server/src/api/analytics/deleteGoal.ts:
--------------------------------------------------------------------------------
1 | import { FastifyReply, FastifyRequest } from "fastify";
2 | import { db } from "../../db/postgres/postgres.js";
3 | import { goals } from "../../db/postgres/schema.js";
4 | import { getUserHasAccessToSite } from "../../lib/auth-utils.js";
5 | import { eq } from "drizzle-orm";
6 |
7 | export async function deleteGoal(
8 | request: FastifyRequest<{
9 | Params: {
10 | goalId: string;
11 | };
12 | }>,
13 | reply: FastifyReply
14 | ) {
15 | const { goalId } = request.params;
16 |
17 | try {
18 | // Get the goal to check the site ID
19 | const goalToDelete = await db.query.goals.findFirst({
20 | where: eq(goals.goalId, parseInt(goalId, 10)),
21 | });
22 |
23 | if (!goalToDelete) {
24 | return reply.status(404).send({ error: "Goal not found" });
25 | }
26 |
27 | // Check user access to the site
28 | const userHasAccessToSite = await getUserHasAccessToSite(
29 | request,
30 | goalToDelete.siteId.toString()
31 | );
32 |
33 | if (!userHasAccessToSite) {
34 | return reply.status(403).send({ error: "Forbidden" });
35 | }
36 |
37 | // Delete the goal
38 | const result = await db
39 | .delete(goals)
40 | .where(eq(goals.goalId, parseInt(goalId, 10)))
41 | .returning({ deleted: goals.goalId });
42 |
43 | if (!result || result.length === 0) {
44 | return reply.status(500).send({ error: "Failed to delete goal" });
45 | }
46 |
47 | return reply.send({ success: true });
48 | } catch (error) {
49 | console.error("Error deleting goal:", error);
50 | return reply.status(500).send({ error: "Failed to delete goal" });
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/server/src/api/analytics/getLiveSessionLocations.ts:
--------------------------------------------------------------------------------
1 | import { FastifyRequest, FastifyReply } from "fastify";
2 | import clickhouse from "../../db/clickhouse/clickhouse.js";
3 | import { processResults } from "./utils.js";
4 | import SqlString from "sqlstring";
5 |
6 | export async function getLiveSessionLocations(
7 | req: FastifyRequest<{
8 | Params: {
9 | site: string;
10 | };
11 | Querystring: {
12 | time: number;
13 | };
14 | }>,
15 | res: FastifyReply
16 | ) {
17 | const { site } = req.params;
18 | if (isNaN(Number(req.query.time))) {
19 | return res.status(400).send({ error: "Invalid time" });
20 | }
21 |
22 | const result = await clickhouse.query({
23 | query: `
24 | WITH stuff AS (
25 | SELECT
26 | session_id,
27 | any(lat) AS lat,
28 | any(lon) AS lon,
29 | any(city) AS city
30 | FROM
31 | events
32 | WHERE
33 | site_id = {site:Int32}
34 | AND timestamp > now() - interval ${SqlString.escape(
35 | Number(req.query.time)
36 | )} minute
37 | GROUP BY
38 | session_id
39 | )
40 | SELECT
41 | lat,
42 | lon,
43 | city,
44 | count() as count
45 | from
46 | stuff
47 | GROUP BY
48 | lat,
49 | lon,
50 | city`,
51 | query_params: {
52 | site,
53 | },
54 | format: "JSONEachRow",
55 | });
56 |
57 | const data = await processResults<{
58 | lat: number;
59 | lon: number;
60 | count: number;
61 | city: string;
62 | }>(result);
63 |
64 | return res.status(200).send({ data });
65 | }
66 |
--------------------------------------------------------------------------------
/server/src/api/analytics/getLiveUsercount.ts:
--------------------------------------------------------------------------------
1 | import { FastifyReply, FastifyRequest } from "fastify";
2 | import clickhouse from "../../db/clickhouse/clickhouse.js";
3 | import { getUserHasAccessToSitePublic } from "../../lib/auth-utils.js";
4 | import { processResults } from "./utils.js";
5 |
6 | export const getLiveUsercount = async (
7 | req: FastifyRequest<{
8 | Params: { site: string };
9 | Querystring: { minutes: number };
10 | }>,
11 | res: FastifyReply
12 | ) => {
13 | const { site } = req.params;
14 | const { minutes } = req.query;
15 | const userHasAccessToSite = await getUserHasAccessToSitePublic(req, site);
16 | if (!userHasAccessToSite) {
17 | return res.status(403).send({ error: "Forbidden" });
18 | }
19 |
20 | const query = await clickhouse.query({
21 | query: `SELECT COUNT(DISTINCT(session_id)) AS count FROM events WHERE timestamp > now() - interval {minutes:Int32} minute AND site_id = {siteId:Int32}`,
22 | format: "JSONEachRow",
23 | query_params: {
24 | siteId: Number(site),
25 | minutes: Number(minutes || 5),
26 | },
27 | });
28 |
29 | const result = await processResults<{ count: number }>(query);
30 |
31 | return res.send({ count: result[0].count });
32 | };
33 |
--------------------------------------------------------------------------------
/server/src/api/getConfig.ts:
--------------------------------------------------------------------------------
1 | import { FastifyRequest, FastifyReply } from "fastify";
2 | import { DISABLE_SIGNUP } from "../lib/const.js";
3 |
4 | export async function getConfig(_: FastifyRequest, reply: FastifyReply) {
5 | return reply.send({
6 | disableSignup: DISABLE_SIGNUP,
7 | });
8 | }
9 |
--------------------------------------------------------------------------------
/server/src/api/sites/deleteSite.ts:
--------------------------------------------------------------------------------
1 | import { eq } from "drizzle-orm";
2 | import { FastifyReply, FastifyRequest } from "fastify";
3 | import { clickhouse } from "../../db/clickhouse/clickhouse.js";
4 | import { db } from "../../db/postgres/postgres.js";
5 | import { sites } from "../../db/postgres/schema.js";
6 | import { loadAllowedDomains } from "../../lib/allowedDomains.js";
7 | import { getUserHasAdminAccessToSite } from "../../lib/auth-utils.js";
8 | import { siteConfig } from "../../lib/siteConfig.js";
9 |
10 | export async function deleteSite(
11 | request: FastifyRequest<{ Params: { id: string } }>,
12 | reply: FastifyReply
13 | ) {
14 | const { id } = request.params;
15 |
16 | const userHasAdminAccessToSite = await getUserHasAdminAccessToSite(
17 | request,
18 | id
19 | );
20 | if (!userHasAdminAccessToSite) {
21 | return reply.status(403).send({ error: "Forbidden" });
22 | }
23 |
24 | await db.delete(sites).where(eq(sites.siteId, Number(id)));
25 | await clickhouse.command({
26 | query: `DELETE FROM events WHERE site_id = ${id}`,
27 | });
28 | await loadAllowedDomains();
29 |
30 | // Remove the site from the siteConfig cache
31 | siteConfig.removeSite(Number(id));
32 |
33 | return reply.status(200).send({ success: true });
34 | }
35 |
--------------------------------------------------------------------------------
/server/src/api/sites/getSiteHasData.ts:
--------------------------------------------------------------------------------
1 | import { FastifyReply, FastifyRequest } from "fastify";
2 | import clickhouse from "../../db/clickhouse/clickhouse.js";
3 |
4 | export async function getSiteHasData(
5 | request: FastifyRequest<{ Params: { site: string } }>,
6 | reply: FastifyReply
7 | ) {
8 | const { site } = request.params;
9 |
10 | try {
11 | // Check if site has data using original method
12 | const pageviewsData: { count: number }[] = await clickhouse
13 | .query({
14 | query: `SELECT count(*) as count FROM events WHERE site_id = {siteId:Int32}`,
15 | format: "JSONEachRow",
16 | query_params: {
17 | siteId: Number(site),
18 | },
19 | })
20 | .then((res) => res.json());
21 |
22 | const hasData = pageviewsData[0].count > 0;
23 | return {
24 | hasData,
25 | };
26 | } catch (error) {
27 | console.error("Error checking if site has data:", error);
28 | return reply.status(500).send({ error: "Internal server error" });
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/server/src/api/sites/getSiteIsPublic.ts:
--------------------------------------------------------------------------------
1 | import { FastifyRequest, FastifyReply } from "fastify";
2 | import { siteConfig } from "../../lib/siteConfig.js";
3 |
4 | export async function getSiteIsPublic(
5 | request: FastifyRequest<{ Params: { site: string } }>,
6 | reply: FastifyReply
7 | ) {
8 | const { site } = request.params;
9 | const isPublic = siteConfig.isSitePublic(site);
10 | return reply.status(200).send({ isPublic });
11 | }
12 |
--------------------------------------------------------------------------------
/server/src/api/user/getUserOrganizations.ts:
--------------------------------------------------------------------------------
1 | import { FastifyRequest, FastifyReply } from "fastify";
2 | import { db } from "../../db/postgres/postgres.js";
3 | import { eq } from "drizzle-orm";
4 | import { member, organization } from "../../db/postgres/schema.js";
5 | import { getSessionFromReq } from "../../lib/auth-utils.js";
6 |
7 | export const getUserOrganizations = async (
8 | request: FastifyRequest,
9 | reply: FastifyReply
10 | ) => {
11 | try {
12 | const session = await getSessionFromReq(request);
13 |
14 | if (!session?.user.id) {
15 | return reply.status(401).send({ error: "Unauthorized" });
16 | }
17 |
18 | const userOrganizations = await db
19 | .select({
20 | id: organization.id,
21 | name: organization.name,
22 | slug: organization.slug,
23 | logo: organization.logo,
24 | createdAt: organization.createdAt,
25 | metadata: organization.metadata,
26 | role: member.role,
27 | })
28 | .from(member)
29 | .innerJoin(organization, eq(member.organizationId, organization.id))
30 | .where(eq(member.userId, session?.user.id));
31 |
32 | return reply.send(userOrganizations);
33 | } catch (error) {
34 | console.error("Error fetching user organizations:", error);
35 | return reply.status(500).send("Failed to fetch user organizations");
36 | }
37 | };
38 |
--------------------------------------------------------------------------------
/server/src/cron/index.ts:
--------------------------------------------------------------------------------
1 | import * as cron from "node-cron";
2 | import { cleanupOldSessions } from "../db/postgres/session-cleanup.js";
3 | import { IS_CLOUD } from "../lib/const.js";
4 | import { updateUsersMonthlyUsage } from "./monthly-usage-checker.js";
5 |
6 | export async function initializeCronJobs() {
7 | console.log("Initializing cron jobs...");
8 |
9 | if (IS_CLOUD && process.env.NODE_ENV !== "development") {
10 | // Schedule the monthly usage checker to run every 5 minutes
11 | cron.schedule("*/5 * * * *", updateUsersMonthlyUsage);
12 | updateUsersMonthlyUsage();
13 | }
14 | cron.schedule("* * * * *", cleanupOldSessions);
15 |
16 | console.log("Cron jobs initialized successfully");
17 | }
18 |
--------------------------------------------------------------------------------
/server/src/db/postgres/postgres.ts:
--------------------------------------------------------------------------------
1 | import dotenv from "dotenv";
2 | import { drizzle } from "drizzle-orm/postgres-js";
3 | import postgres from "postgres";
4 | import * as schema from "./schema.js";
5 |
6 | dotenv.config();
7 |
8 | // Create postgres connection
9 | const client = postgres({
10 | host: process.env.POSTGRES_HOST || "postgres",
11 | port: parseInt(process.env.POSTGRES_PORT || "5432", 10),
12 | database: process.env.POSTGRES_DB,
13 | username: process.env.POSTGRES_USER,
14 | password: process.env.POSTGRES_PASSWORD,
15 | onnotice: () => {},
16 | max: 20,
17 | });
18 |
19 | // Create drizzle ORM instance
20 | export const db = drizzle(client, { schema });
21 |
22 | // For compatibility with raw SQL if needed
23 | export const sql = client;
24 |
--------------------------------------------------------------------------------
/server/src/lib/allowedDomains.ts:
--------------------------------------------------------------------------------
1 | import { db, sql } from "../db/postgres/postgres.js";
2 | import { sites } from "../db/postgres/schema.js";
3 | import { normalizeOrigin } from "../utils.js";
4 | import { initAuth } from "./auth.js";
5 | import dotenv from "dotenv";
6 |
7 | dotenv.config();
8 |
9 | export let allowList: string[] = [];
10 |
11 | export const loadAllowedDomains = async () => {
12 | try {
13 | // Check if the sites table exists
14 | const tableExists = await sql`
15 | SELECT EXISTS (
16 | SELECT FROM information_schema.tables
17 | WHERE table_schema = 'public'
18 | AND table_name = 'sites'
19 | );
20 | `;
21 |
22 | // Only query the sites table if it exists
23 | let domains: { domain: string }[] = [];
24 | if (tableExists[0].exists) {
25 | // Use Drizzle to get domains
26 | const sitesData = await db.select({ domain: sites.domain }).from(sites);
27 | domains = sitesData;
28 | }
29 |
30 | allowList = [
31 | "localhost",
32 | normalizeOrigin(process.env.BASE_URL || ""),
33 | ...domains.map(({ domain }) => normalizeOrigin(domain)),
34 | ];
35 | } catch (error) {
36 | console.error("Error loading allowed domains:", error);
37 | // Set default values in case of error
38 | allowList = ["localhost", normalizeOrigin(process.env.BASE_URL || "")];
39 | initAuth(allowList);
40 | }
41 | };
42 |
--------------------------------------------------------------------------------
/server/src/lib/stripe.ts:
--------------------------------------------------------------------------------
1 | import Stripe from "stripe";
2 | import dotenv from "dotenv";
3 |
4 | dotenv.config();
5 |
6 | const secretKey = process.env.STRIPE_SECRET_KEY;
7 |
8 | export const stripe = secretKey
9 | ? new Stripe(secretKey, {
10 | typescript: true, // Enable TypeScript support
11 | })
12 | : null;
13 |
--------------------------------------------------------------------------------
/server/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface TrackingPayload {
2 | site_id: string;
3 | hostname: string;
4 | pathname: string;
5 | querystring: string;
6 | timestamp: string;
7 | screenWidth: number;
8 | screenHeight: number;
9 | language: string;
10 | page_title: string;
11 | referrer: string;
12 | }
13 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "module": "NodeNext",
5 | "moduleResolution": "NodeNext",
6 | "lib": [
7 | "ES2020"
8 | ],
9 | "strict": true,
10 | "esModuleInterop": true,
11 | "skipLibCheck": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "outDir": "dist",
14 | "rootDir": "src",
15 | "resolveJsonModule": true
16 | },
17 | "include": [
18 | "src/**/*"
19 | ],
20 | "exclude": [
21 | "node_modules"
22 | ]
23 | }
--------------------------------------------------------------------------------
/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Exit immediately if a command exits with a non-zero status.
4 | set -e
5 |
6 | echo "Starting services..."
7 |
8 | # Check if .env file exists
9 | if [ ! -f .env ]; then
10 | echo "Error: .env file not found. Please run setup.sh first."
11 | echo "Usage: ./setup.sh "
12 | exit 1
13 | fi
14 |
15 | # Load environment variables
16 | source .env
17 |
18 | if [ "$USE_WEBSERVER" = "false" ]; then
19 | # Start without the caddy service when using --no-webserver
20 | docker compose start backend client clickhouse postgres
21 | else
22 | # Start all services including caddy
23 | docker compose start
24 | fi
25 |
26 | echo "Services started. You can monitor logs with: docker compose logs -f"
--------------------------------------------------------------------------------
/stop.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Exit immediately if a command exits with a non-zero status.
4 | set -e
5 |
6 | echo "Stopping services..."
7 | docker compose stop
8 |
9 | echo "Services stopped."
--------------------------------------------------------------------------------
/update.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Exit immediately if a command exits with a non-zero status.
4 | set -e
5 |
6 | echo "Updating to the latest version..."
7 |
8 | # Pull latest changes from git repository
9 | echo "Pulling latest code..."
10 | git pull
11 |
12 | # Stop running containers
13 | echo "Stopping current services..."
14 | docker compose down
15 |
16 | # Check if .env file exists
17 | if [ ! -f .env ]; then
18 | echo "Error: .env file not found. Please run setup.sh first."
19 | echo "Usage: ./setup.sh "
20 | exit 1
21 | fi
22 |
23 | # Pull latest Docker images
24 | echo "Pulling latest Docker images..."
25 | docker compose pull
26 |
27 | # Rebuild and start containers
28 | echo "Rebuilding and starting updated services..."
29 |
30 | # Load environment variables
31 | source .env
32 |
33 | if [ "$USE_WEBSERVER" = "false" ]; then
34 | # Start without the caddy service when using --no-webserver
35 | docker compose up -d backend client clickhouse postgres
36 | else
37 | # Start all services including caddy
38 | docker compose up -d
39 | fi
40 |
41 | echo "Update complete. Services are running with the latest version."
42 | echo "You can monitor logs with: docker compose logs -f"
--------------------------------------------------------------------------------