diff --git a/apps/web/app/admin.dub.co/components/send-thanks.tsx b/apps/web/app/admin.dub.co/components/send-thanks.tsx new file mode 100644 index 0000000000..99800e28d3 --- /dev/null +++ b/apps/web/app/admin.dub.co/components/send-thanks.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { LoadingSpinner } from "@dub/ui"; +import { cn } from "@dub/utils"; +import { useFormStatus } from "react-dom"; +import { toast } from "sonner"; + +export default function SendThanks() { + return ( +
+
{ + if ( + !confirm( + `This will send an email to ${formData.get("email")} with a thank you message. Are you sure?`, + ) + ) { + return; + } + await fetch("/api/admin/send-thanks", { + method: "POST", + body: JSON.stringify({ + email: formData.get("email"), + }), + }).then(async (res) => { + if (res.ok) { + toast.success("Successfully sent email"); + } else { + const error = await res.text(); + toast.error(error); + } + }); + }} + > + +
+
+ ); +} + +const Form = () => { + const { pending } = useFormStatus(); + + return ( +
+ + {pending && ( + + )} +
+ ); +}; diff --git a/apps/web/app/admin.dub.co/page.tsx b/apps/web/app/admin.dub.co/page.tsx index d36c7ba41f..f339c13a92 100644 --- a/apps/web/app/admin.dub.co/page.tsx +++ b/apps/web/app/admin.dub.co/page.tsx @@ -2,6 +2,7 @@ import { constructMetadata } from "@dub/utils"; import ImpersonateUser from "./components/impersonate-user"; import ImpersonateWorkspace from "./components/impersonate-workspace"; import RefreshDomain from "./components/refresh-domain"; +import SendThanks from "./components/send-thanks"; export const metadata = constructMetadata({ title: "Dub Admin", @@ -13,13 +14,13 @@ export default function AdminPage() {

Impersonate User

-

Get a login link for a user.

+

Get a login link for a user

Impersonate Workspace

- Get a login link for the owner of a workspace. + Get a login link for the owner of a workspace

@@ -30,6 +31,11 @@ export default function AdminPage() {

+
+

Send Thanks

+

Send thank you email to a user

+ +
); } diff --git a/apps/web/app/api/admin/send-thanks/route.ts b/apps/web/app/api/admin/send-thanks/route.ts new file mode 100644 index 0000000000..5b0b0b9b94 --- /dev/null +++ b/apps/web/app/api/admin/send-thanks/route.ts @@ -0,0 +1,23 @@ +import { withAdmin } from "@/lib/auth"; +import { sendEmail } from "emails"; +import UpgradeEmail from "emails/upgrade-email"; +import { NextResponse } from "next/server"; + +// POST /api/admin/send-thanks +export const POST = withAdmin(async ({ req }) => { + const { email } = await req.json(); + + await sendEmail({ + email, + subject: "Thank you for upgrading to Dub.co Pro!", + react: UpgradeEmail({ + name: null, + email, + plan: "pro", + }), + marketing: true, + bcc: process.env.TRUSTPILOT_BCC_EMAIL, + }); + + return NextResponse.json({ success: true }); +}); diff --git a/apps/web/emails/index.ts b/apps/web/emails/index.ts index f9b3cc59fa..19f0def350 100644 --- a/apps/web/emails/index.ts +++ b/apps/web/emails/index.ts @@ -10,6 +10,7 @@ export const sendEmail = async ({ email, subject, from, + bcc, text, react, marketing, @@ -17,6 +18,7 @@ export const sendEmail = async ({ email: string; subject: string; from?: string; + bcc?: string; text?: string; react?: ReactElement>; marketing?: boolean; @@ -44,6 +46,7 @@ export const sendEmail = async ({ ? "system@dub.co" : `${process.env.NEXT_PUBLIC_APP_NAME} `, To: email, + Bcc: bcc, ReplyTo: process.env.NEXT_PUBLIC_IS_DUB ? "support@dub.co" : `support@${process.env.NEXT_PUBLIC_APP_DOMAIN}`, diff --git a/apps/web/lib/zod/schemas/analytics.ts b/apps/web/lib/zod/schemas/analytics.ts index a767e4a937..5416a08621 100644 --- a/apps/web/lib/zod/schemas/analytics.ts +++ b/apps/web/lib/zod/schemas/analytics.ts @@ -92,26 +92,92 @@ export const analyticsQuerySchema = z.object({ .optional() .describe("The country to retrieve analytics for.") .openapi({ ref: "countryCode" }), - city: z.string().optional().describe("The city to retrieve analytics for."), + city: z + .string() + .optional() + .describe("The city to retrieve analytics for.") + .openapi({ + examples: [ + "New York", + "Los Angeles", + "Chicago", + "Houston", + "Phoenix", + "Philadelphia", + "San Antonio", + "San Diego", + "Dallas", + "Tokyo", + "Delhi", + "Shanghai", + "São Paulo", + "Mumbai", + "Beijing", + ], + }), device: z .string() .optional() .transform((v) => capitalize(v) as string | undefined) - .describe("The device to retrieve analytics for."), + .describe("The device to retrieve analytics for.") + .openapi({ + examples: ["Desktop", "Mobile", "Tablet", "Wearable", "Smarttv"], + }), browser: z .string() .optional() .transform((v) => capitalize(v) as string | undefined) - .describe("The browser to retrieve analytics for."), + .describe("The browser to retrieve analytics for.") + .openapi({ + examples: [ + "Chrome", + "Mobile Safari", + "Edge", + "Instagram", + "Firefox", + "Facebook", + "WebKit", + "Samsung Browser", + "Chrome WebView", + "Safari", + "Opera", + "IE", + "Yandex", + ], + }), os: z .string() .optional() .transform((v) => capitalize(v) as string | undefined) - .describe("The OS to retrieve analytics for."), + .describe("The OS to retrieve analytics for.") + .openapi({ + examples: [ + "Windows", + "iOS", + "Android", + "Mac OS", + "Linux", + "Ubuntu", + "Chromium OS", + "Fedora", + ], + }), referer: z .string() .optional() - .describe("The referer to retrieve analytics for."), + .describe("The referer to retrieve analytics for.") + .openapi({ + examples: [ + "(direct)", + "t.co", + "youtube.com", + "perplexity.ai", + "l.instagram.com", + "m.facebook.com", + "linkedin.com", + "google.com", + ], + }), url: z.string().optional().describe("The URL to retrieve analytics for."), tagId: z .string() diff --git a/apps/web/ui/analytics/referer-icon.tsx b/apps/web/ui/analytics/referer-icon.tsx new file mode 100644 index 0000000000..7458a4042f --- /dev/null +++ b/apps/web/ui/analytics/referer-icon.tsx @@ -0,0 +1,20 @@ +import { cn } from "@dub/utils"; +import { Link2 } from "lucide-react"; +import LinkLogo from "../links/link-logo"; + +export default function RefererIcon({ + display, + className, +}: { + display: string; + className?: string; +}) { + return display === "(direct)" ? ( + + ) : ( + + ); +} diff --git a/apps/web/ui/analytics/referer.tsx b/apps/web/ui/analytics/referer.tsx index cbbef5ebed..5839592cb6 100644 --- a/apps/web/ui/analytics/referer.tsx +++ b/apps/web/ui/analytics/referer.tsx @@ -1,96 +1,11 @@ -import { BlurImage, Modal, useRouterStuff } from "@dub/ui"; +import { BlurImage, useRouterStuff } from "@dub/ui"; import { GOOGLE_FAVICON_URL } from "@dub/utils"; -import { Link2, Maximize } from "lucide-react"; -import { useState } from "react"; +import { Link2 } from "lucide-react"; import { AnalyticsCard } from "./analytics-card"; import { AnalyticsLoadingSpinner } from "./analytics-loading-spinner"; import BarList from "./bar-list"; import { useAnalyticsFilterOption } from "./utils"; -function RefererOld() { - const data = useAnalyticsFilterOption("referers"); - - const { queryParams } = useRouterStuff(); - const [showModal, setShowModal] = useState(false); - - const barList = (limit?: number) => ( - ({ - icon: - d.referer === "(direct)" ? ( - - ) : ( - - ), - title: d.referer, - href: queryParams({ - set: { - referer: d.referer, - }, - getNewPath: true, - }) as string, - value: d.count || 0, - })) || [] - } - maxValue={(data && data[0]?.count) || 0} - barBackground="bg-red-100" - hoverBackground="bg-red-100/50" - setShowModal={setShowModal} - {...(limit && { limit })} - /> - ); - - return ( - <> - -
-

Referers

-
- {barList()} -
-
-
-

Referers

-
- {data ? ( - data.length > 0 ? ( - barList(9) - ) : ( -
-

No data available

-
- ) - ) : ( -
- -
- )} - {data && data.length > 9 && ( - - )} -
- - ); -} - export default function Referer() { const { queryParams } = useRouterStuff(); diff --git a/apps/web/ui/analytics/toggle.tsx b/apps/web/ui/analytics/toggle.tsx index 94ed347f7d..4c9c521195 100644 --- a/apps/web/ui/analytics/toggle.tsx +++ b/apps/web/ui/analytics/toggle.tsx @@ -30,6 +30,7 @@ import { MobilePhone, OfficeBuilding, QRCode, + ReferredVia, Tag, Window, } from "@dub/ui/src/icons"; @@ -38,6 +39,7 @@ import { COUNTRIES, DUB_LOGO, GOOGLE_FAVICON_URL, + capitalize, cn, getApexDomain, getNextPlan, @@ -58,6 +60,7 @@ import LinkLogo from "../links/link-logo"; import { COLORS_LIST } from "../links/tag-badge"; import DeviceIcon from "./device-icon"; import ExportButton from "./export-button"; +import RefererIcon from "./referer-icon"; import SharePopover from "./share-popover"; import { useAnalyticsFilterOption } from "./utils"; @@ -72,14 +75,24 @@ export default function Toggle() { const scrolled = useScroll(80); const { tags } = useTags(); - const { allDomains: domains } = useDomains(); + const { allDomains: domains, primaryDomain } = useDomains(); const { links: allLinks } = useLinks(); const [requestedFilters, setRequestedFilters] = useState([]); const activeFilters = useMemo(() => { - const { domain, tagId, qr, country, city, device, browser, os, key } = - searchParamsObj; + const { + domain, + key, + tagId, + qr, + country, + city, + device, + browser, + os, + referer, + } = searchParamsObj; return [ ...(domain && !key ? [{ key: "domain", value: domain }] : []), ...(domain && key @@ -92,10 +105,11 @@ export default function Toggle() { ...(device ? [{ key: "device", value: device }] : []), ...(browser ? [{ key: "browser", value: browser }] : []), ...(os ? [{ key: "os", value: os }] : []), + ...(referer ? [{ key: "referer", value: referer }] : []), ]; }, [searchParamsObj]); - const isEnabled = useCallback( + const isRequested = useCallback( (key: string) => requestedFilters.includes(key) || activeFilters.some((af) => af.key === key), @@ -103,49 +117,77 @@ export default function Toggle() { ); const links = useAnalyticsFilterOption("top_links", { - enabled: isEnabled("link"), + cacheOnly: !isRequested("link"), }); const countries = useAnalyticsFilterOption("countries", { - enabled: isEnabled("country"), + cacheOnly: !isRequested("country"), }); const cities = useAnalyticsFilterOption("cities", { - enabled: isEnabled("city"), + cacheOnly: !isRequested("city"), }); const devices = useAnalyticsFilterOption("devices", { - enabled: isEnabled("device"), + cacheOnly: !isRequested("device"), }); const browsers = useAnalyticsFilterOption("browsers", { - enabled: isEnabled("browser"), + cacheOnly: !isRequested("browser"), }); const os = useAnalyticsFilterOption("os", { - enabled: isEnabled("os"), + cacheOnly: !isRequested("os"), + }); + const referers = useAnalyticsFilterOption("referers", { + cacheOnly: !isRequested("referers"), }); + // Some suggestions will only appear if previously requested (see isRequested above) + const aiFilterSuggestions = useMemo( + () => [ + ...(isPublicStatsPage + ? [] + : [ + { + value: `Clicks on ${primaryDomain} domain this year`, + icon: Globe, + }, + ]), + { + value: "Mobile users, US only", + icon: MobilePhone, + }, + { + value: "Tokyo, Chrome users", + icon: OfficeBuilding, + }, + { + value: "Safari, Singapore, last month", + icon: FlagWavy, + }, + { + value: "QR scans last quarter", + icon: QRCode, + }, + ], + [primaryDomain, isPublicStatsPage], + ); + const [streaming, setStreaming] = useState(false); const filters: ComponentProps["filters"] = useMemo( () => [ + { + key: "ai", + icon: Magic, + label: "Ask AI", + separatorAfter: true, + options: + aiFilterSuggestions?.map(({ icon, value }) => ({ + value, + label: value, + icon, + })) ?? null, + }, ...(isPublicStatsPage ? [] : [ - { - key: "ai", - icon: Magic, - label: "Ask AI", - separatorAfter: true, - options: [ - { - value: "QR code scans in the last 30 days, US only", - label: "QR code scans in the last 30 days, US only", - icon: , - }, - { - value: "Canadian Desktop users in the last 90 days", - label: "Canadian Desktop users in the last 90 days", - icon: , - }, - ], - }, { key: "domain", icon: Globe, @@ -253,6 +295,7 @@ export default function Toggle() { icon: QRCode, }, ], + separatorAfter: !isPublicStatsPage, }, { key: "country", @@ -296,7 +339,11 @@ export default function Toggle() { icon: MobilePhone, label: "Device", getOptionIcon: (value) => ( - + ), options: devices?.map(({ device, count }) => ({ @@ -333,10 +380,25 @@ export default function Toggle() { right: nFormatter(count, { full: true }), })) ?? null, }, + { + key: "referer", + icon: ReferredVia, + label: "Referer", + getOptionIcon: (value, props) => ( + + ), + options: + referers?.map(({ referer, count }) => ({ + value: referer, + label: referer, + right: nFormatter(count, { full: true }), + })) ?? null, + }, ], [ isPublicStatsPage, domains, + allLinks, links, tags, countries, @@ -344,6 +406,7 @@ export default function Toggle() { devices, browsers, os, + referers, ], ); diff --git a/apps/web/ui/analytics/utils.ts b/apps/web/ui/analytics/utils.ts index fa63e88389..d1f6ab6f93 100644 --- a/apps/web/ui/analytics/utils.ts +++ b/apps/web/ui/analytics/utils.ts @@ -2,9 +2,13 @@ import { AnalyticsGroupByOptions } from "@/lib/analytics/types"; import { editQueryString } from "@/lib/analytics/utils"; import { fetcher } from "@dub/utils"; import { useContext } from "react"; -import useSWR from "swr"; +import useSWR, { useSWRConfig } from "swr"; import { AnalyticsContext } from "."; +type AnalyticsFilterResult = + | ({ count?: number } & Record)[] + | null; + /** * Fetches event counts grouped by the specified filter * @@ -15,13 +19,25 @@ export function useAnalyticsFilterOption( groupByOrParams: | AnalyticsGroupByOptions | ({ groupBy: AnalyticsGroupByOptions } & Record), - options?: { enabled?: boolean }, -): ({ count?: number } & Record)[] | null { + options?: { cacheOnly?: boolean }, +): AnalyticsFilterResult { + const { cache } = useSWRConfig(); + const { baseApiPath, queryString, selectedTab, requiresUpgrade } = useContext(AnalyticsContext); + const enabled = + !options?.cacheOnly || + [...cache.keys()].includes( + `${baseApiPath}?${editQueryString(queryString, { + ...(typeof groupByOrParams === "string" + ? { groupBy: groupByOrParams } + : groupByOrParams), + })}`, + ); + const { data } = useSWR[]>( - options?.enabled !== false + enabled ? `${baseApiPath}?${editQueryString(queryString, { ...(typeof groupByOrParams === "string" ? { groupBy: groupByOrParams } diff --git a/packages/ui/src/icons/nucleo/index.ts b/packages/ui/src/icons/nucleo/index.ts index e15e9561ad..d252a53f2f 100644 --- a/packages/ui/src/icons/nucleo/index.ts +++ b/packages/ui/src/icons/nucleo/index.ts @@ -8,5 +8,6 @@ export * from "./invoice-dollar"; export * from "./mobile-phone"; export * from "./office-building"; export * from "./qrcode"; +export * from "./referred-via"; export * from "./tag"; export * from "./window"; diff --git a/packages/ui/src/icons/nucleo/referred-via.tsx b/packages/ui/src/icons/nucleo/referred-via.tsx new file mode 100644 index 0000000000..38e2f29b9e --- /dev/null +++ b/packages/ui/src/icons/nucleo/referred-via.tsx @@ -0,0 +1,40 @@ +import { SVGProps } from "react"; + +export function ReferredVia(props: SVGProps) { + return ( + + + + + + + + ); +}