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 (
+
+
+
+
+ );
+}
+
+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)
- ) : (
-
- )
- ) : (
-
- )}
- {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 (
+
+ );
+}