├── .env.example ├── .github ├── dependabot.yml └── workflows │ ├── docker-publish.yml │ └── issue-manager.yml ├── .gitignore ├── .prettierignore ├── Caddyfile ├── LICENSE.md ├── README.md ├── clickhouse_config ├── enable_json.xml ├── logging_rules.xml ├── network.xml └── user_logging.xml ├── client ├── .gitignore ├── Dockerfile ├── README.md ├── components.json ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public │ ├── browsers │ │ ├── 360.png │ │ ├── AVG.svg │ │ ├── Android.svg │ │ ├── Avast.png │ │ ├── Baidu.svg │ │ ├── Brave.svg │ │ ├── Chrome.svg │ │ ├── Chromium.svg │ │ ├── CocCoc.svg │ │ ├── DuckDuckGo.svg │ │ ├── Ecosia.svg │ │ ├── Edge.svg │ │ ├── Electron.svg │ │ ├── Facebook.svg │ │ ├── Firefox.svg │ │ ├── HeyTap.png │ │ ├── Huawei.svg │ │ ├── IE.svg │ │ ├── Instagram.svg │ │ ├── Iron.png │ │ ├── KAKAOTALK.svg │ │ ├── Lenovo.png │ │ ├── LibreWolf.svg │ │ ├── Line.svg │ │ ├── LinkedIn.svg │ │ ├── Miui.png │ │ ├── Naver.webp │ │ ├── Oculus.svg │ │ ├── Opera.svg │ │ ├── OperaGX.svg │ │ ├── PaleMoon.png │ │ ├── QQ.webp │ │ ├── Quark.svg │ │ ├── Safari.svg │ │ ├── SamsungInternet.svg │ │ ├── SeaMonkey.svg │ │ ├── Silk.png │ │ ├── Sleipnir.webp │ │ ├── Snapchat.svg │ │ ├── Sogou.png │ │ ├── TikTok.svg │ │ ├── Twitter.svg │ │ ├── UCBrowser.svg │ │ ├── Vivo.webp │ │ ├── Waterfox.svg │ │ ├── WeChat.svg │ │ ├── WebKit.svg │ │ ├── Whale.svg │ │ ├── Wolvic.png │ │ └── Yandex.svg │ ├── countries.geojson │ ├── countries.json │ ├── operating-systems │ │ ├── Android.svg │ │ ├── Apple.svg │ │ ├── Chrome.svg │ │ ├── Debian.svg │ │ ├── Fedora.svg │ │ ├── HarmonyOS.svg │ │ ├── Nintendo.svg │ │ ├── OpenHarmony.png │ │ ├── Playstation.svg │ │ ├── Symbian.svg │ │ ├── Tizen.png │ │ ├── Tux.svg │ │ ├── Ubuntu.svg │ │ ├── Windows.svg │ │ ├── Xbox.svg │ │ └── macOS.svg │ ├── rybbit.png │ └── subdivisions.json ├── src │ ├── api │ │ ├── admin │ │ │ ├── auth.ts │ │ │ ├── getAdminOrganizations.ts │ │ │ ├── getAdminSites.ts │ │ │ ├── organizations.ts │ │ │ └── sites.ts │ │ ├── analytics │ │ │ ├── useCreateGoal.ts │ │ │ ├── useDeleteFunnel.ts │ │ │ ├── useDeleteGoal.ts │ │ │ ├── useGetEventNames.ts │ │ │ ├── useGetEventProperties.ts │ │ │ ├── useGetEvents.ts │ │ │ ├── useGetFunnel.ts │ │ │ ├── useGetFunnels.ts │ │ │ ├── useGetGoal.ts │ │ │ ├── useGetGoals.ts │ │ │ ├── useGetLiveSessionLocations.ts │ │ │ ├── useGetOrgEventCount.ts │ │ │ ├── useGetOverview.ts │ │ │ ├── useGetOverviewBucketed.ts │ │ │ ├── useGetOverviewBucketedWithInView.ts │ │ │ ├── useGetOverviewWithInView.ts │ │ │ ├── useGetPageTitles.ts │ │ │ ├── useGetPerformanceByDimension.ts │ │ │ ├── useGetPerformanceByPath.ts │ │ │ ├── useGetPerformanceOverview.ts │ │ │ ├── useGetPerformanceTimeSeries.ts │ │ │ ├── useGetRetention.ts │ │ │ ├── useInfiniteSingleCol.ts │ │ │ ├── useJourneys.ts │ │ │ ├── useLiveUserCount.ts │ │ │ ├── usePaginatedSingleCol.ts │ │ │ ├── useSingleCol.ts │ │ │ ├── useUpdateGoal.ts │ │ │ ├── userInfo.ts │ │ │ ├── userSessions.ts │ │ │ ├── users.ts │ │ │ └── utils.ts │ │ ├── types.ts │ │ ├── usePageMetadata.ts │ │ └── utils.ts │ ├── app │ │ ├── ReactScan.tsx │ │ ├── [site] │ │ │ ├── components │ │ │ │ ├── Header │ │ │ │ │ ├── Header.tsx │ │ │ │ │ ├── NoData.tsx │ │ │ │ │ └── UsageBanners.tsx │ │ │ │ ├── Sidebar │ │ │ │ │ ├── LiveUserCount.tsx │ │ │ │ │ ├── Sidebar.tsx │ │ │ │ │ └── SiteSelector.tsx │ │ │ │ ├── SubHeader │ │ │ │ │ ├── Filters │ │ │ │ │ │ ├── Filters.tsx │ │ │ │ │ │ └── NewFilterButton.tsx │ │ │ │ │ └── SubHeader.tsx │ │ │ │ └── shared │ │ │ │ │ ├── Filters │ │ │ │ │ ├── FilterComponent.tsx │ │ │ │ │ ├── ValueSelect.tsx │ │ │ │ │ ├── const.tsx │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── Map.tsx │ │ │ │ │ ├── StandardSection │ │ │ │ │ ├── Row.tsx │ │ │ │ │ ├── Skeleton.tsx │ │ │ │ │ ├── StandardSection.tsx │ │ │ │ │ └── StandardSectionDialog.tsx │ │ │ │ │ └── icons │ │ │ │ │ ├── Browser.tsx │ │ │ │ │ ├── CountryFlag.tsx │ │ │ │ │ └── OperatingSystem.tsx │ │ │ ├── events │ │ │ │ ├── components │ │ │ │ │ ├── EventList.tsx │ │ │ │ │ ├── EventLog.tsx │ │ │ │ │ ├── EventLogItem.tsx │ │ │ │ │ └── EventProperties.tsx │ │ │ │ └── page.tsx │ │ │ ├── funnels │ │ │ │ ├── components │ │ │ │ │ ├── CreateFunnel.tsx │ │ │ │ │ ├── EditFunnel.tsx │ │ │ │ │ ├── Funnel.tsx │ │ │ │ │ ├── FunnelForm.tsx │ │ │ │ │ └── FunnelRow.tsx │ │ │ │ └── page.tsx │ │ │ ├── goals │ │ │ │ ├── components │ │ │ │ │ ├── CreateGoalButton.tsx │ │ │ │ │ ├── GoalCard.tsx │ │ │ │ │ ├── GoalFormModal.tsx │ │ │ │ │ └── GoalsList.tsx │ │ │ │ └── page.tsx │ │ │ ├── journeys │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── main │ │ │ │ ├── components │ │ │ │ │ ├── MainSection │ │ │ │ │ │ ├── BucketSelection.tsx │ │ │ │ │ │ ├── Chart.tsx │ │ │ │ │ │ ├── MainSection.tsx │ │ │ │ │ │ ├── Overview.tsx │ │ │ │ │ │ ├── PreviousChart.tsx │ │ │ │ │ │ └── SparklinesChart.tsx │ │ │ │ │ └── sections │ │ │ │ │ │ ├── Countries.tsx │ │ │ │ │ │ ├── Devices.tsx │ │ │ │ │ │ ├── Events.tsx │ │ │ │ │ │ ├── Pages.tsx │ │ │ │ │ │ ├── Referrers.tsx │ │ │ │ │ │ └── Weekdays.tsx │ │ │ │ └── page.tsx │ │ │ ├── map │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ ├── pages │ │ │ │ ├── components │ │ │ │ │ ├── PageListItem.tsx │ │ │ │ │ ├── PageListSkeleton.tsx │ │ │ │ │ └── PageSparklineChart.tsx │ │ │ │ └── page.tsx │ │ │ ├── performance │ │ │ │ ├── components │ │ │ │ │ ├── PercentileSelector.tsx │ │ │ │ │ ├── PerformanceByDimensions.tsx │ │ │ │ │ ├── PerformanceChart.tsx │ │ │ │ │ ├── PerformanceMap.tsx │ │ │ │ │ ├── PerformanceOverview.tsx │ │ │ │ │ ├── PerformanceTable.tsx │ │ │ │ │ └── shared │ │ │ │ │ │ └── MetricTooltip.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── performanceStore.ts │ │ │ │ └── utils │ │ │ │ │ └── performanceUtils.ts │ │ │ ├── realtime │ │ │ │ ├── RealtimeChart │ │ │ │ │ └── RealtimeChart.tsx │ │ │ │ ├── RealtimeEvents │ │ │ │ │ └── RealtimeEvents.tsx │ │ │ │ ├── RealtimeGlobe │ │ │ │ │ └── RealtimeGlobe.tsx │ │ │ │ ├── RealtimeMap │ │ │ │ │ └── RealtimeMap.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── realtimeStore.ts │ │ │ ├── reports │ │ │ │ └── page.tsx │ │ │ ├── retention │ │ │ │ ├── RetentionChart.tsx │ │ │ │ └── page.tsx │ │ │ ├── sessions │ │ │ │ └── page.tsx │ │ │ ├── user │ │ │ │ └── [userId] │ │ │ │ │ ├── components │ │ │ │ │ └── Calendar.tsx │ │ │ │ │ └── page.tsx │ │ │ └── users │ │ │ │ └── page.tsx │ │ ├── account │ │ │ ├── components │ │ │ │ ├── AccountInner.tsx │ │ │ │ ├── ChangePassword.tsx │ │ │ │ └── DeleteAccount.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ ├── components │ │ │ │ ├── organizations │ │ │ │ │ └── Organizations.tsx │ │ │ │ ├── shared │ │ │ │ │ ├── AdminLayout.tsx │ │ │ │ │ ├── AdminTablePagination.tsx │ │ │ │ │ ├── ErrorAlert.tsx │ │ │ │ │ ├── SearchInput.tsx │ │ │ │ │ └── SortableHeader.tsx │ │ │ │ ├── sites │ │ │ │ │ └── Sites.tsx │ │ │ │ └── users │ │ │ │ │ ├── UserFilters.tsx │ │ │ │ │ ├── UserTableSkeleton.tsx │ │ │ │ │ ├── Users.tsx │ │ │ │ │ └── UsersTable.tsx │ │ │ ├── hooks │ │ │ │ └── useAdminPermission.ts │ │ │ └── page.tsx │ │ ├── apple-icon.png │ │ ├── auth │ │ │ └── subscription │ │ │ │ └── success │ │ │ │ └── page.tsx │ │ ├── components │ │ │ ├── AddSite.tsx │ │ │ └── Footer.tsx │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── invitation │ │ │ ├── components │ │ │ │ ├── login.tsx │ │ │ │ └── signup.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── login │ │ │ └── page.tsx │ │ ├── organization │ │ │ ├── layout.tsx │ │ │ ├── members │ │ │ │ ├── components │ │ │ │ │ ├── DeleteOrganizationDialog.tsx │ │ │ │ │ ├── EditOrganizationDialog.tsx │ │ │ │ │ ├── Invitations.tsx │ │ │ │ │ ├── InviteMemberDialog.tsx │ │ │ │ │ └── RemoveMemberDialog.tsx │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ └── subscription │ │ │ │ └── page.tsx │ │ ├── page.tsx │ │ ├── reset-password │ │ │ └── page.tsx │ │ ├── signup │ │ │ └── page.tsx │ │ └── subscribe │ │ │ ├── components │ │ │ ├── FAQSection.tsx │ │ │ ├── PricingCard.tsx │ │ │ ├── PricingHeader.tsx │ │ │ ├── TrialUsageStats.tsx │ │ │ └── utils.ts │ │ │ └── page.tsx │ ├── components │ │ ├── AuthenticationGuard.tsx │ │ ├── BucketSelection.tsx │ │ ├── CodeSnippet.tsx │ │ ├── ConfirmationModal.tsx │ │ ├── CopyText.tsx │ │ ├── CreateOrganizationDialog.tsx │ │ ├── DateSelector │ │ │ ├── CustomDateRangePicker.tsx │ │ │ ├── DateSelector.tsx │ │ │ └── types.ts │ │ ├── DisabledOverlay.tsx │ │ ├── Favicon.tsx │ │ ├── FreePlanBanner.tsx │ │ ├── FreeTrialBanner.tsx │ │ ├── Loaders.tsx │ │ ├── Logo.tsx │ │ ├── MobileSidebar.tsx │ │ ├── MultiSelect.tsx │ │ ├── NoOrganization.tsx │ │ ├── NothingFound.tsx │ │ ├── OrganizationInitializer.tsx │ │ ├── OrganizationSelector.tsx │ │ ├── Sessions │ │ │ ├── SessionCard.tsx │ │ │ ├── SessionDetails.tsx │ │ │ └── SessionsList.tsx │ │ ├── SiteCard.tsx │ │ ├── SiteSessionChart.tsx │ │ ├── SiteSettings │ │ │ ├── ScriptBuilder.tsx │ │ │ └── SiteSettings.tsx │ │ ├── StandardPage.tsx │ │ ├── ThemeToggle.tsx │ │ ├── TopBar.tsx │ │ ├── UsageChart.tsx │ │ ├── auth │ │ │ ├── AuthButton.tsx │ │ │ ├── AuthError.tsx │ │ │ ├── AuthInput.tsx │ │ │ └── SocialButtons.tsx │ │ ├── nivo.ts │ │ ├── pagination.tsx │ │ ├── subscription │ │ │ ├── ExpiredTrialPlan.tsx │ │ │ ├── FreePlan.tsx │ │ │ ├── HelpSection.tsx │ │ │ ├── ProPlan.tsx │ │ │ └── TrialPlan.tsx │ │ └── ui │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── badge.tsx │ │ │ ├── basic-tabs.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── command.tsx │ │ │ ├── dialog.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── textarea.tsx │ │ │ └── tooltip.tsx │ ├── hooks │ │ ├── useAdminUsers.ts │ │ ├── useInView.ts │ │ ├── useSetPageTitle.ts │ │ └── useStopImpersonation.ts │ ├── lib │ │ ├── auth.ts │ │ ├── configs.ts │ │ ├── const.ts │ │ ├── countryPopulation.ts │ │ ├── dateTimeUtils.ts │ │ ├── geo.ts │ │ ├── nivo.ts │ │ ├── store.ts │ │ ├── stripe.ts │ │ ├── subscription │ │ │ ├── constants.ts │ │ │ ├── planUtils.tsx │ │ │ └── useStripeSubscription.ts │ │ ├── urlParams.ts │ │ ├── userStore.ts │ │ └── utils.ts │ ├── middleware.ts │ ├── providers │ │ ├── QueryProvider.tsx │ │ └── ThemeProvider.tsx │ └── types │ │ └── admin.ts ├── tailwind.config.ts └── tsconfig.json ├── docker-compose.cloud.yml ├── docker-compose.dev.yml ├── docker-compose.yml ├── docs ├── .gitignore ├── Caddyfile ├── Dockerfile ├── components.json ├── docker-compose.yml ├── mdx-components.js ├── next-env.d.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public │ ├── blog │ │ └── 5kstars.png │ ├── browsers │ │ ├── 360.png │ │ ├── AVG.svg │ │ ├── Android.svg │ │ ├── Avast.png │ │ ├── Baidu.svg │ │ ├── Chrome.svg │ │ ├── Chromium.svg │ │ ├── CocCoc.svg │ │ ├── DuckDuckGo.svg │ │ ├── Edge.svg │ │ ├── Facebook.svg │ │ ├── Firefox.svg │ │ ├── HeyTap.png │ │ ├── Huawei.svg │ │ ├── IE.svg │ │ ├── Instagram.svg │ │ ├── Iron.png │ │ ├── KAKAOTALK.svg │ │ ├── Lenovo.png │ │ ├── Line.svg │ │ ├── Naver.webp │ │ ├── Oculus.svg │ │ ├── Opera.svg │ │ ├── OperaGX.svg │ │ ├── PaleMoon.png │ │ ├── QQ.webp │ │ ├── Safari.svg │ │ ├── SamsungInternet.svg │ │ ├── Silk.png │ │ ├── Sleipnir.webp │ │ ├── Sogou.png │ │ ├── UCBrowser.svg │ │ ├── Vivo.webp │ │ ├── WeChat.svg │ │ ├── WebKit.svg │ │ ├── Whale.svg │ │ ├── Wolvic.png │ │ └── Yandex.svg │ ├── eu.png │ ├── eu.svg │ ├── favicon.ico │ ├── globe.jpg │ ├── main.jpg │ ├── operating-systems │ │ ├── Android.svg │ │ ├── Apple.svg │ │ ├── Chrome.svg │ │ ├── HarmonyOS.svg │ │ ├── OpenHarmony.png │ │ ├── Playstation.svg │ │ ├── Tizen.png │ │ ├── Tux.svg │ │ ├── Ubuntu.svg │ │ ├── Windows.svg │ │ └── macOS.svg │ ├── platforms │ │ ├── angular.svg │ │ ├── gatsby.svg │ │ ├── gtm.svg │ │ ├── laravel.svg │ │ ├── nextjs.svg │ │ ├── nuxt.svg │ │ ├── react.svg │ │ ├── remix.png │ │ ├── remix.svg │ │ ├── shopify.svg │ │ ├── svelte.svg │ │ ├── vue.svg │ │ ├── webflow.svg │ │ └── wordpress.svg │ ├── rybbit.png │ └── settings.jpg ├── src │ ├── app │ │ ├── _ignored │ │ │ ├── _meta.js │ │ │ └── page.mdx │ │ ├── _meta.js │ │ ├── apple-icon.png │ │ ├── blog │ │ │ ├── [slug] │ │ │ │ └── page.jsx │ │ │ ├── blog.css │ │ │ ├── code-highlight.css │ │ │ ├── page.jsx │ │ │ └── tag │ │ │ │ └── [tag] │ │ │ │ └── page.jsx │ │ ├── components │ │ │ ├── Browser.jsx │ │ │ ├── Cards │ │ │ │ ├── AdvancedFilters.jsx │ │ │ │ ├── EventTracking.jsx │ │ │ │ ├── Funnels.jsx │ │ │ │ ├── GoalConversion.jsx │ │ │ │ ├── RealTimeAnalytics.jsx │ │ │ │ ├── UserBehaviorTrends.jsx │ │ │ │ ├── UserFlowAnalysis.jsx │ │ │ │ ├── UserProfiles.jsx │ │ │ │ └── UserSessions.jsx │ │ │ ├── Country.jsx │ │ │ ├── Logo.jsx │ │ │ ├── OperatingSystem.jsx │ │ │ ├── PricingSection.jsx │ │ │ ├── Tweet.tsx │ │ │ └── integrations.jsx │ │ ├── contact │ │ │ └── page.jsx │ │ ├── docs │ │ │ └── [[...mdxPath]] │ │ │ │ └── page.jsx │ │ ├── globals.css │ │ ├── icon.ico │ │ ├── layout.jsx │ │ ├── opengraph-image.png │ │ ├── page.jsx │ │ ├── pricing │ │ │ └── page.jsx │ │ ├── privacy │ │ │ └── page.jsx │ │ └── providers.jsx │ ├── components │ │ ├── CodeHighlighter.jsx │ │ ├── MDXComponents.jsx │ │ ├── magicui │ │ │ ├── animated-shiny-text.tsx │ │ │ ├── dot-pattern.tsx │ │ │ ├── marquee.tsx │ │ │ ├── safari.tsx │ │ │ └── shine-border.tsx │ │ └── ui │ │ │ ├── accordion.tsx │ │ │ └── slider.tsx │ ├── content │ │ ├── _meta.js │ │ ├── blog │ │ │ ├── 5k-stars.mdx │ │ │ └── _meta.js │ │ ├── changing-domains.mdx │ │ ├── definitions.mdx │ │ ├── deleting-sites.mdx │ │ ├── enhanced-privacy.mdx │ │ ├── hiding-own-traffic.mdx │ │ ├── index.mdx │ │ ├── integrations │ │ │ ├── framer.mdx │ │ │ ├── google-tag-manager.mdx │ │ │ ├── next-js.mdx │ │ │ ├── shopify.mdx │ │ │ ├── webflow.mdx │ │ │ └── wordpress.mdx │ │ ├── inviting-users.mdx │ │ ├── public-site.mdx │ │ ├── roadmap.mdx │ │ ├── script.mdx │ │ ├── sdks │ │ │ └── web.mdx │ │ ├── self-hosting-advanced.mdx │ │ ├── self-hosting-nginx.mdx │ │ ├── self-hosting.mdx │ │ ├── track-events.mdx │ │ └── v1-migration.mdx │ └── lib │ │ ├── blog.js │ │ ├── highlightCode.js │ │ └── utils.ts └── tsconfig.json ├── memory-bank ├── activeContext.md ├── decisionLog.md ├── productContext.md ├── progress.md └── systemPatterns.md ├── mockdata ├── README.md ├── index.js ├── package-lock.json └── package.json ├── projectBrief.md ├── restart.sh ├── server ├── .dockerignore ├── Dockerfile ├── GeoLite2-City.mmdb ├── docker-entrypoint.sh ├── drizzle.config.ts ├── package-lock.json ├── package.json ├── public │ ├── script-full.js │ └── script.js ├── src │ ├── api │ │ ├── admin │ │ │ ├── getAdminOrganizations.ts │ │ │ └── getAdminSites.ts │ │ ├── analytics │ │ │ ├── createFunnel.ts │ │ │ ├── createGoal.ts │ │ │ ├── deleteFunnel.ts │ │ │ ├── deleteGoal.ts │ │ │ ├── getEventNames.ts │ │ │ ├── getEventProperties.ts │ │ │ ├── getEvents.ts │ │ │ ├── getFunnel.ts │ │ │ ├── getFunnels.ts │ │ │ ├── getGoal.ts │ │ │ ├── getGoals.ts │ │ │ ├── getJourneys.ts │ │ │ ├── getLiveSessionLocations.ts │ │ │ ├── getLiveUsercount.ts │ │ │ ├── getOrgEventCount.ts │ │ │ ├── getOverview.ts │ │ │ ├── getOverviewBucketed.ts │ │ │ ├── getPageTitles.ts │ │ │ ├── getPerformanceByDimension.ts │ │ │ ├── getPerformanceByPath.ts │ │ │ ├── getPerformanceOverview.ts │ │ │ ├── getPerformanceTimeSeries.ts │ │ │ ├── getRetention.ts │ │ │ ├── getSession.ts │ │ │ ├── getSessions.ts │ │ │ ├── getSingleCol.ts │ │ │ ├── getUserInfo.ts │ │ │ ├── getUserSessionCount.ts │ │ │ ├── getUserSessions.ts │ │ │ ├── getUsers.ts │ │ │ ├── query-validation.ts │ │ │ ├── types.ts │ │ │ ├── updateGoal.ts │ │ │ └── utils.ts │ │ ├── getConfig.ts │ │ ├── sites │ │ │ ├── addSite.ts │ │ │ ├── changeSiteBlockBots.ts │ │ │ ├── changeSiteDomain.ts │ │ │ ├── changeSitePublic.ts │ │ │ ├── changeSiteSalt.ts │ │ │ ├── deleteSite.ts │ │ │ ├── getSite.ts │ │ │ ├── getSiteHasData.ts │ │ │ ├── getSiteIsPublic.ts │ │ │ └── getSitesFromOrg.ts │ │ ├── stripe │ │ │ ├── createCheckoutSession.ts │ │ │ ├── createPortalSession.ts │ │ │ ├── getSubscription.ts │ │ │ └── webhook.ts │ │ └── user │ │ │ ├── getUserOrganizations.ts │ │ │ └── listOrganizationMembers.ts │ ├── cron │ │ ├── index.ts │ │ └── monthly-usage-checker.ts │ ├── db │ │ ├── clickhouse │ │ │ └── clickhouse.ts │ │ ├── geolocation │ │ │ └── geolocation.ts │ │ └── postgres │ │ │ ├── migration.ts │ │ │ ├── postgres.ts │ │ │ ├── schema.ts │ │ │ └── session-cleanup.ts │ ├── index.ts │ ├── lib │ │ ├── allowedDomains.ts │ │ ├── auth-utils.ts │ │ ├── auth.ts │ │ ├── const.ts │ │ ├── resend.ts │ │ ├── siteConfig.ts │ │ └── stripe.ts │ ├── tracker │ │ ├── channelExamples.ts │ │ ├── const.ts │ │ ├── getChannel.ts │ │ ├── pageviewQueue.ts │ │ ├── trackEvent.ts │ │ └── trackingUtils.ts │ ├── types.ts │ └── utils.ts └── tsconfig.json ├── setup.sh ├── start.sh ├── stop.sh └── update.sh /.env.example: -------------------------------------------------------------------------------- 1 | # Domain and URL Configuration 2 | DOMAIN_NAME=demo.rybbit.io 3 | BASE_URL="https://${DOMAIN_NAME}" 4 | 5 | # Authentication and Security 6 | BETTER_AUTH_SECRET=insecure-secret 7 | DISABLE_SIGNUP=false 8 | 9 | # Webserver Configuration 10 | # Set to false to disable the built-in Caddy webserver 11 | USE_WEBSERVER=true 12 | # Host port mappings - these control how services are exposed 13 | # Format: "host_binding:container_port" or "port:container_port" 14 | HOST_BACKEND_PORT=127.0.0.1:3001 15 | HOST_CLIENT_PORT=127.0.0.1:3002 16 | 17 | # ClickHouse Database Configuration 18 | CLICKHOUSE_DB=analytics 19 | CLICKHOUSE_USER=default 20 | CLICKHOUSE_PASSWORD=frog 21 | 22 | # PostgreSQL Database Configuration 23 | POSTGRES_DB=analytics 24 | POSTGRES_USER=frog 25 | POSTGRES_PASSWORD=frog 26 | 27 | RESEND_API_KEY=rs_XXXXXXX 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/client" 5 | schedule: 6 | interval: "daily" 7 | 8 | - package-ecosystem: "npm" 9 | directory: "/server" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/issue-manager.yml: -------------------------------------------------------------------------------- 1 | name: Issue Manager 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | issue_comment: 7 | types: 8 | - created 9 | issues: 10 | types: 11 | - labeled 12 | workflow_dispatch: 13 | 14 | permissions: 15 | issues: write 16 | 17 | jobs: 18 | issue-manager: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: tiangolo/issue-manager@0.5.1 22 | with: 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | config: '{"answered": {}}' 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | .pnpm-store/ 4 | 5 | # Build outputs 6 | dist/ 7 | build/ 8 | *.tsbuildinfo 9 | 10 | # Environment variables 11 | .env 12 | .env.local 13 | .env.*.local 14 | 15 | # Logs 16 | logs 17 | *.log 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | pnpm-debug.log* 22 | 23 | # IDE specific files 24 | .idea/ 25 | .vscode/ 26 | *.swp 27 | *.swo 28 | .DS_Store 29 | 30 | # Docker volumes 31 | docker-volumes/ 32 | clickhouse-data/ 33 | 34 | # Testing 35 | coverage/ 36 | .nyc_output/ 37 | 38 | # Temporary files 39 | *.tmp 40 | *.temp 41 | .cache/ 42 | 43 | # Production 44 | .env.production 45 | 46 | .cursor/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # So minified script is not formatted 2 | **/script.js 3 | **/*.mdx 4 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | # Caddyfile 2 | # Use the domain name passed from docker-compose environment 3 | {$DOMAIN_NAME} { 4 | # Enable compression 5 | encode zstd gzip 6 | 7 | handle /api/* { 8 | reverse_proxy backend:3001 9 | } 10 | 11 | 12 | # Proxy all other requests to the client service 13 | handle { 14 | reverse_proxy client:3002 15 | } 16 | 17 | } -------------------------------------------------------------------------------- /clickhouse_config/enable_json.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1 4 | 5 | -------------------------------------------------------------------------------- /clickhouse_config/logging_rules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | warning 4 | true 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /clickhouse_config/network.xml: -------------------------------------------------------------------------------- 1 | 2 | 0.0.0.0 3 | -------------------------------------------------------------------------------- /clickhouse_config/user_logging.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 0 5 | 0 6 | 0 7 | 8 | 9 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /client/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": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": false, 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 | } -------------------------------------------------------------------------------- /client/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | output: "standalone", 4 | env: { 5 | NEXT_PUBLIC_BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL, 6 | NEXT_PUBLIC_DISABLE_SIGNUP: process.env.NEXT_PUBLIC_DISABLE_SIGNUP, 7 | NEXT_PUBLIC_APP_VERSION: process.env.npm_package_version 8 | }, 9 | }; 10 | 11 | module.exports = nextConfig; 12 | -------------------------------------------------------------------------------- /client/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /client/public/browsers/360.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/public/browsers/360.png -------------------------------------------------------------------------------- /client/public/browsers/Android.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /client/public/browsers/Avast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/public/browsers/Avast.png -------------------------------------------------------------------------------- /client/public/browsers/Baidu.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/public/browsers/Chrome.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/public/browsers/Facebook.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 11 | 12 | 13 | 15 | 17 | 18 | -------------------------------------------------------------------------------- /client/public/browsers/HeyTap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/public/browsers/HeyTap.png -------------------------------------------------------------------------------- /client/public/browsers/Iron.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/public/browsers/Iron.png -------------------------------------------------------------------------------- /client/public/browsers/Lenovo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/public/browsers/Lenovo.png -------------------------------------------------------------------------------- /client/public/browsers/Line.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/public/browsers/LinkedIn.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/public/browsers/Miui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/public/browsers/Miui.png -------------------------------------------------------------------------------- /client/public/browsers/Naver.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/public/browsers/Naver.webp -------------------------------------------------------------------------------- /client/public/browsers/Oculus.svg: -------------------------------------------------------------------------------- 1 | 2 | Oculus icon -------------------------------------------------------------------------------- /client/public/browsers/OperaGX.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/browsers/PaleMoon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/public/browsers/PaleMoon.png -------------------------------------------------------------------------------- /client/public/browsers/QQ.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/public/browsers/QQ.webp -------------------------------------------------------------------------------- /client/public/browsers/Quark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/public/browsers/Silk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/public/browsers/Silk.png -------------------------------------------------------------------------------- /client/public/browsers/Sleipnir.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/public/browsers/Sleipnir.webp -------------------------------------------------------------------------------- /client/public/browsers/Sogou.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/public/browsers/Sogou.png -------------------------------------------------------------------------------- /client/public/browsers/Twitter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/browsers/Vivo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/public/browsers/Vivo.webp -------------------------------------------------------------------------------- /client/public/browsers/Wolvic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/public/browsers/Wolvic.png -------------------------------------------------------------------------------- /client/public/operating-systems/Android.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /client/public/operating-systems/Chrome.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/public/operating-systems/OpenHarmony.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/public/operating-systems/OpenHarmony.png -------------------------------------------------------------------------------- /client/public/operating-systems/Tizen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/public/operating-systems/Tizen.png -------------------------------------------------------------------------------- /client/public/operating-systems/Ubuntu.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /client/public/operating-systems/Windows.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/public/operating-systems/Xbox.svg: -------------------------------------------------------------------------------- 1 | 2 | Xbox Logo -------------------------------------------------------------------------------- /client/public/operating-systems/macOS.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/public/rybbit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rybbit-io/rybbit/e41c268eb1dbf8e8e975e9937cd457468b9abd23/client/public/rybbit.png -------------------------------------------------------------------------------- /client/src/api/admin/auth.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { BACKEND_URL } from "../../lib/const"; 3 | import { authedFetchWithError } from "../utils"; 4 | 5 | type GetOrganizationMembersResponse = { 6 | data: { 7 | id: string; 8 | role: string; 9 | userId: string; 10 | organizationId: string; 11 | createdAt: string; 12 | user: { 13 | id: string; 14 | name: string | null; 15 | email: string; 16 | }; 17 | }[]; 18 | }; 19 | 20 | export const useOrganizationMembers = (organizationId: string) => { 21 | return useQuery({ 22 | queryKey: ["organization-members", organizationId], 23 | queryFn: () => 24 | authedFetchWithError( 25 | `${BACKEND_URL}/list-organization-members/${organizationId}` 26 | ), 27 | staleTime: Infinity, 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /client/src/api/admin/getAdminOrganizations.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { BACKEND_URL } from "../../lib/const"; 3 | import { authedFetchWithError } from "../utils"; 4 | 5 | export interface AdminOrganizationData { 6 | id: string; 7 | name: string; 8 | createdAt: string; 9 | monthlyEventCount: number; 10 | overMonthlyLimit: boolean; 11 | subscription: { 12 | id: string | null; 13 | planName: string; 14 | status: string; 15 | eventLimit: number; 16 | currentPeriodEnd: Date; 17 | cancelAtPeriodEnd?: boolean; 18 | interval?: string; 19 | }; 20 | sites: { 21 | siteId: number; 22 | name: string; 23 | domain: string; 24 | createdAt: string; 25 | eventsLast24Hours: number; 26 | }[]; 27 | members: { 28 | userId: string; 29 | name: string; 30 | email: string; 31 | role: string; 32 | createdAt: string; 33 | }[]; 34 | } 35 | 36 | export async function getAdminOrganizations() { 37 | return authedFetchWithError( 38 | `${BACKEND_URL}/admin/organizations` 39 | ); 40 | } 41 | 42 | export function useAdminOrganizations() { 43 | return useQuery({ 44 | queryKey: ["admin-organizations"], 45 | queryFn: getAdminOrganizations, 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /client/src/api/admin/getAdminSites.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { BACKEND_URL } from "../../lib/const"; 3 | import { authedFetchWithError } from "../utils"; 4 | 5 | export interface AdminSiteData { 6 | siteId: number; 7 | domain: string; 8 | createdAt: string; 9 | public: boolean; 10 | eventsLast24Hours: number; 11 | organizationOwnerEmail: string | null; 12 | } 13 | 14 | export async function getAdminSites() { 15 | return authedFetchWithError(`${BACKEND_URL}/admin/sites`); 16 | } 17 | 18 | export function useAdminSites() { 19 | return useQuery({ 20 | queryKey: ["admin-sites"], 21 | queryFn: getAdminSites, 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /client/src/api/admin/organizations.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { BACKEND_URL } from "../../lib/const"; 3 | import { authedFetchWithError } from "../utils"; 4 | import { authClient } from "../../lib/auth"; 5 | 6 | export type UserOrganization = { 7 | id: string; 8 | name: string; 9 | slug: string; 10 | logo: string | null; 11 | createdAt: string; 12 | metadata: string | null; 13 | role: string; 14 | }; 15 | 16 | export function getUserOrganizations(): Promise { 17 | return authedFetchWithError(`${BACKEND_URL}/user/organizations`); 18 | } 19 | 20 | export function useUserOrganizations() { 21 | return useQuery({ 22 | queryKey: ["userOrganizations"], 23 | queryFn: getUserOrganizations, 24 | }); 25 | } 26 | 27 | export function useOrganizationInvitations(organizationId: string) { 28 | return useQuery({ 29 | queryKey: ["invitations", organizationId], 30 | queryFn: async () => { 31 | const invitations = await authClient.organization.listInvitations({ 32 | query: { 33 | organizationId, 34 | }, 35 | }); 36 | 37 | if (invitations.error) { 38 | throw new Error(invitations.error.message); 39 | } 40 | 41 | return invitations.data; 42 | }, 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /client/src/api/analytics/useCreateGoal.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { BACKEND_URL } from "../../lib/const"; 3 | import { authedFetchWithError } from "../utils"; 4 | 5 | export interface CreateGoalRequest { 6 | siteId: number; 7 | name?: string; 8 | goalType: "path" | "event"; 9 | config: { 10 | pathPattern?: string; 11 | eventName?: string; 12 | eventPropertyKey?: string; 13 | eventPropertyValue?: string | number | boolean; 14 | }; 15 | } 16 | 17 | interface CreateGoalResponse { 18 | success: boolean; 19 | goalId: number; 20 | } 21 | 22 | export function useCreateGoal() { 23 | const queryClient = useQueryClient(); 24 | 25 | return useMutation({ 26 | mutationFn: async (goalData) => { 27 | try { 28 | return await authedFetchWithError( 29 | `${BACKEND_URL}/goal/create`, 30 | { 31 | method: "POST", 32 | headers: { 33 | "Content-Type": "application/json", 34 | }, 35 | body: JSON.stringify(goalData), 36 | } 37 | ); 38 | } catch (error) { 39 | throw new Error( 40 | error instanceof Error ? error.message : "Failed to create goal" 41 | ); 42 | } 43 | }, 44 | onSuccess: (_, variables) => { 45 | // Invalidate goals query to refetch with the new goal 46 | queryClient.invalidateQueries({ 47 | queryKey: ["goals", variables.siteId.toString()], 48 | }); 49 | }, 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /client/src/api/analytics/useDeleteFunnel.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { BACKEND_URL } from "../../lib/const"; 3 | import { authedFetchWithError } from "../utils"; 4 | 5 | /** 6 | * Hook for deleting a saved funnel report 7 | */ 8 | export function useDeleteFunnel() { 9 | const queryClient = useQueryClient(); 10 | 11 | return useMutation<{ success: boolean }, Error, number>({ 12 | mutationFn: async (reportId) => { 13 | try { 14 | return await authedFetchWithError<{ success: boolean }>( 15 | `${BACKEND_URL}/funnel/${reportId}`, 16 | { 17 | method: "DELETE", 18 | } 19 | ); 20 | } catch (error) { 21 | console.error(error); 22 | throw new Error("Failed to delete funnel"); 23 | } 24 | }, 25 | onSuccess: () => { 26 | // Invalidate the funnels query to refresh the list 27 | queryClient.invalidateQueries({ queryKey: ["funnels"] }); 28 | }, 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /client/src/api/analytics/useDeleteGoal.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { BACKEND_URL } from "../../lib/const"; 3 | import { authedFetchWithError } from "../utils"; 4 | import { useStore } from "../../lib/store"; 5 | 6 | export function useDeleteGoal() { 7 | const queryClient = useQueryClient(); 8 | const { site } = useStore(); 9 | 10 | return useMutation<{ success: boolean }, Error, number>({ 11 | mutationFn: async (goalId: number) => { 12 | try { 13 | return await authedFetchWithError<{ success: boolean }>( 14 | `${BACKEND_URL}/goal/${goalId}`, 15 | { 16 | method: "DELETE", 17 | } 18 | ); 19 | } catch (error) { 20 | throw new Error( 21 | error instanceof Error ? error.message : "Failed to delete goal" 22 | ); 23 | } 24 | }, 25 | onSuccess: () => { 26 | // Invalidate goals query to refetch without the deleted goal 27 | queryClient.invalidateQueries({ 28 | queryKey: ["goals", site], 29 | }); 30 | }, 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /client/src/api/analytics/useGetEventNames.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { BACKEND_URL } from "../../lib/const"; 3 | import { useStore, getFilteredFilters, EVENT_FILTERS } from "../../lib/store"; 4 | import { authedFetchWithError } from "../utils"; 5 | import { getQueryTimeParams } from "./utils"; 6 | import { buildUrl } from "../utils"; 7 | 8 | export type EventName = { 9 | eventName: string; 10 | count: number; 11 | }; 12 | 13 | export function useGetEventNames() { 14 | const { site, time, filters } = useStore(); 15 | 16 | const timeParams = getQueryTimeParams(time); 17 | const filteredFilters = getFilteredFilters(EVENT_FILTERS); 18 | 19 | return useQuery({ 20 | queryKey: ["event-names", site, timeParams, filteredFilters], 21 | enabled: !!site, 22 | queryFn: () => { 23 | const url = buildUrl(`${BACKEND_URL}/events/names/${site}`, { 24 | ...Object.fromEntries(new URLSearchParams(timeParams)), 25 | filters: filteredFilters.length > 0 ? filteredFilters : undefined, 26 | }); 27 | 28 | return authedFetchWithError<{ data: EventName[] }>(url).then( 29 | (res) => res.data 30 | ); 31 | }, 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /client/src/api/analytics/useGetEventProperties.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { BACKEND_URL } from "../../lib/const"; 3 | import { useStore, getFilteredFilters, EVENT_FILTERS } from "../../lib/store"; 4 | import { authedFetchWithError } from "../utils"; 5 | import { getQueryTimeParams } from "./utils"; 6 | import { buildUrl } from "../utils"; 7 | 8 | export type EventProperty = { 9 | propertyKey: string; 10 | propertyValue: string; 11 | count: number; 12 | }; 13 | 14 | export function useGetEventProperties(eventName: string | null) { 15 | const { site, time, filters } = useStore(); 16 | 17 | const timeParams = getQueryTimeParams(time); 18 | const filteredFilters = getFilteredFilters(EVENT_FILTERS); 19 | 20 | return useQuery({ 21 | queryKey: [ 22 | "event-properties", 23 | site, 24 | eventName, 25 | timeParams, 26 | filteredFilters, 27 | ], 28 | enabled: !!site && !!eventName, 29 | queryFn: () => { 30 | const url = buildUrl(`${BACKEND_URL}/events/properties/${site}`, { 31 | ...Object.fromEntries(new URLSearchParams(timeParams)), 32 | eventName, 33 | filters: filteredFilters.length > 0 ? filteredFilters : undefined, 34 | }); 35 | 36 | return authedFetchWithError<{ data: EventProperty[] }>(url).then( 37 | (res) => res.data 38 | ); 39 | }, 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /client/src/api/analytics/useGetFunnels.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { BACKEND_URL } from "../../lib/const"; 3 | import { Filter } from "../../lib/store"; 4 | import { FunnelStep } from "./useGetFunnel"; 5 | import { authedFetchWithError } from "../utils"; 6 | 7 | export interface SavedFunnel { 8 | id: number; 9 | name: string; 10 | steps: FunnelStep[]; 11 | filters?: Filter[]; 12 | createdAt: string; 13 | updatedAt: string; 14 | conversionRate: number | null; 15 | totalVisitors: number | null; 16 | } 17 | 18 | export function useGetFunnels(siteId?: string | number) { 19 | return useQuery({ 20 | queryKey: ["funnels", siteId], 21 | queryFn: async () => { 22 | if (!siteId) { 23 | return []; 24 | } 25 | try { 26 | const response = await authedFetchWithError<{ data: SavedFunnel[] }>( 27 | `${BACKEND_URL}/funnels/${siteId}` 28 | ); 29 | return response.data; 30 | } catch (error) { 31 | throw new Error("Failed to fetch funnels"); 32 | } 33 | }, 34 | enabled: !!siteId, 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /client/src/api/analytics/useGetGoal.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { BACKEND_URL } from "../../lib/const"; 3 | import { timeZone } from "../../lib/dateTimeUtils"; 4 | import { authedFetch } from "../utils"; 5 | import { useStore, Filter } from "../../lib/store"; 6 | import { Goal } from "./useGetGoals"; 7 | 8 | interface GetGoalResponse { 9 | data: Goal; 10 | } 11 | 12 | export function useGetGoal({ 13 | goalId, 14 | startDate, 15 | endDate, 16 | filters, 17 | enabled = true, 18 | }: { 19 | goalId: number; 20 | startDate: string; 21 | endDate: string; 22 | filters?: Filter[]; 23 | enabled?: boolean; 24 | }) { 25 | const { site } = useStore(); 26 | 27 | return useQuery({ 28 | queryKey: ["goal", site, goalId, startDate, endDate, timeZone, filters], 29 | queryFn: async () => { 30 | return authedFetch(`${BACKEND_URL}/goal/${goalId}/${site}`, { 31 | startDate, 32 | endDate, 33 | timeZone, 34 | filters, 35 | }).then((res) => res.json()); 36 | }, 37 | enabled: !!site && !!goalId && enabled, 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /client/src/api/analytics/useGetLiveSessionLocations.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { useStore } from "../../lib/store"; 3 | import { BACKEND_URL } from "../../lib/const"; 4 | import { authedFetchWithError } from "../utils"; 5 | 6 | export type LiveSessionLocation = { 7 | lat: number; 8 | lon: number; 9 | count: number; 10 | city: string; 11 | }; 12 | 13 | export function useGetLiveSessionLocations(minutes = 5) { 14 | const { site } = useStore(); 15 | return useQuery({ 16 | queryKey: ["live-session-locations", site, minutes], 17 | queryFn: () => 18 | authedFetchWithError( 19 | `${BACKEND_URL}/live-session-locations/${site}?time=${minutes}` 20 | ).then((res: any) => res.data), 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /client/src/api/analytics/useGetOrgEventCount.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { BACKEND_URL } from "../../lib/const"; 3 | import { authedFetchWithError } from "../utils"; 4 | 5 | export type OrgEventCountResponse = { 6 | event_date: string; 7 | event_count: number; 8 | }[]; 9 | 10 | export type GetOrgEventCountResponse = { 11 | data: OrgEventCountResponse; 12 | }; 13 | 14 | async function getOrgEventCount({ 15 | organizationId, 16 | startDate, 17 | endDate, 18 | timeZone = "UTC", 19 | }: { 20 | organizationId: string; 21 | startDate?: string; 22 | endDate?: string; 23 | timeZone?: string; 24 | }): Promise { 25 | const params = new URLSearchParams(); 26 | if (startDate) params.append("startDate", startDate); 27 | if (endDate) params.append("endDate", endDate); 28 | if (timeZone) params.append("timeZone", timeZone); 29 | 30 | return authedFetchWithError( 31 | `${BACKEND_URL}/org-event-count/${organizationId}?${params.toString()}` 32 | ); 33 | } 34 | 35 | export function useGetOrgEventCount({ 36 | organizationId, 37 | startDate, 38 | endDate, 39 | timeZone = "UTC", 40 | enabled = true, 41 | }: { 42 | organizationId: string; 43 | startDate?: string; 44 | endDate?: string; 45 | timeZone?: string; 46 | enabled?: boolean; 47 | }) { 48 | return useQuery({ 49 | queryKey: ["org-event-count", organizationId, startDate, endDate, timeZone], 50 | queryFn: () => 51 | getOrgEventCount({ 52 | organizationId, 53 | startDate, 54 | endDate, 55 | timeZone, 56 | }), 57 | enabled: enabled && !!organizationId, 58 | staleTime: 1000 * 60 * 5, // 5 minutes 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /client/src/api/analytics/useGetOverviewWithInView.ts: -------------------------------------------------------------------------------- 1 | import { useGetOverviewPastMinutes } from "./useGetOverview"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | import { BACKEND_URL } from "../../lib/const"; 4 | import { timeZone } from "../../lib/dateTimeUtils"; 5 | import { authedFetch } from "../utils"; 6 | 7 | /** 8 | * A wrapper around useGetOverviewPastMinutes that adds support for 9 | * conditional fetching based on viewport visibility 10 | */ 11 | export function useGetOverviewWithInView({ 12 | pastMinutes = 24 * 60, 13 | site, 14 | isInView = true, 15 | }: { 16 | pastMinutes?: number; 17 | site?: number | string; 18 | isInView?: boolean; 19 | }) { 20 | // Always call the useQuery hook, but conditionally enable the fetch 21 | return useQuery({ 22 | queryKey: ["overview-past-minutes", pastMinutes, site], 23 | queryFn: () => { 24 | return authedFetch(`${BACKEND_URL}/overview/${site}`, { 25 | pastMinutes, 26 | timeZone, 27 | }).then((res) => res.json()); 28 | }, 29 | enabled: isInView, // Only fetch when in view 30 | staleTime: Infinity, 31 | placeholderData: (_, query: any) => { 32 | if (!query?.queryKey) return undefined; 33 | const prevQueryKey = query.queryKey as [string, string, string]; 34 | const [, , prevSite] = prevQueryKey; 35 | 36 | if (prevSite === site) { 37 | return query.state.data; 38 | } 39 | return undefined; 40 | }, 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /client/src/api/analytics/useGetRetention.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { BACKEND_URL } from "../../lib/const"; 3 | import { useStore } from "../../lib/store"; 4 | import { authedFetch } from "../utils"; 5 | 6 | // Define the interface for processed retention data 7 | export interface ProcessedRetentionData { 8 | cohorts: Record; 9 | maxPeriods: number; 10 | mode: "day" | "week"; 11 | range: number; 12 | } 13 | 14 | export type RetentionMode = "day" | "week"; 15 | 16 | export function useGetRetention( 17 | mode: RetentionMode = "week", 18 | range: number = 90 19 | ) { 20 | const { site } = useStore(); 21 | return useQuery({ 22 | queryKey: ["retention", site, mode, range], 23 | queryFn: () => 24 | authedFetch( 25 | `${BACKEND_URL}/retention/${site}?mode=${mode}&range=${range}` 26 | ) 27 | .then((res) => res.json()) 28 | .then((data) => data.data), 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /client/src/api/analytics/useJourneys.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { authedFetch, getStartAndEndDate } from "../utils"; 3 | import { BACKEND_URL } from "../../lib/const"; 4 | import { Time } from "../../components/DateSelector/types"; 5 | 6 | export interface JourneyParams { 7 | siteId?: number; 8 | steps?: number; 9 | timeZone?: string; 10 | time: Time; 11 | limit?: number; 12 | } 13 | 14 | export interface Journey { 15 | path: string[]; 16 | count: number; 17 | percentage: number; 18 | } 19 | 20 | export interface JourneysResponse { 21 | journeys: Journey[]; 22 | } 23 | 24 | export const useJourneys = ({ 25 | siteId, 26 | steps = 3, 27 | timeZone = "UTC", 28 | time, 29 | limit = 100, 30 | }: JourneyParams) => { 31 | const { startDate, endDate } = getStartAndEndDate(time); 32 | 33 | return useQuery({ 34 | queryKey: ["journeys", siteId, steps, startDate, endDate, timeZone, limit], 35 | queryFn: async () => { 36 | let url = `${BACKEND_URL}/journeys/${siteId}`; 37 | const params = new URLSearchParams(); 38 | 39 | if (steps) params.append("steps", steps.toString()); 40 | if (startDate) params.append("startDate", startDate); 41 | if (endDate) params.append("endDate", endDate); 42 | if (timeZone) params.append("timeZone", timeZone); 43 | if (limit) params.append("limit", limit.toString()); 44 | 45 | const queryString = params.toString(); 46 | if (queryString) url += `?${queryString}`; 47 | 48 | const response = await authedFetch(url); 49 | return response.json(); 50 | }, 51 | enabled: !!siteId, 52 | }); 53 | }; 54 | -------------------------------------------------------------------------------- /client/src/api/analytics/useLiveUserCount.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { useStore } from "../../lib/store"; 3 | import { BACKEND_URL } from "../../lib/const"; 4 | import { authedFetch } from "../utils"; 5 | 6 | export function useGetLiveUsercount(minutes = 5) { 7 | const { site } = useStore(); 8 | return useQuery({ 9 | queryKey: ["live-user-count", site, minutes], 10 | refetchInterval: 5000, 11 | queryFn: () => 12 | authedFetch( 13 | `${BACKEND_URL}/live-user-count/${site}?minutes=${minutes}` 14 | ).then((res) => res.json()), 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /client/src/api/analytics/useUpdateGoal.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { BACKEND_URL } from "../../lib/const"; 3 | import { authedFetchWithError } from "../utils"; 4 | import { useStore } from "../../lib/store"; 5 | 6 | export interface UpdateGoalRequest { 7 | goalId: number; 8 | siteId: number; 9 | name?: string; 10 | goalType: "path" | "event"; 11 | config: { 12 | pathPattern?: string; 13 | eventName?: string; 14 | eventPropertyKey?: string; 15 | eventPropertyValue?: string | number | boolean; 16 | }; 17 | } 18 | 19 | interface UpdateGoalResponse { 20 | success: boolean; 21 | goalId: number; 22 | } 23 | 24 | export function useUpdateGoal() { 25 | const queryClient = useQueryClient(); 26 | const { site } = useStore(); 27 | 28 | return useMutation({ 29 | mutationFn: async (goalData) => { 30 | try { 31 | return await authedFetchWithError( 32 | `${BACKEND_URL}/goal/update`, 33 | { 34 | method: "PUT", 35 | headers: { 36 | "Content-Type": "application/json", 37 | }, 38 | body: JSON.stringify(goalData), 39 | } 40 | ); 41 | } catch (error) { 42 | throw new Error( 43 | error instanceof Error ? error.message : "Failed to update goal" 44 | ); 45 | } 46 | }, 47 | onSuccess: () => { 48 | // Invalidate goals query to refetch with the updated goal 49 | queryClient.invalidateQueries({ 50 | queryKey: ["goals", site], 51 | }); 52 | }, 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /client/src/api/analytics/userInfo.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { BACKEND_URL } from "../../lib/const"; 3 | import { authedFetch } from "../utils"; 4 | 5 | export type UserInfo = { 6 | duration: number; 7 | sessions: number; 8 | country: string; 9 | region: string; 10 | city: string; 11 | language: string; 12 | device_type: string; 13 | browser: string; 14 | browser_version: string; 15 | operating_system: string; 16 | operating_system_version: string; 17 | screen_height: number; 18 | screen_width: number; 19 | last_seen: string; 20 | first_seen: string; 21 | pageviews: number; 22 | events: number; 23 | }; 24 | 25 | export function useUserInfo(siteId: number, userId: string) { 26 | return useQuery({ 27 | queryKey: ["user-info", userId, siteId], 28 | queryFn: () => { 29 | return authedFetch(`${BACKEND_URL}/user/info/${userId}/${siteId}`) 30 | .then((res) => res.json()) 31 | .then((res) => res.data); 32 | }, 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /client/src/api/analytics/utils.ts: -------------------------------------------------------------------------------- 1 | import { Time } from "../../components/DateSelector/types"; 2 | import { getStartAndEndDate } from "../utils"; 3 | import { timeZone } from "../../lib/dateTimeUtils"; 4 | 5 | /** 6 | * Generates URL query parameters for time filtering 7 | * @param time Time object from store 8 | * @returns URL query string with time parameters 9 | */ 10 | export function getQueryTimeParams(time: Time): string { 11 | const params = new URLSearchParams(); 12 | 13 | // Handle last-24-hours mode differently 14 | if (time.mode === "last-24-hours") { 15 | // Use pastMinutesStart/pastMinutesEnd parameters instead of date range 16 | params.append("pastMinutesStart", "1440"); // 24 hours ago (24 * 60 minutes) 17 | params.append("pastMinutesEnd", "0"); // now 18 | params.append("timeZone", timeZone); 19 | return params.toString(); 20 | } 21 | 22 | // Regular date-based approach for other modes 23 | const { startDate, endDate } = getStartAndEndDate(time); 24 | 25 | if (startDate) params.append("startDate", startDate); 26 | if (endDate) params.append("endDate", endDate); 27 | params.append("timeZone", timeZone); 28 | 29 | return params.toString(); 30 | } 31 | -------------------------------------------------------------------------------- /client/src/api/types.ts: -------------------------------------------------------------------------------- 1 | export type APIResponse = { 2 | data: T; 3 | error?: string; 4 | }; 5 | -------------------------------------------------------------------------------- /client/src/api/usePageMetadata.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | 3 | interface PageMetadata { 4 | title?: string; 5 | description?: string; 6 | image?: string; 7 | url?: string; 8 | siteName?: string; 9 | favicon?: string; 10 | } 11 | 12 | export function usePageMetadata(pageUrl: string | null) { 13 | return useQuery({ 14 | queryKey: ["page-metadata", pageUrl], 15 | queryFn: async (): Promise => { 16 | if (!pageUrl) { 17 | return {}; 18 | } 19 | 20 | try { 21 | // Using Microlink to fetch metadata 22 | const response = await fetch( 23 | `https://api.microlink.io/?url=${encodeURIComponent( 24 | pageUrl 25 | )}&meta=true&screenshot=false` 26 | ); 27 | 28 | if (!response.ok) { 29 | throw new Error(`Failed to fetch metadata: ${response.statusText}`); 30 | } 31 | 32 | const data = await response.json(); 33 | 34 | // Extract relevant metadata 35 | return { 36 | title: data.data?.title, 37 | description: data.data?.description, 38 | image: data.data?.ogImage?.url || data.data?.image?.url, 39 | url: data.data?.url, 40 | siteName: data.data?.publisher, 41 | favicon: data.data?.logo?.url, 42 | }; 43 | } catch (error) { 44 | console.error("Error fetching page metadata:", error); 45 | throw error; 46 | } 47 | }, 48 | enabled: !!pageUrl, // Only run the query if pageUrl is provided 49 | staleTime: 24 * 60 * 60 * 1000, // Cache for 24 hours 50 | retry: 1, // Only retry once if failed 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /client/src/app/ReactScan.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | // react-scan must be imported before react 3 | import { scan } from "react-scan"; 4 | import { JSX, useEffect } from "react"; 5 | 6 | export function ReactScan(): JSX.Element { 7 | useEffect(() => { 8 | scan({ 9 | enabled: true, 10 | }); 11 | }, []); 12 | 13 | return <>; 14 | } 15 | -------------------------------------------------------------------------------- /client/src/app/[site]/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FreePlanBanner } from "../../../../components/FreePlanBanner"; 4 | import { userStore } from "../../../../lib/userStore"; 5 | import { NoData } from "./NoData"; 6 | import { UsageBanners } from "./UsageBanners"; 7 | 8 | export function Header() { 9 | const { user } = userStore(); 10 | 11 | return ( 12 |
13 | {user && ( 14 |
15 | 16 | 17 | 18 |
19 | )} 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /client/src/app/[site]/components/Sidebar/LiveUserCount.tsx: -------------------------------------------------------------------------------- 1 | import { useGetLiveUsercount } from "../../../../api/analytics/useLiveUserCount"; 2 | import { 3 | Tooltip, 4 | TooltipContent, 5 | TooltipTrigger, 6 | } from "../../../../components/ui/tooltip"; 7 | import NumberFlow from "@number-flow/react"; 8 | 9 | export default function LiveUserCount() { 10 | const { data } = useGetLiveUsercount(5); 11 | 12 | return ( 13 |
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 | {os 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 |
13 | 14 |
15 | 16 | 17 | 18 |
19 |
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 |
32 |
33 |
34 | ); 35 | } 36 | 37 | return ( 38 |
39 |
40 |

{title}

41 | {showStopImpersonating && isImpersonating && ( 42 | 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 | 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 |
29 |
30 |
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 | 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 | Public Site 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" --------------------------------------------------------------------------------