├── .prettierrc.json ├── public ├── favicon.ico └── _static │ └── landing_graphic.png ├── postcss.config.js ├── src ├── assets │ └── fonts │ │ └── CalSans-SemiBold.otf ├── app │ ├── globals.css │ ├── app │ │ ├── settings │ │ │ ├── billing │ │ │ │ ├── layout.tsx │ │ │ │ ├── usage.tsx │ │ │ │ └── page.tsx │ │ │ ├── integrations │ │ │ │ ├── layout.tsx │ │ │ │ ├── how-to │ │ │ │ │ ├── cal │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── calendly │ │ │ │ │ │ └── page.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── google-calendar-integration.tsx │ │ │ │ ├── zoom-integration.tsx │ │ │ │ ├── calendar-tools.tsx │ │ │ │ ├── modal-form.tsx │ │ │ │ ├── no-calendar-tool.tsx │ │ │ │ └── integration-card.tsx │ │ │ ├── settings-container.tsx │ │ │ ├── availability │ │ │ │ ├── page.tsx │ │ │ │ ├── google-calendar-alert.tsx │ │ │ │ ├── availability-table.tsx │ │ │ │ └── form.tsx │ │ │ ├── page.tsx │ │ │ ├── notifications │ │ │ │ ├── page.tsx │ │ │ │ └── notification-provider.tsx │ │ │ ├── navigation.tsx │ │ │ ├── layout.tsx │ │ │ ├── layout-settings.tsx │ │ │ └── form.tsx │ │ ├── getting-started │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── form.tsx │ │ ├── (auth) │ │ │ ├── layout.tsx │ │ │ ├── signin │ │ │ │ ├── page.tsx │ │ │ │ └── form.tsx │ │ │ └── signup │ │ │ │ ├── page.tsx │ │ │ │ └── form.tsx │ │ └── (home) │ │ │ ├── greeter.tsx │ │ │ ├── navigation.tsx │ │ │ ├── call-log │ │ │ ├── copy-profile-link.tsx │ │ │ ├── page.tsx │ │ │ └── call-log-table.tsx │ │ │ ├── layout.tsx │ │ │ ├── do-not-disturb-toggle.tsx │ │ │ ├── incoming-calls.tsx │ │ │ ├── page.tsx │ │ │ └── incoming-call-card.tsx │ ├── jumpcal.io │ │ ├── layout.tsx │ │ ├── [username] │ │ │ ├── (embeds) │ │ │ │ ├── calendly-embed.tsx │ │ │ │ └── cal-embed.tsx │ │ │ ├── own-profile-banner.tsx │ │ │ ├── not-found.tsx │ │ │ ├── user-header.tsx │ │ │ └── page.tsx │ │ ├── navigation.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── providers.tsx │ └── api │ │ ├── stripe │ │ ├── billing │ │ │ └── route.ts │ │ ├── callback │ │ │ └── route.ts │ │ └── upgrade │ │ │ └── route.ts │ │ ├── qstash │ │ ├── update-call-status │ │ │ └── route.ts │ │ └── send-email-call-recipient │ │ │ └── route.ts │ │ └── integration │ │ ├── google │ │ └── connect │ │ │ └── route.ts │ │ └── zoom │ │ └── connect │ │ └── route.ts ├── lib │ ├── fonts.ts │ ├── premium.ts │ ├── hooks │ │ ├── use-scroll.ts │ │ └── use-media-query.ts │ ├── resend │ │ ├── footer.tsx │ │ ├── index.ts │ │ └── emails │ │ │ ├── welcome-email.tsx │ │ │ └── call-survey.tsx │ ├── planetscale.ts │ ├── middleware │ │ ├── utils.ts │ │ └── app.ts │ ├── upstash.ts │ ├── utils.ts │ ├── constants │ │ └── cookies.ts │ ├── providers │ │ ├── trpc-provider.tsx │ │ └── incoming-calls-provider.tsx │ ├── stripe.ts │ ├── availability.ts │ └── integrations │ │ ├── zoom.ts │ │ └── google-calendar.ts ├── db.ts ├── components │ ├── ui │ │ ├── skeleton.tsx │ │ ├── icons │ │ │ └── google-icon.tsx │ │ ├── label.tsx │ │ ├── separator-with-text.tsx │ │ ├── separator.tsx │ │ ├── toaster.tsx │ │ ├── progress.tsx │ │ ├── textarea.tsx │ │ ├── tooltip.tsx │ │ ├── modal.tsx │ │ ├── popover.tsx │ │ ├── switch.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── alert.tsx │ │ ├── input.tsx │ │ ├── card.tsx │ │ ├── button.tsx │ │ ├── dialog.tsx │ │ └── use-toast.ts │ └── app │ │ ├── max-width-container.tsx │ │ ├── app-layout.tsx │ │ └── user-dropdown.tsx ├── server │ ├── root.ts │ ├── routes │ │ ├── integration-routes.ts │ │ └── notification-routes.ts │ └── trpc.ts ├── middleware.ts ├── pages │ └── api │ │ ├── trpc │ │ └── [trpc].ts │ │ └── auth │ │ └── [...nextauth].ts └── env.mjs ├── next.config.mjs ├── components.json ├── .env.example ├── .gitignore ├── README.md ├── tsconfig.json ├── .eslintrc.js ├── tailwind.config.ts ├── package.json └── prisma └── schema.prisma /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exception/jumpcal/develop/public/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/_static/landing_graphic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exception/jumpcal/develop/public/_static/landing_graphic.png -------------------------------------------------------------------------------- /src/assets/fonts/CalSans-SemiBold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exception/jumpcal/develop/src/assets/fonts/CalSans-SemiBold.otf -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | // await import('./src/env.mjs'); 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | // reactStrictMode: true, 6 | }; 7 | 8 | export default nextConfig; 9 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | body { 7 | @apply font-sans; 8 | font-feature-settings: 9 | "rlig" 1, 10 | "calt" 1; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/app/settings/billing/layout.tsx: -------------------------------------------------------------------------------- 1 | import { makeMetadata } from "@/lib/utils"; 2 | 3 | export const metadata = makeMetadata({ 4 | title: "Jumpcal - Billing Settings", 5 | }); 6 | 7 | const BillingLayout = ({ children }: React.PropsWithChildren) => { 8 | return <>{children}; 9 | }; 10 | 11 | export default BillingLayout; 12 | -------------------------------------------------------------------------------- /src/app/app/settings/integrations/layout.tsx: -------------------------------------------------------------------------------- 1 | import { makeMetadata } from "@/lib/utils"; 2 | 3 | export const metadata = makeMetadata({ 4 | title: "Jumpcal - Integration Settings", 5 | }); 6 | 7 | const IntegrationLayout = ({ children }: React.PropsWithChildren) => { 8 | return <>{children}; 9 | }; 10 | 11 | export default IntegrationLayout; 12 | -------------------------------------------------------------------------------- /src/app/jumpcal.io/layout.tsx: -------------------------------------------------------------------------------- 1 | import MarketingNavigation from "./navigation"; 2 | 3 | const LandingLayout = ({ children }: React.PropsWithChildren) => { 4 | return ( 5 |
6 | 7 | {children} 8 |
9 | ); 10 | }; 11 | 12 | export default LandingLayout; 13 | -------------------------------------------------------------------------------- /src/lib/fonts.ts: -------------------------------------------------------------------------------- 1 | import { Inter } from "next/font/google"; 2 | import localFont from "next/font/local"; 3 | 4 | export const inter = Inter({ 5 | variable: "--font-inter", 6 | subsets: ["latin"], 7 | display: "swap", 8 | }); 9 | 10 | export const calSans = localFont({ 11 | src: '../assets/fonts/CalSans-SemiBold.otf', 12 | variable: '--font-cal-sans', 13 | }); -------------------------------------------------------------------------------- /src/lib/premium.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "@/db"; 2 | 3 | export const checkPremiumUsername = async (username: string) => { 4 | const user = await prisma.user.findUnique({ 5 | where: { 6 | username, 7 | }, 8 | }); 9 | 10 | if (user) return { available: false }; 11 | 12 | const isPremium = username.length <= 4; 13 | return { available: true, premium: isPremium }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { env } from "@/env.mjs"; 3 | 4 | const globalForPrisma = globalThis as unknown as { 5 | prisma: PrismaClient | undefined; 6 | }; 7 | 8 | export const prisma = 9 | globalForPrisma.prisma ?? 10 | new PrismaClient({ 11 | log: ["error"] 12 | }); 13 | 14 | if (env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; 15 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 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 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
15 | ); 16 | } 17 | 18 | export { Skeleton }; 19 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL= 2 | 3 | GOOGLE_CLIENT_ID= 4 | GOOGLE_CLIENT_SECRET= 5 | 6 | GITHUB_CLIENT_ID= 7 | GITHUB_CLIENT_SECRET= 8 | 9 | NEXTAUTH_SECRET= 10 | 11 | NEXT_PUBLIC_ZOOM_CLIENT_ID= 12 | ZOOM_CLIENT_SECRET= 13 | 14 | CLOUDINARY_URL= 15 | 16 | TWILIO_SID= 17 | TWILIO_AUTH_TOKEN= 18 | TWILIO_SERVICE= 19 | TWILIO_PHONE_NUMBER= 20 | 21 | QSTASH_CLIENT_TOKEN= 22 | QSTASH_CURRENT_SIGNING_KEY= 23 | QSTASH_NEXT_SIGNING_KEY= 24 | 25 | NGROK_URL= -------------------------------------------------------------------------------- /src/components/app/max-width-container.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | interface Props { 4 | className?: string; 5 | } 6 | 7 | const MaxWidthContainer = ({ 8 | className, 9 | children, 10 | }: React.PropsWithChildren) => { 11 | return ( 12 |
18 | {children} 19 |
20 | ); 21 | }; 22 | 23 | export default MaxWidthContainer; 24 | -------------------------------------------------------------------------------- /src/lib/hooks/use-scroll.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | 3 | export const useScroll = (threshold: number) => { 4 | const [scrolled, setScrolled] = useState(false); 5 | 6 | const onScroll = useCallback(() => { 7 | setScrolled(window.pageYOffset > threshold); 8 | }, [threshold]); 9 | 10 | useEffect(() => { 11 | window.addEventListener("scroll", onScroll); 12 | return () => window.removeEventListener("scroll", onScroll); 13 | }, [onScroll]); 14 | 15 | return scrolled; 16 | }; 17 | -------------------------------------------------------------------------------- /src/server/root.ts: -------------------------------------------------------------------------------- 1 | import { callRoutes } from "./routes/call-routes"; 2 | import { integrationRoutes } from "./routes/integration-routes"; 3 | import { notificationRoutes } from "./routes/notification-routes"; 4 | import { userRoutes } from "./routes/user-routes"; 5 | import { createTRPCRouter } from "./trpc"; 6 | 7 | export const appRouter = createTRPCRouter({ 8 | users: userRoutes, 9 | calls: callRoutes, 10 | notifications: notificationRoutes, 11 | integrations: integrationRoutes 12 | }); 13 | 14 | export type AppRouter = typeof appRouter; 15 | -------------------------------------------------------------------------------- /.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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | /src/app/api/test/ 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Jumpcal

2 |

Revolutionizing Call Scheduling. Effortlessly book and manage calls with Jumpcal's intuitive interface. Say goodbye to timezone confusions and back-to-back meetings. Jump right into seamless scheduling today!

3 | 4 | ## Local Development 5 | 6 | To run/work on Jumpcal locally, you will need to clone this repository and set up all the env vars outlined in the `env.mjs` file. 7 | Once that's complete, you can use the following commands to run the app locally: 8 | 9 | ```bash 10 | pnpm 11 | pnpm dev 12 | ``` 13 | -------------------------------------------------------------------------------- /src/app/jumpcal.io/[username]/(embeds)/calendly-embed.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { InlineWidget } from "react-calendly"; 4 | 5 | interface Props { 6 | calendarLink: string; 7 | } 8 | 9 | const CalendlyEmbed = ({ calendarLink }: Props) => { 10 | return ( 11 |
12 |

Schedule a time via Calendly

13 | 19 |
20 | ); 21 | }; 22 | 23 | export default CalendlyEmbed; 24 | -------------------------------------------------------------------------------- /src/app/app/getting-started/layout.tsx: -------------------------------------------------------------------------------- 1 | import JumpcalLogoFull from "@/components/ui/icons/jumpcal-logo-full"; 2 | import { makeMetadata } from "@/lib/utils"; 3 | 4 | export const metadata = makeMetadata({ 5 | title: "Jumpcal", 6 | }); 7 | 8 | const OnboardLayout = ({ children }: React.PropsWithChildren) => { 9 | return ( 10 |
11 |
12 | 13 |
14 | {children} 15 |
16 | ); 17 | }; 18 | 19 | export default OnboardLayout; 20 | -------------------------------------------------------------------------------- /src/components/ui/icons/google-icon.tsx: -------------------------------------------------------------------------------- 1 | const GoogleIcon = ({ className }: { className?: string }) => { 2 | return ( 3 | 9 | 10 | 11 | ); 12 | }; 13 | 14 | export default GoogleIcon; 15 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, type NextRequest } from "next/server"; 2 | import AppMiddleware from "@/lib/middleware/app"; 3 | import { isLanding, parse } from "./lib/middleware/utils"; 4 | 5 | export const config = { 6 | matcher: [ 7 | "/((?!api/|_next/|_proxy/|_static|_vercel|favicon.ico|sitemap.xml|robots.txt).*)", 8 | ], 9 | }; 10 | 11 | export default function middleware(req: NextRequest) { 12 | const { domain, path } = parse(req); 13 | 14 | if (isLanding(domain)) { 15 | return NextResponse.rewrite(new URL(`/jumpcal.io${path}`, req.url)); 16 | } 17 | 18 | return AppMiddleware(req); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import JumpcalLogoFull from "@/components/ui/icons/jumpcal-logo-full"; 2 | import Link from "next/link"; 3 | 4 | export const runtime = "edge"; 5 | 6 | const AuthLayout = ({ children }: React.PropsWithChildren) => { 7 | return ( 8 |
9 | 13 | 14 | 15 | {children} 16 |
17 | ); 18 | }; 19 | 20 | export default AuthLayout; 21 | -------------------------------------------------------------------------------- /src/app/app/(home)/greeter.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useMemo } from "react"; 4 | 5 | const Greeter = () => { 6 | const currentDate = useMemo(() => new Date(), []); 7 | const toShow = useMemo(() => { 8 | const hour = currentDate.getHours(); 9 | if (hour < 12) { 10 | return "Good Morning"; 11 | } else if (hour < 18) { 12 | return "Good Afternoon"; 13 | } else { 14 | return "Good Evening"; 15 | } 16 | }, [currentDate]); 17 | 18 | return ( 19 |

20 | {toShow} 👋 21 |

22 | ); 23 | }; 24 | 25 | export default Greeter; 26 | -------------------------------------------------------------------------------- /src/lib/resend/footer.tsx: -------------------------------------------------------------------------------- 1 | import { Hr, Tailwind, Text } from "@react-email/components"; 2 | 3 | interface Props { 4 | intendedFor: string; 5 | showUnsubscribe?: boolean; 6 | } 7 | 8 | const Footer = ({ intendedFor }: Props) => { 9 | return ( 10 | 11 |
12 | 13 | This email was intended for{" "} 14 | {intendedFor}. If you think this 15 | email was not meant for you, please ignore and delete it. 16 | 17 |
18 | ); 19 | }; 20 | 21 | export default Footer; 22 | -------------------------------------------------------------------------------- /src/app/app/settings/settings-container.tsx: -------------------------------------------------------------------------------- 1 | import { Separator } from "@/components/ui/separator"; 2 | 3 | interface Props { 4 | title: string; 5 | description: string; 6 | } 7 | 8 | const SettingsContainer = ({ 9 | title, 10 | description, 11 | children, 12 | }: React.PropsWithChildren) => { 13 | return ( 14 |
15 |
16 |

{title}

17 |

{description}

18 |
19 | 20 | {children} 21 |
22 | ); 23 | }; 24 | 25 | export default SettingsContainer; 26 | -------------------------------------------------------------------------------- /src/app/app/settings/availability/page.tsx: -------------------------------------------------------------------------------- 1 | import { makeMetadata } from "@/lib/utils"; 2 | import SettingsContainer from "../settings-container"; 3 | import AvailabilityContent from "./form"; 4 | import GoogleCalendarAlert from "./google-calendar-alert"; 5 | 6 | export const metadata = makeMetadata({ 7 | title: "Jumpcal - Availability Settings", 8 | }); 9 | 10 | const AvailabilitySettings = () => { 11 | return ( 12 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default AvailabilitySettings; 23 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | // app/layout.tsx 2 | import NextTopLoader from "nextjs-toploader"; 3 | import "./globals.css"; 4 | 5 | import Providers from "./providers"; 6 | import { inter, calSans } from "@/lib/fonts"; 7 | import { cn, makeMetadata } from "@/lib/utils"; 8 | 9 | export const metadata = makeMetadata(); 10 | 11 | const RootLayout = ({ children }: { children: React.ReactNode }) => { 12 | return ( 13 | 14 | 15 | 16 | {children} 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default RootLayout; 23 | -------------------------------------------------------------------------------- /src/lib/planetscale.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env.mjs"; 2 | import { connect } from "@planetscale/database"; 3 | import { type User } from "@prisma/client"; 4 | 5 | export const pscale_config = { 6 | url: env.DATABASE_URL, 7 | }; 8 | 9 | export const conn = env.DATABASE_URL ? connect(pscale_config) : null; 10 | 11 | // Used only when runtime = 'edge' 12 | export const getUserViaEdge = async ( 13 | username: string, 14 | ): Promise => { 15 | if (!conn) return null; 16 | 17 | const { rows } = await conn.execute("SELECT * FROM User WHERE username = ?", [ 18 | username, 19 | ]); 20 | 21 | return rows && Array.isArray(rows) && rows.length > 0 22 | ? (rows[0] as User) 23 | : null; 24 | }; 25 | -------------------------------------------------------------------------------- /src/pages/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { createNextApiHandler } from "@trpc/server/adapters/next"; 2 | import { env } from "@/env.mjs"; 3 | import { appRouter } from "@/server/root"; 4 | import { createTRPCContext } from "@/server/trpc"; 5 | 6 | export const config = { 7 | api: { 8 | bodyParser: { 9 | sizeLimit: '4mb', 10 | }, 11 | }, 12 | } 13 | 14 | export default createNextApiHandler({ 15 | router: appRouter, 16 | createContext: createTRPCContext, 17 | onError: 18 | env.NODE_ENV === "development" 19 | ? ({ path, error }) => { 20 | console.error( 21 | `❌ tRPC failed on ${path ?? ""}: ${error.message}` 22 | ); 23 | } 24 | : undefined, 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/app/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { makeMetadata } from "@/lib/utils"; 2 | import AccountSettingsForm from "./form"; 3 | import SettingsContainer from "./settings-container"; 4 | import UploadAvatarRow from "./upload-avatar"; 5 | import LayoutSettings from "./layout-settings"; 6 | import UsernameRow from "./username-row"; 7 | 8 | export const metadata = makeMetadata({ 9 | title: "Jumpcal - Account Settings", 10 | }); 11 | 12 | const AccountSettings = () => { 13 | return ( 14 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | }; 25 | 26 | export default AccountSettings; 27 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /src/lib/resend/index.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env.mjs"; 2 | import { type JSXElementConstructor, type ReactElement } from "react"; 3 | import { Resend } from "resend"; 4 | 5 | interface EmailArgs { 6 | to: string; 7 | subject: string; 8 | content: ReactElement>; 9 | isTest?: boolean; 10 | marketing?: boolean; 11 | } 12 | 13 | export const resend = new Resend(env.RESEND_API_KEY); 14 | 15 | export const sendEmail = async ({ 16 | to, 17 | subject, 18 | content, 19 | isTest = false, 20 | marketing, 21 | }: EmailArgs) => { 22 | return resend.emails.send({ 23 | from: marketing 24 | ? "Erik from Jumpcal " 25 | : "Jumpcal ", 26 | to: isTest ? "delivered@resend.dev" : to, 27 | subject, 28 | react: content, 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /src/lib/middleware/utils.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest } from "next/server"; 2 | 3 | const LANDING_DOMAINS = new Set(["landing.localhost:3000", "jumpcal.io"]); 4 | const APP_DOMAINS = new Set(["localhost:3000", "app.jumpcal.io", "preview.jumpcal.io"]); 5 | 6 | export const isLanding = (domain: string) => { 7 | return LANDING_DOMAINS.has(domain) || domain.endsWith(".vercel.app"); 8 | }; 9 | 10 | export const isAppDomain = (domain: string) => { 11 | return APP_DOMAINS.has(domain); 12 | } 13 | 14 | export const parse = (req: NextRequest) => { 15 | let domain = req.headers.get("host")!; 16 | domain = domain.replace("www.", ""); 17 | 18 | const path = req.nextUrl.pathname; 19 | 20 | const key = decodeURIComponent(path.split("/")[1] ?? ""); 21 | const fullKey = decodeURIComponent(path.slice(1)); 22 | 23 | return { domain, path, key, fullKey }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/app/app/settings/availability/google-calendar-alert.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 4 | import { trpc } from "@/lib/providers/trpc-provider"; 5 | import { Info } from "lucide-react"; 6 | 7 | const GoogleCalendarAlert = () => { 8 | const { data, isLoading } = trpc.users.hasIntegration.useQuery({ 9 | type: "CALENDAR_GCAL", 10 | }); 11 | 12 | if (!data || isLoading) { 13 | return <>; 14 | } 15 | 16 | return ( 17 | 18 | 19 | Heads up! 20 | 21 | You have the Google Calendar integration enabled. You will show as busy 22 | if you have an ongoing event! 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default GoogleCalendarAlert; 29 | -------------------------------------------------------------------------------- /src/app/app/settings/notifications/page.tsx: -------------------------------------------------------------------------------- 1 | import { makeMetadata } from "@/lib/utils"; 2 | import SettingsContainer from "../settings-container"; 3 | import SmsNotification from "./sms-notification"; 4 | import { NotificationProvider } from "./notification-provider"; 5 | // import WhatsappNotification from "./whatsapp-notification"; 6 | 7 | export const metadata = makeMetadata({ 8 | title: "Jumpcal - Notification Settings", 9 | }); 10 | 11 | const NotificationSettings = () => { 12 | return ( 13 | 17 | 18 | 19 | {/* */} 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default NotificationSettings; 26 | -------------------------------------------------------------------------------- /src/app/jumpcal.io/[username]/own-profile-banner.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import MaxWidthContainer from "@/components/app/max-width-container"; 4 | import { useSession } from "next-auth/react"; 5 | 6 | interface Props { 7 | id: string; 8 | } 9 | 10 | const OwnProfileBanner = ({ id }: Props) => { 11 | const { data: session } = useSession(); 12 | 13 | if (!session || session.user.id !== id) return <>; 14 | 15 | return ( 16 |
17 | 18 |

19 | You are viewing your own profile. Calling won't be available. 20 |

21 |
22 |
23 | ); 24 | }; 25 | 26 | export default OwnProfileBanner; 27 | -------------------------------------------------------------------------------- /src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Toaster } from "@/components/ui/toaster"; 4 | import { TooltipProvider } from "@/components/ui/tooltip"; 5 | import IncomingCallsProvider from "@/lib/providers/incoming-calls-provider"; 6 | import { TrpcProvider } from "@/lib/providers/trpc-provider"; 7 | import { SessionProvider } from "next-auth/react"; 8 | import { Analytics } from "@vercel/analytics/react"; 9 | 10 | const Providers = ({ children }: React.PropsWithChildren) => { 11 | return ( 12 | 13 | {/* TIMEZONE CHECK */} 14 | 15 | 16 | {children} 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default Providers; 26 | -------------------------------------------------------------------------------- /src/server/routes/integration-routes.ts: -------------------------------------------------------------------------------- 1 | import { APP_URL } from "@/lib/constants"; 2 | import { createTRPCRouter, protectedProcedure } from "../trpc"; 3 | import { google } from "googleapis"; 4 | import { env } from "@/env.mjs"; 5 | 6 | const scopes = ["https://www.googleapis.com/auth/calendar.readonly"]; 7 | 8 | export const integrationRoutes = createTRPCRouter({ 9 | generateGoogleOAuthUrl: protectedProcedure.mutation(() => { 10 | const redirectUri = `${APP_URL}/api/integration/google/connect`; 11 | 12 | const oauth2Client = new google.auth.OAuth2({ 13 | clientId: env.GOOGLE_CLIENT_ID, 14 | clientSecret: env.GOOGLE_CLIENT_SECRET, 15 | redirectUri, 16 | }); 17 | 18 | const authUrl = oauth2Client.generateAuthUrl({ 19 | access_type: "offline", 20 | prompt: "consent", 21 | scope: scopes, 22 | }); 23 | 24 | return authUrl; 25 | }), 26 | }); 27 | -------------------------------------------------------------------------------- /src/components/ui/separator-with-text.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | type SeparatorWithTextProps = { 4 | text: string; 5 | } & React.HTMLAttributes; 6 | 7 | const SeparatorWithText = ({ 8 | text, 9 | className, 10 | ...rest 11 | }: SeparatorWithTextProps) => { 12 | return ( 13 |
14 | 27 | ); 28 | }; 29 | 30 | export default SeparatorWithText; 31 | -------------------------------------------------------------------------------- /src/app/api/stripe/billing/route.ts: -------------------------------------------------------------------------------- 1 | import { APP_URL } from "@/lib/constants"; 2 | import stripe, { getStripeCustomerId } from "@/lib/stripe"; 3 | import { authOptions } from "@/pages/api/auth/[...nextauth]"; 4 | import { getServerSession } from "next-auth"; 5 | 6 | export const dynamic = "force-dynamic"; 7 | 8 | export const GET = async () => { 9 | const session = await getServerSession(authOptions); 10 | if (!session?.user?.id) { 11 | return new Response(null, { status: 403 }); 12 | } 13 | 14 | const customerId = await getStripeCustomerId(session.user); 15 | if (!customerId) { 16 | return Response.redirect( 17 | `${APP_URL}/settings/billing?error=Stripe customer id not found`, 18 | ); 19 | } 20 | 21 | const stripeSession = await stripe.billingPortal.sessions.create({ 22 | customer: customerId, 23 | return_url: `${APP_URL}/settings/billing`, 24 | }); 25 | 26 | return Response.redirect(stripeSession.url); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref, 15 | ) => ( 16 | 27 | ), 28 | ); 29 | Separator.displayName = SeparatorPrimitive.Root.displayName; 30 | 31 | export { Separator }; 32 | -------------------------------------------------------------------------------- /src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Toast, 5 | ToastClose, 6 | ToastDescription, 7 | ToastProvider, 8 | ToastTitle, 9 | ToastViewport, 10 | } from "@/components/ui/toast"; 11 | import { useToast } from "@/components/ui/use-toast"; 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast(); 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ); 31 | })} 32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "checkJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "incremental": true, 18 | "noUncheckedIndexedAccess": true, 19 | "baseUrl": ".", 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | }, 23 | "plugins": [ 24 | { 25 | "name": "next" 26 | } 27 | ] 28 | }, 29 | "include": [ 30 | ".eslintrc.cjs", 31 | "next-env.d.ts", 32 | "**/*.ts", 33 | "**/*.tsx", 34 | "**/*.cjs", 35 | "**/*.mjs", 36 | ".next/types/**/*.ts" 37 | ], 38 | "exclude": ["node_modules"] 39 | } 40 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | const config = { 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | project: true, 6 | }, 7 | plugins: ["@typescript-eslint"], 8 | extends: [ 9 | "next/core-web-vitals", 10 | "plugin:@typescript-eslint/recommended-type-checked", 11 | "plugin:@typescript-eslint/stylistic-type-checked", 12 | ], 13 | rules: { 14 | "@typescript-eslint/consistent-type-imports": [ 15 | "warn", 16 | { 17 | prefer: "type-imports", 18 | fixStyle: "inline-type-imports", 19 | }, 20 | ], 21 | "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], 22 | "@typescript-eslint/no-misused-promises": ["off"], 23 | "@typescript-eslint/consistent-type-definitions": ["off"], 24 | "@typescript-eslint/no-unsafe-assignment": ["off"], 25 | "@typescript-eslint/no-explicit-any": ["off"], 26 | }, 27 | }; 28 | 29 | module.exports = config; 30 | -------------------------------------------------------------------------------- /src/app/app/settings/integrations/how-to/cal/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import SettingsContainer from "../../../settings-container"; 3 | 4 | const HowToCalCom = () => { 5 | return ( 6 | 10 | 27 | ); 28 | }; 29 | 30 | export default HowToCalCom; 31 | -------------------------------------------------------------------------------- /src/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ProgressPrimitive from "@radix-ui/react-progress"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Progress = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, value, ...props }, ref) => ( 12 | 20 | 24 | 25 | )); 26 | Progress.displayName = ProgressPrimitive.Root.displayName; 27 | 28 | export { Progress }; 29 | -------------------------------------------------------------------------------- /src/app/app/settings/integrations/how-to/calendly/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import SettingsContainer from "../../../settings-container"; 3 | 4 | const HowToCalendly = () => { 5 | return ( 6 | 10 | 27 | ); 28 | }; 29 | 30 | export default HowToCalendly; 31 | -------------------------------------------------------------------------------- /src/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export type TextareaProps = React.TextareaHTMLAttributes; 6 | 7 | const Textarea = React.forwardRef( 8 | ({ className, ...props }, ref) => { 9 | return ( 10 |