diff --git a/.env.example b/.env.example index 269c048b..9fa0bef4 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,10 @@ -DATABASE_URL='postgresql://:@:' +DATABASE_URL='postgresql://:@:/' GOOGLE_API_CREDENTIALS='secret' NEXTAUTH_URL='http://localhost:3000' +# Remove this var if you don't want Calendso to collect anonymous usage +NEXT_PUBLIC_TELEMETRY_KEY=js.2pvs2bbpqq1zxna97wcml.oi2jzirnbj1ev4tc57c5r + # Used for the Office 365 / Outlook.com Calendar integration MS_GRAPH_CLIENT_ID= MS_GRAPH_CLIENT_SECRET= \ No newline at end of file diff --git a/components/Shell.tsx b/components/Shell.tsx index bafbd386..e0a8fe0f 100644 --- a/components/Shell.tsx +++ b/components/Shell.tsx @@ -1,14 +1,22 @@ import Link from 'next/link'; -import { useState } from "react"; +import {useContext, useEffect, useState} from "react"; import { useRouter } from "next/router"; import { signOut, useSession } from 'next-auth/client'; import { MenuIcon, XIcon } from '@heroicons/react/outline'; +import {TelemetryContext, useTelemetry} from "../lib/telemetry"; export default function Shell(props) { const router = useRouter(); const [ session, loading ] = useSession(); const [ profileDropdownExpanded, setProfileDropdownExpanded ] = useState(false); const [ mobileMenuExpanded, setMobileMenuExpanded ] = useState(false); + let telemetry = useTelemetry(); + + useEffect(() => { + telemetry.withJitsu((jitsu) => { + return jitsu.track('page_view', {page_url: router.pathname}) + }); + }, [telemetry]) const toggleProfileDropdown = () => { setProfileDropdownExpanded(!profileDropdownExpanded); diff --git a/lib/telemetry.ts b/lib/telemetry.ts new file mode 100644 index 00000000..5cc38163 --- /dev/null +++ b/lib/telemetry.ts @@ -0,0 +1,62 @@ +import React, {useContext} from 'react' +import {jitsuClient, JitsuClient} from "@jitsu/sdk-js"; + + +/** + * Telemetry client + */ +export type TelemetryClient = { + /** + * Use it as: withJitsu((jitsu) => {return jitsu.track()}). If telemetry is disabled, the callback will ignored + * + * ATTENTION: always return the value of jitsu.track() or id() call. Otherwise unhandled rejection can happen, + * which is handled in Next.js with a popup. + */ + withJitsu: (callback: (jitsu: JitsuClient) => void | Promise) => void +} + +const emptyClient: TelemetryClient = {withJitsu: () => {}}; + +function useTelemetry(): TelemetryClient { + return useContext(TelemetryContext); +} + +function createTelemetryClient(): TelemetryClient { + if (process.env.NEXT_PUBLIC_TELEMETRY_KEY) { + return { + withJitsu: (callback) => { + if (!process.env.NEXT_PUBLIC_TELEMETRY_KEY) { + //telemetry is disabled + return; + } + if (!window) { + console.warn("Jitsu has been called during SSR, this scenario isn't supported yet"); + return; + } else if (!window['jitsu']) { + window['jitsu'] = jitsuClient({ + log_level: 'ERROR', + tracking_host: "https://t.calendso.com", + key: "js.2pvs2bbpqq1zxna97wcml.oi2jzirnbj1ev4tc57c5r", + cookie_name: "__clnds", + capture_3rd_party_cookies: false, + }); + } + let res = callback(window['jitsu']); + if (res && typeof res['catch'] === "function") { + res.catch(e => { + console.debug("Unable to send telemetry event", e) + }); + } + } + } + } else { + return emptyClient; + } +} + + +const TelemetryContext = React.createContext(emptyClient) + +const TelemetryProvider = TelemetryContext.Provider + +export { TelemetryContext, TelemetryProvider, createTelemetryClient, useTelemetry }; diff --git a/next.config.js b/next.config.js index 414aee90..9ffb8597 100644 --- a/next.config.js +++ b/next.config.js @@ -1,5 +1,20 @@ const withTM = require('next-transpile-modules')(['react-timezone-select']); +const validJson = (jsonString) => { + try { + const o = JSON.parse(jsonString); + if (o && typeof o === "object") { + return o; + } + } + catch (e) {} + return false; +} + +if (process.env.GOOGLE_API_CREDENTIALS && ! validJson(process.env.GOOGLE_API_CREDENTIALS)) { + console.warn('\x1b[33mwarn', '\x1b[0m', "- Disabled 'Google Calendar' integration. Reason: Invalid value for GOOGLE_API_CREDENTIALS environment variable. When set, this value needs to contain valid JSON like {\"web\":{\"client_id\":\"\",\"client_secret\":\"\",\"redirect_uris\":[\"/api/integrations/googlecalendar/callback>\"]}. You can download this JSON from your OAuth Client @ https://console.cloud.google.com/apis/credentials."); +} + module.exports = withTM({ future: { webpack5: true, diff --git a/package.json b/package.json index fef9125b..f881d951 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@headlessui/react": "^1.0.0", "@heroicons/react": "^1.0.1", + "@jitsu/sdk-js": "^2.0.0", "@prisma/client": "2.21.2", "@tailwindcss/forms": "^0.2.1", "bcryptjs": "^2.4.3", diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx index 36e9f435..ec8398eb 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -16,6 +16,7 @@ dayjs.extend(utc); dayjs.extend(timezone); import getSlots from '../../lib/slots'; +import {useTelemetry} from "../../lib/telemetry"; function classNames(...classes) { return classes.filter(Boolean).join(' ') @@ -29,6 +30,7 @@ export default function Type(props) { const [isTimeOptionsOpen, setIsTimeOptionsOpen] = useState(false); const [is24h, setIs24h] = useState(false); const [busy, setBusy] = useState([]); + const telemetry = useTelemetry(); // Get router variables const router = useRouter(); @@ -66,7 +68,6 @@ export default function Type(props) { for (let i = 1; i <= daysInMonth; i++) { days.push(i); } - // Create placeholder elements for empty days in first week const weekdayOfFirst = dayjs().month(selectedMonth).date(1).day(); @@ -78,7 +79,10 @@ export default function Type(props) { // Combine placeholder days with actual days const calendar = [...emptyDays, ...days.map((day) => - )]; diff --git a/pages/[user]/book.tsx b/pages/[user]/book.tsx index 2ec3e924..faef74b8 100644 --- a/pages/[user]/book.tsx +++ b/pages/[user]/book.tsx @@ -3,14 +3,21 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; import { ClockIcon, CalendarIcon } from '@heroicons/react/solid'; import prisma from '../../lib/prisma'; +import {useTelemetry} from "../../lib/telemetry"; +import {useEffect} from "react"; const dayjs = require('dayjs'); export default function Book(props) { const router = useRouter(); const { date, user } = router.query; + const telemetry = useTelemetry(); + useEffect(() => { + telemetry.withJitsu(jitsu => jitsu.track('time_selected')); + }) const bookingHandler = event => { event.preventDefault(); + telemetry.withJitsu(jitsu => jitsu.track('booking_confirmed')); const res = fetch( '/api/book/' + user, { @@ -59,13 +66,13 @@ export default function Book(props) {
- +
- +
diff --git a/pages/_app.tsx b/pages/_app.tsx index 2bbf669d..e2964dde 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,11 +1,14 @@ import '../styles/globals.css'; +import {createTelemetryClient, TelemetryProvider} from '../lib/telemetry'; import { Provider } from 'next-auth/client'; function MyApp({ Component, pageProps }) { return ( - - - + + + + + ); } diff --git a/pages/integrations/index.tsx b/pages/integrations/index.tsx index edb01a29..76e16a34 100644 --- a/pages/integrations/index.tsx +++ b/pages/integrations/index.tsx @@ -7,7 +7,7 @@ import { useSession, getSession } from 'next-auth/client'; import { CheckCircleIcon, XCircleIcon, ChevronRightIcon, PlusIcon } from '@heroicons/react/solid'; import { InformationCircleIcon } from '@heroicons/react/outline'; -export default function Home(props) { +export default function Home({ integrations }) { const [session, loading] = useSession(); const [showAddModal, setShowAddModal] = useState(false); @@ -24,7 +24,7 @@ export default function Home(props) { } function integrationHandler(type) { - fetch('/api/integrations/' + type + '/add') + fetch('/api/integrations/' + type.replace('_', '') + '/add') .then((response) => response.json()) .then((data) => window.location.href = data.url); } @@ -38,73 +38,63 @@ export default function Home(props) {
- - {props.credentials.length == 0 && -
-
-
- + : +
+
+
+ +
+
+

+ You don't have any integrations added. +

+
+

+ You currently do not have any integrations set up. Add your first integration to get started. +

-
-

- You don't have any integrations added. -

-
-

- You currently do not have any integrations set up. Add your first integration to get started. -

-
-
- -
+
+
+
}
{showAddModal && @@ -150,30 +140,18 @@ export default function Home(props) {
    -
  • + {integrations.filter( (integration) => integration.installed ).map( (integration) => (
  • - Office 365 / Outlook.com Calendar + {integration.title}
    -

    Office 365 / Outlook.com Calendar

    -

    For personal and business accounts

    +

    { integration.title }

    +

    { integration.description }

    - +
    -
  • -
  • -
    - Google Calendar -
    -
    -

    Google Calendar

    -

    For personal and business accounts

    -
    -
    - -
    -
  • + ))}
@@ -190,6 +168,17 @@ export default function Home(props) { ); } +const validJson = (jsonString: string) => { + try { + const o = JSON.parse(jsonString); + if (o && typeof o === "object") { + return o; + } + } + catch (e) {} + return false; +} + export async function getServerSideProps(context) { const session = await getSession(context); @@ -212,7 +201,24 @@ export async function getServerSideProps(context) { key: true } }); + + const integrations = [ { + installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)), + credential: credentials.find( (integration) => integration.type === "google_calendar" ) || null, + type: "google_calendar", + title: "Google Calendar", + imageSrc: "integrations/google-calendar.png", + description: "For personal and business accounts", + }, { + installed: !!(process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET), + type: "office365_calendar", + credential: credentials.find( (integration) => integration.type === "office365_calendar" ) || null, + title: "Office 365 / Outlook.com Calendar", + imageSrc: "integrations/office-365.png", + description: "For personal and business accounts", + } ]; + return { - props: {credentials}, // will be passed to the page component as props + props: {integrations}, } -} +} \ No newline at end of file diff --git a/pages/settings/password.tsx b/pages/settings/password.tsx index 8644d3c7..df8ec35a 100644 --- a/pages/settings/password.tsx +++ b/pages/settings/password.tsx @@ -74,9 +74,6 @@ export default function Settings(props) {

- diff --git a/pages/settings/profile.tsx b/pages/settings/profile.tsx index e536f8b1..17b4624c 100644 --- a/pages/settings/profile.tsx +++ b/pages/settings/profile.tsx @@ -145,9 +145,6 @@ export default function Settings(props) {

- @@ -181,4 +178,4 @@ export async function getServerSideProps(context) { return { props: {user}, // will be passed to the page component as props } -} +} \ No newline at end of file diff --git a/pages/success.tsx b/pages/success.tsx index 78c7431a..fd1c2407 100644 --- a/pages/success.tsx +++ b/pages/success.tsx @@ -74,7 +74,7 @@ export default function Success(props) {
Add to your calendar
- Google diff --git a/yarn.lock b/yarn.lock index 36257ba0..62cb3042 100644 --- a/yarn.lock +++ b/yarn.lock @@ -168,6 +168,11 @@ crypto-js "^4.0.0" require_optional "^1.0.1" typeorm "^0.2.30" + +"@jitsu/sdk-js@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@jitsu/sdk-js/-/sdk-js-2.0.0.tgz#01ef96c602b3b2aa1e1a4bf87e868f7a5bfe3b35" + integrity sha512-+IQLEbzrIpuXKmP2bLbD7eAdF1WaEcZ2eaSMl6AAwcE0BEFctjNG8QhQYWsMgb+KahNKFz1ARll3aJegkqgrew== "@next/env@10.2.0": version "10.2.0"