├── .deepsource.toml ├── .env.example ├── .gitignore ├── LICENSE ├── README.md ├── app ├── api │ ├── public-script │ │ └── route.js │ └── support-email │ │ └── route.ts ├── auth │ └── callback │ │ └── route.ts ├── dashboard │ ├── [slug] │ │ ├── loading.tsx │ │ └── page.tsx │ └── page.tsx ├── features │ └── page.tsx ├── globals.css ├── layout.tsx ├── login │ ├── page.tsx │ └── submit-button.tsx ├── logout │ └── page.tsx ├── not-found.tsx ├── page.tsx ├── privacy │ └── page.tsx ├── reset-password │ └── page.tsx ├── signup │ ├── page.tsx │ └── submit-button.tsx ├── styles.css ├── support │ ├── SupportForm.tsx │ └── page.tsx └── terms │ └── page.tsx ├── components.json ├── components ├── AnalyticsPage.tsx ├── AuthButton.tsx ├── BrowsersCard.tsx ├── CountryCard.tsx ├── DemoSection.tsx ├── DeviceCard.tsx ├── DevicesCard.tsx ├── FAQSection.tsx ├── Footer.tsx ├── ForgotPassword.tsx ├── HeroSection.tsx ├── Navbar.tsx ├── NewSiteDialog.tsx ├── OSCard.tsx ├── OpenSource.tsx ├── PathsCard.tsx ├── PrivacyFeatures.tsx ├── PublicURLSwitch.tsx ├── ReferrersCard.tsx ├── ResetPasswordForm.tsx ├── SitesList.tsx ├── Stack.tsx ├── Steps.tsx ├── ViewsBarChart.tsx ├── features.tsx └── ui │ ├── accordion.tsx │ ├── alert-dialog.tsx │ ├── alert.tsx │ ├── aspect-ratio.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── breadcrumb.tsx │ ├── button.tsx │ ├── calendar.tsx │ ├── card.tsx │ ├── carousel.tsx │ ├── checkbox.tsx │ ├── collapsible.tsx │ ├── command.tsx │ ├── context-menu.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── hover-card.tsx │ ├── input-otp.tsx │ ├── input.tsx │ ├── label.tsx │ ├── menubar.tsx │ ├── navigation-menu.tsx │ ├── pagination.tsx │ ├── popover.tsx │ ├── progress.tsx │ ├── radio-group.tsx │ ├── resizable.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── skeleton.tsx │ ├── slider.tsx │ ├── sonner.tsx │ ├── switch.tsx │ ├── table.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ ├── toast.tsx │ ├── toaster.tsx │ ├── toggle-group.tsx │ ├── toggle.tsx │ ├── tooltip.tsx │ └── use-toast.ts ├── lib └── utils.ts ├── middleware.ts ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── favicon.ico ├── icons │ ├── nextjs.svg │ ├── resend.svg │ ├── shadcnui.svg │ ├── supabase.svg │ ├── supatycs.png │ ├── tremor.jpeg │ └── vercel.svg ├── illustrations │ └── dashboard-empty.svg ├── og.png ├── screenshots │ ├── dashboard-screenshot.png │ └── supabase.png └── track.js ├── tailwind.config.ts ├── tsconfig.json └── utils └── supabase ├── client.ts ├── middleware.ts └── server.ts /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[analyzers]] 4 | name = "javascript" 5 | 6 | [analyzers.meta] 7 | plugins = ["react"] 8 | environment = [ 9 | "browser", 10 | "nodejs" 11 | ] 12 | 13 | [[transformers]] 14 | name = "prettier" -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | 2 | # Name of your website 3 | NEXT_PUBLIC_SITE_NAME="Woyage" 4 | 5 | # URL of your website 6 | NEXT_PUBLIC_SITE_URL="https://woyage.app/" 7 | 8 | # Stage of your website (development/production) used to determine the environment 9 | NEXT_PUBLIC_STAGE="development" 10 | 11 | # Get this from your Supabase project settings 12 | NEXT_PUBLIC_SUPABASE_ANON_KEY="..." 13 | 14 | # Get this from your Supabase project settings 15 | NEXT_PUBLIC_SUPABASE_URL="https://project_id.supabase.co" 16 | 17 | # We are using Resend for everything related to emails, you need to sign up on Resend and set up a domain over there & setup the Supabase integration. Check out the following links for more information: 18 | # https://resend.com/docs/dashboard/domains/introduction 19 | # https://supabase.com/partners/integrations/resend 20 | # https://resend.com/api-keys 21 | 22 | # After setting up the domain, set the RESEND_EMAIL variable 23 | RESEND_EMAIL="re_..." 24 | 25 | # Support email for your website, remove this if you don't plan to implement support 26 | NEXT_PUBLIC_SUPPORT_EMAIL="support@woyage.app" 27 | 28 | # Woyage use Supabase Webhooks to email the admin whenever a new support request is created. You can set a webhook in your Supabase project settings: 29 | # https://supabase.com/docs/guides/database/webhooks 30 | 31 | # When you create a webhook, create a HEADER with a key as `supabase-verified` and a value that will be used to verify it, and set the following environment variable with the value you've set, in order to prevent unauthorized requests to your webhook 32 | SUPABASE_WEBHOOK_HEADER_KEY="...." 33 | 34 | # Email of the recipient of the support requests, remove this if you don't plan to implement support 35 | SUPPORT_RECIPIENT="hey@ishaanbedi.com" -------------------------------------------------------------------------------- /.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.js 7 | .yarn/install-state.gz 8 | ./package.lock.json 9 | package.lock.json 10 | 11 | 12 | # testing 13 | /coverage 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | 19 | # production 20 | /build 21 | 22 | # misc 23 | .DS_Store 24 | *.pem 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # local env files 32 | .env*.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | next-env.d.ts 40 | 41 | 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ishaan Bedi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/api/public-script/route.js: -------------------------------------------------------------------------------- 1 | import { UAParser } from "ua-parser-js"; 2 | import { createServerClient } from "@supabase/ssr"; 3 | import { cookies } from "next/headers"; 4 | import { NextResponse } from "next/server"; 5 | export async function POST(request) { 6 | const cookieStore = cookies(); 7 | const supabase = createServerClient( 8 | process.env.NEXT_PUBLIC_SUPABASE_URL, 9 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, 10 | { 11 | cookies: { 12 | get(name) { 13 | return cookieStore.get(name)?.value; 14 | }, 15 | }, 16 | }, 17 | ); 18 | const data = await request.json(); 19 | if (!data.userAgents || !data.country || !data.id || !data.domain) { 20 | return NextResponse.json({ error: "Missing data" }, { status: 400 }); 21 | } 22 | if (data.userAgents.length === 0) { 23 | return NextResponse.json({ error: "No user agents" }, { status: 400 }); 24 | } 25 | const { userAgents, country, id } = data; 26 | const ua = UAParser(userAgents); 27 | const browser = ua.browser.name; 28 | const os = ua.os.name; 29 | const device = ua.device.type || "desktop"; 30 | const { data: website } = await supabase 31 | .from("site_domains") 32 | .select("*") 33 | .eq("domain_name", data.domain); 34 | if (!website || website.length === 0) { 35 | return NextResponse.json({ error: "Website not found" }, { status: 404 }); 36 | } 37 | var found = false; 38 | for (let i = 0; i < website.length; i++) { 39 | if (website[i].website_id === id) { 40 | found = true; 41 | break; 42 | } 43 | } 44 | if (!found) { 45 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 46 | } 47 | const { error } = await supabase.from("analytics").insert({ 48 | id: id, 49 | path: data.path, 50 | browser: browser, 51 | referrer: data.referrer, 52 | os: os, 53 | device: device, 54 | country: country, 55 | website_id: data.id, 56 | domain: data.domain, 57 | language: data.language, 58 | }); 59 | if (error) { 60 | console.error("error adding analytics:", error); 61 | return NextResponse.json(error, { status: 500 }); 62 | } 63 | return NextResponse.json(data, { status: 200 }); 64 | } 65 | -------------------------------------------------------------------------------- /app/api/support-email/route.ts: -------------------------------------------------------------------------------- 1 | import { Resend } from "resend"; 2 | const resend = new Resend(process.env.RESEND_EMAIL); 3 | import { NextRequest, NextResponse } from "next/server"; 4 | import { headers } from "next/headers"; 5 | 6 | export async function POST(request: NextRequest) { 7 | const data = await request.json(); 8 | const headersList = headers(); 9 | const key = headersList.get("supabase-verified"); 10 | if (key !== process.env.SUPABASE_WEBHOOK_HEADER_KEY) { 11 | return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); 12 | } 13 | try { 14 | const html = ` 15 |

New Support Request

16 |

Name: ${data.record.name}

17 |

Email: ${data.record.email}

18 |

Query Type: ${data.record.query_type}

19 |

Message: ${data.record.message}

20 | `; 21 | await resend.emails.send({ 22 | from: `${process.env.NEXT_PUBLIC_SUPPORT_EMAIL}`, 23 | to: `${process.env.SUPPORT_RECIPIENT}`, 24 | subject: "New Support Request", 25 | html: html, 26 | }); 27 | } catch (error) { 28 | console.error(error); 29 | return NextResponse.json({ message: "Error sending email" }); 30 | } 31 | return NextResponse.json({ message: "Email sent" }); 32 | } 33 | -------------------------------------------------------------------------------- /app/auth/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/utils/supabase/server"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export async function GET(request: Request) { 5 | const requestUrl = new URL(request.url); 6 | const code = requestUrl.searchParams.get("code"); 7 | const origin = requestUrl.origin; 8 | 9 | if (code) { 10 | const supabase = createClient(); 11 | await supabase.auth.exchangeCodeForSession(code); 12 | } 13 | 14 | return NextResponse.redirect(`${origin}/`); 15 | } 16 | -------------------------------------------------------------------------------- /app/dashboard/[slug]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderCircle } from "lucide-react"; 2 | const Loading = async () => { 3 | return ( 4 |
5 |
6 | 7 |
8 |
9 | ); 10 | } 11 | 12 | export default Loading; -------------------------------------------------------------------------------- /app/dashboard/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/utils/supabase/server"; 2 | import AnalyticsPage from "../../../components/AnalyticsPage"; 3 | import { redirect } from "next/navigation"; 4 | import Navbar from "@/components/Navbar"; 5 | import { Button } from "@/components/ui/button"; 6 | import Link from "next/link"; 7 | import { type Metadata } from "next"; 8 | export const metadata: Metadata = { 9 | title: 'Analytics', 10 | }; 11 | export default async function SitesDynamicPage({ 12 | params, 13 | }: { 14 | params: { slug: string }; 15 | }) { 16 | const supabase = createClient(); 17 | const { 18 | data: { user }, 19 | } = await supabase.auth.getUser(); 20 | if (!user) { 21 | return redirect("/login"); 22 | } else { 23 | const email = user.email; 24 | console.log(params.slug) 25 | const { data, error } = await supabase 26 | .from("site_domains") 27 | .select("domain_name, owner_id, public_url") 28 | .eq("website_id", params.slug) 29 | .eq("owner_id", user.id) 30 | .single() 31 | console.log(data) 32 | if (error) { 33 | return ( 34 |
35 |

Site Not Found

36 |

37 | Possible reasons could be that the site does not exist or you do not 38 | have access to it. 39 |

40 | 41 | 42 | 43 |
44 | ); 45 | } 46 | 47 | if (data.owner_id !== user.id) { 48 | return ( 49 |
50 |

Not Found

51 |

52 | Looks like you do not have access to this site. 53 |

54 |
55 | ); 56 | } 57 | return ( 58 |
59 | 60 | 61 |
62 | ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/utils/supabase/server"; 2 | import { redirect } from "next/navigation"; 3 | import SitesList from "../../components/SitesList"; 4 | import Navbar from "@/components/Navbar"; 5 | import { type Metadata } from "next"; 6 | export const metadata: Metadata = { 7 | title: 'Dashboard', 8 | }; 9 | export default async function SitesPage() { 10 | const supabase = createClient(); 11 | const { 12 | data: { user }, 13 | } = await supabase.auth.getUser(); 14 | if (!user) { 15 | return redirect("/login"); 16 | } 17 | return ( 18 |
19 | 20 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/features/page.tsx: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/utils/supabase/server"; 2 | import { Features } from "@/components/features"; 3 | import Navbar from "@/components/Navbar"; 4 | import { PrivacyFeatures } from "@/components/PrivacyFeatures"; 5 | import { OpenSourced } from "@/components/OpenSource"; 6 | import { Divider } from "@tremor/react"; 7 | import { type Metadata } from "next"; 8 | export const metadata: Metadata = { 9 | title: 'Features', 10 | description: `Exlpore the features of ${process.env.NEXT_PUBLIC_SITE_NAME}.` 11 | }; 12 | export default async function FeaturesPage() { 13 | const supabase = createClient(); 14 | const { 15 | data: { user }, 16 | } = await supabase.auth.getUser(); 17 | return ( 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | 78 | html { 79 | scroll-behavior: smooth; 80 | } 81 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Bricolage_Grotesque } from "next/font/google"; 3 | import { Comfortaa } from "next/font/google"; 4 | import "./styles.css"; 5 | import { GeistSans } from "geist/font/sans"; 6 | import "./globals.css"; 7 | import { Toaster } from "@/components/ui/sonner"; 8 | import Footer from "@/components/Footer"; 9 | import Head from "next/head"; 10 | export const metadata: Metadata = { 11 | metadataBase: new URL("https://woyage.io"), 12 | title: { 13 | default: "Woyage", 14 | template: "Woyage | %s", 15 | }, 16 | description: 17 | "An open-sourced & privacy-focused analytics platform for your website.", 18 | openGraph: { 19 | title: "Woyage", 20 | description: 21 | "An open-sourced & privacy-focused analytics platform for your website.", 22 | url: "https://woyage.io", 23 | siteName: "Woyage", 24 | locale: "en_US", 25 | type: "website", 26 | images: [ 27 | { 28 | url: "https://www.woyage.app/og.png", 29 | }, 30 | ], 31 | }, 32 | twitter: { 33 | title: "Woyage", 34 | card: "summary_large_image", 35 | }, 36 | }; 37 | 38 | const bricolage_grotesque = Bricolage_Grotesque({ 39 | subsets: ["latin"], 40 | display: "swap", 41 | variable: "--font-bricolage_grotesque", 42 | }); 43 | const comfortaa = Comfortaa({ 44 | subsets: ["latin"], 45 | display: "swap", 46 | variable: "--font-comfortaa", 47 | }); 48 | export default function RootLayout({ 49 | children, 50 | }: { 51 | children: React.ReactNode; 52 | }) { 53 | return ( 54 | 55 | 56 | 57 | 58 | 59 |
{children}
60 |