diff --git a/apps/web/app/api/cron/import/csv/route.ts b/apps/web/app/api/cron/import/csv/route.ts
index d6d16540d16..57e0b2f04ec 100644
--- a/apps/web/app/api/cron/import/csv/route.ts
+++ b/apps/web/app/api/cron/import/csv/route.ts
@@ -87,10 +87,11 @@ export async function POST(req: Request) {
await redis.incrby(`${redisKey}:processed`, rows.length);
if (rows.length === BATCH_SIZE) {
- return await qstash.publishJSON({
+ const response = await qstash.publishJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/csv`,
body: payload,
});
+ return NextResponse.json(response);
}
}
diff --git a/apps/web/app/api/partner-profile/programs/[programId]/customers/[customerId]/activity/route.ts b/apps/web/app/api/partner-profile/programs/[programId]/customers/[customerId]/activity/route.ts
index 8e625d818b5..16790085889 100644
--- a/apps/web/app/api/partner-profile/programs/[programId]/customers/[customerId]/activity/route.ts
+++ b/apps/web/app/api/partner-profile/programs/[programId]/customers/[customerId]/activity/route.ts
@@ -16,6 +16,13 @@ export const GET = withPartnerProfile(async ({ partner, params }) => {
programId: programId,
});
+ if (program.slug === "framer") {
+ throw new DubApiError({
+ code: "forbidden",
+ message: "Framer program does not support customer activity",
+ });
+ }
+
const customer = await prisma.customer.findUnique({
where: {
id: customerId,
diff --git a/apps/web/app/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts b/apps/web/app/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts
index 8278f79f785..39b2e6ba44d 100644
--- a/apps/web/app/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts
+++ b/apps/web/app/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts
@@ -16,6 +16,13 @@ export const GET = withPartnerProfile(async ({ partner, params }) => {
programId: programId,
});
+ if (program.slug === "framer") {
+ throw new DubApiError({
+ code: "forbidden",
+ message: "Framer program does not support customer profile",
+ });
+ }
+
const customer = await prisma.customer.findUnique({
where: {
id: customerId,
diff --git a/apps/web/app/api/partner-profile/programs/[programId]/earnings/count/route.ts b/apps/web/app/api/partner-profile/programs/[programId]/earnings/count/route.ts
index 02a855abaad..2011dbe5fc7 100644
--- a/apps/web/app/api/partner-profile/programs/[programId]/earnings/count/route.ts
+++ b/apps/web/app/api/partner-profile/programs/[programId]/earnings/count/route.ts
@@ -95,7 +95,7 @@ export const GET = withPartnerProfile(
return {
id: customerId,
email: customer?.email
- ? customer.email.replace(/(?<=^.).+(?=.@)/, "********")
+ ? customer.email.replace(/(?<=^.).+(?=.@)/, "****")
: customer?.name || generateRandomName(),
_count,
};
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/[programId]/commissions/commission-table.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/[programId]/commissions/commission-table.tsx
index 51a41a935e0..55a9ab331b8 100644
--- a/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/[programId]/commissions/commission-table.tsx
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/programs/[programId]/commissions/commission-table.tsx
@@ -90,12 +90,8 @@ const CommissionTableInner = memo(
},
{
header: "Customer",
- cell: ({ row }) => {
- if (!row.original.customer) {
- return "-";
- }
-
- return (
+ cell: ({ row }) =>
+ row.original.customer ? (
- );
- },
+ ) : (
+ "-"
+ ),
meta: {
filterParams: ({ row }) =>
row.original.customer
diff --git a/apps/web/app/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/[customerId]/page-client.tsx b/apps/web/app/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/[customerId]/page-client.tsx
index 6bfc78c5e57..bf61f6c2ad8 100644
--- a/apps/web/app/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/[customerId]/page-client.tsx
+++ b/apps/web/app/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/[customerId]/page-client.tsx
@@ -11,8 +11,8 @@ import { CustomerDetailsColumn } from "@/ui/customers/customer-details-column";
import { CustomerSalesTable } from "@/ui/customers/customer-sales-table";
import { ProgramRewardList } from "@/ui/partners/program-reward-list";
import { BackLink } from "@/ui/shared/back-link";
-import { MoneyBill2, Tooltip, User } from "@dub/ui";
-import { fetcher } from "@dub/utils";
+import { MoneyBill2, Tooltip } from "@dub/ui";
+import { fetcher, OG_AVATAR_URL } from "@dub/utils";
import { notFound, useParams } from "next/navigation";
import { memo } from "react";
import useSWR from "swr";
@@ -46,9 +46,15 @@ export function ProgramCustomerPageClient() {
Earnings
-
-
-
+ {customer ? (
+

+ ) : (
+
+ )}
{customer ? (
diff --git a/apps/web/app/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/[customerId]/page.tsx b/apps/web/app/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/[customerId]/page.tsx
index 4982498f9dc..67a7a9e209d 100644
--- a/apps/web/app/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/[customerId]/page.tsx
+++ b/apps/web/app/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/customers/[customerId]/page.tsx
@@ -1,8 +1,16 @@
import { PageContent } from "@/ui/layout/page-content";
import { MaxWidthWrapper } from "@dub/ui";
+import { redirect } from "next/navigation";
import { ProgramCustomerPageClient } from "./page-client";
-export default function ProgramCustomer() {
+export default function ProgramCustomer({
+ params,
+}: {
+ params: { programSlug: string; customerId: string };
+}) {
+ if (params.programSlug === "framer") {
+ redirect("/programs/framer");
+ }
return (
diff --git a/apps/web/app/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-table.tsx b/apps/web/app/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-table.tsx
index 512a5b6cfb7..538e94c5cdb 100644
--- a/apps/web/app/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-table.tsx
+++ b/apps/web/app/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-table.tsx
@@ -24,6 +24,7 @@ import {
formatDateTimeSmart,
getApexDomain,
getPrettyUrl,
+ OG_AVATAR_URL,
} from "@dub/utils";
import { Cell } from "@tanstack/react-table";
import Link from "next/link";
@@ -130,8 +131,15 @@ export function EarningsTablePartner({ limit }: { limit?: number }) {
scroll={false}
className="flex w-full items-center justify-between gap-2 px-4 py-2.5 transition-colors hover:bg-stone-100"
>
-
- {row.original.customer.email}
+
+

+
+ {row.original.customer.email}
+
diff --git a/apps/web/lib/api/links/cache.ts b/apps/web/lib/api/links/cache.ts
index 422eff1937c..97f73571f34 100644
--- a/apps/web/lib/api/links/cache.ts
+++ b/apps/web/lib/api/links/cache.ts
@@ -51,7 +51,10 @@ class LinkCache {
}
async get({ domain, key }: Pick
) {
- return await redis.get(this._createKey({ domain, key }));
+ // here we use linkcache:${domain}:${key} instead of this._createKey({ domain, key })
+ // because the key can either be cached as case-sensitive or case-insensitive depending on the domain
+ // so we should get the original key from the cache
+ return await redis.get(`linkcache:${domain}:${key}`);
}
async delete({ domain, key }: Pick) {
diff --git a/apps/web/lib/middleware/utils/parse.ts b/apps/web/lib/middleware/utils/parse.ts
index 3aac8cf850d..2cafc5b7441 100644
--- a/apps/web/lib/middleware/utils/parse.ts
+++ b/apps/web/lib/middleware/utils/parse.ts
@@ -3,16 +3,21 @@ import { NextRequest } from "next/server";
export const parse = (req: NextRequest) => {
let domain = req.headers.get("host") as string;
+ // path is the path of the URL (e.g. dub.sh/stats/github -> /stats/github)
+ let path = req.nextUrl.pathname;
+
// remove www. from domain and convert to lowercase
domain = domain.replace(/^www./, "").toLowerCase();
if (domain === "dub.localhost:8888" || domain.endsWith(".vercel.app")) {
- // for local development and preview URLs
- domain = SHORT_DOMAIN;
+ if (path.toLowerCase() === "/case-sensitive-test") {
+ // special case for case-sensitive link test
+ domain = "dub-internal-test.com";
+ } else {
+ // for local development and preview URLs
+ domain = SHORT_DOMAIN;
+ }
}
- // path is the path of the URL (e.g. dub.sh/stats/github -> /stats/github)
- let path = req.nextUrl.pathname;
-
// fullPath is the full URL path (along with search params)
const searchParams = req.nextUrl.searchParams.toString();
const searchParamsObj = Object.fromEntries(req.nextUrl.searchParams);
diff --git a/apps/web/lib/tinybird/record-click.ts b/apps/web/lib/tinybird/record-click.ts
index 4b0863b4296..9c3de151bd6 100644
--- a/apps/web/lib/tinybird/record-click.ts
+++ b/apps/web/lib/tinybird/record-click.ts
@@ -63,6 +63,11 @@ export async function recordClick({
return null;
}
+ // don't track HEAD requests to avoid non-user traffic from inflating click count
+ if (req.method === "HEAD") {
+ return null;
+ }
+
const isBot = detectBot(req);
// don't record clicks from bots
diff --git a/apps/web/lib/zod/schemas/partner-profile.ts b/apps/web/lib/zod/schemas/partner-profile.ts
index 478bcff1a1e..391552322b1 100644
--- a/apps/web/lib/zod/schemas/partner-profile.ts
+++ b/apps/web/lib/zod/schemas/partner-profile.ts
@@ -20,8 +20,7 @@ export const PartnerEarningsSchema = CommissionSchema.merge(
id: z.string(),
email: z
.string()
- .transform((email) => email.replace(/(?<=^.).+(?=.@)/, "********")),
- avatar: z.string().nullable(),
+ .transform((email) => email.replace(/(?<=^.).+(?=.@)/, "****")),
})
.nullable(),
link: LinkSchema.pick({
@@ -87,5 +86,5 @@ export const PartnerProfileCustomerSchema = CustomerEnrichedSchema.pick({
}).extend({
email: z
.string()
- .transform((email) => email.replace(/(?<=^.).+(?=.@)/, "********")),
+ .transform((email) => email.replace(/(?<=^.).+(?=.@)/, "****")),
});
diff --git a/apps/web/tests/redirects/index.test.ts b/apps/web/tests/redirects/index.test.ts
index 9d4a90511bd..bc1dea07f7b 100644
--- a/apps/web/tests/redirects/index.test.ts
+++ b/apps/web/tests/redirects/index.test.ts
@@ -108,6 +108,30 @@ describe.runIf(env.CI)("Link Redirects", async () => {
expect(response.status).toBe(302);
});
+ test("with case-sensitive (correct) key", async () => {
+ const response = await fetch(
+ `${h.baseUrl}/cAsE-sensitive-test`,
+ fetchOptions,
+ );
+
+ expect(response.headers.get("location")).toBe(
+ "https://dub.co/changelog/case-insensitive-links",
+ );
+ expect(response.headers.get("x-powered-by")).toBe(poweredBy);
+ expect(response.status).toBe(302);
+ });
+
+ test("with case-sensitive (incorrect) key", async () => {
+ const response = await fetch(
+ `${h.baseUrl}/case-sensitive-test`,
+ fetchOptions,
+ );
+
+ expect(response.headers.get("location")).toBe("https://dub.co/");
+ expect(response.headers.get("x-powered-by")).toBe(poweredBy);
+ expect(response.status).toBe(302);
+ });
+
test("with password", async () => {
const response = await fetch(
`${h.baseUrl}/password/check?pw=dub`,
diff --git a/apps/web/ui/customers/customer-activity-list.tsx b/apps/web/ui/customers/customer-activity-list.tsx
index c3c9ac4c1dc..0434803ab6d 100644
--- a/apps/web/ui/customers/customer-activity-list.tsx
+++ b/apps/web/ui/customers/customer-activity-list.tsx
@@ -1,5 +1,5 @@
import { CustomerActivityResponse } from "@/lib/types";
-import { LinkLogo } from "@dub/ui";
+import { DynamicTooltipWrapper, LinkLogo } from "@dub/ui";
import { CursorRays, MoneyBill2, UserCheck } from "@dub/ui/icons";
import { formatDateTimeSmart, getApexDomain, getPrettyUrl } from "@dub/utils";
import Link from "next/link";
@@ -10,17 +10,24 @@ const activityData = {
icon: CursorRays,
content: (event) => {
const { slug, programSlug } = useParams();
+
+ const analyticsBaseUrl = programSlug
+ ? `/programs/${programSlug}/analytics`
+ : `/${slug}/analytics`;
+
const referer =
!event.click?.referer || event.click.referer === "(direct)"
? "direct"
: event.click.referer;
+ const refererUrl = event.click.refererUrl;
+
return (
Found{" "}
via
-
+ Referrer URL:{" "}
+
+ {getPrettyUrl(refererUrl)}
+
+
+ ),
+ }
+ : undefined
}
- target="_blank"
- className="flex items-center gap-2 rounded-md bg-neutral-100 px-1.5 py-1 font-mono text-xs leading-none transition-colors hover:bg-neutral-200/80"
>
-
- {referer}
-
+
+
+
+ {referer}
+
+
+
);
},
diff --git a/apps/web/ui/customers/customer-details-column.tsx b/apps/web/ui/customers/customer-details-column.tsx
index 02fed1891c7..441eee885dc 100644
--- a/apps/web/ui/customers/customer-details-column.tsx
+++ b/apps/web/ui/customers/customer-details-column.tsx
@@ -46,7 +46,6 @@ export function CustomerDetailsColumn({
{icon}
@@ -173,7 +171,6 @@ export function CustomerDetailsColumn({
href={`/${programSlug ? `programs/${programSlug}` : slug}/analytics?domain=${link.domain}&key=${link.key}`}
target="_blank"
className="min-w-0 overflow-hidden truncate"
- linkClassName="underline-offset-2 hover:text-neutral-950 hover:underline"
>
{getPrettyUrl(link.shortLink)}
@@ -193,7 +190,6 @@ export function CustomerDetailsColumn({
href={`/${programSlug ? `programs/${programSlug}` : slug}/analytics?${key}=${encodeURIComponent(value)}`}
target="_blank"
className="truncate text-neutral-500"
- linkClassName="underline-offset-2 hover:text-neutral-600 hover:underline"
>
{value}
@@ -222,13 +218,15 @@ const ConditionalLink = ({
href,
className,
children,
- linkClassName,
...rest
-}: HTMLProps & { linkClassName?: string }) => {
+}: HTMLProps) => {
return href ? (
{children}
diff --git a/apps/web/ui/customers/customer-sales-table.tsx b/apps/web/ui/customers/customer-sales-table.tsx
index 4ee69161466..0a7d017124f 100644
--- a/apps/web/ui/customers/customer-sales-table.tsx
+++ b/apps/web/ui/customers/customer-sales-table.tsx
@@ -1,4 +1,4 @@
-import { PartnerEarningsResponse, SaleEvent } from "@/lib/types";
+import { CommissionResponse, SaleEvent } from "@/lib/types";
import { StatusBadge } from "@dub/ui";
import { currencyFormatter, formatDateTimeSmart } from "@dub/utils";
import {
@@ -18,7 +18,7 @@ export function CustomerSalesTable({
sales?:
| Pick[]
| Pick<
- PartnerEarningsResponse,
+ CommissionResponse,
"createdAt" | "amount" | "earnings" | "status"
>[];
totalSales?: number;
@@ -27,10 +27,7 @@ export function CustomerSalesTable({
}) {
const table = useReactTable<
| Pick
- | Pick<
- PartnerEarningsResponse,
- "createdAt" | "amount" | "earnings" | "status"
- >
+ | Pick
>({
data: sales || [],
columns: [
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 8e25526634e..ec5aea02f0d 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,7 +1,7 @@
{
"name": "@dub/ui",
"description": "UI components for Dub.co",
- "version": "0.2.31",
+ "version": "0.2.32",
"sideEffects": false,
"main": "./dist/index.js",
"module": "./dist/index.mjs",
diff --git a/packages/ui/src/dub-status-badge.tsx b/packages/ui/src/dub-status-badge.tsx
new file mode 100644
index 00000000000..8967ed0259e
--- /dev/null
+++ b/packages/ui/src/dub-status-badge.tsx
@@ -0,0 +1,68 @@
+import { cn } from "@dub/utils";
+
+import { fetcher } from "@dub/utils";
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import useSWR from "swr";
+
+export function DubStatusBadge({ className }: { className?: string }) {
+ const { data } = useSWR<{
+ ongoing_incidents: {
+ name: string;
+ current_worst_impact:
+ | "degraded_performance"
+ | "partial_outage"
+ | "full_outage";
+ }[];
+ }>("https://status.dub.co/api/v1/summary", fetcher);
+
+ const [color, setColor] = useState("bg-neutral-200");
+ const [status, setStatus] = useState("Loading status...");
+
+ useEffect(() => {
+ if (!data) return;
+ const { ongoing_incidents } = data;
+ if (ongoing_incidents.length > 0) {
+ const { current_worst_impact, name } = ongoing_incidents[0];
+ const color =
+ current_worst_impact === "degraded_performance"
+ ? "bg-yellow-500"
+ : "bg-red-500";
+ setStatus(name);
+ setColor(color);
+ } else {
+ setStatus("All systems operational");
+ setColor("bg-green-500");
+ }
+ }, [data]);
+
+ return (
+
+
+
+ {status}
+
+
+ );
+}
diff --git a/packages/ui/src/footer.tsx b/packages/ui/src/footer.tsx
index fe6bfb6360f..888644b1924 100644
--- a/packages/ui/src/footer.tsx
+++ b/packages/ui/src/footer.tsx
@@ -1,12 +1,11 @@
"use client";
-import { ALL_TOOLS, cn, createHref, fetcher } from "@dub/utils";
+import { ALL_TOOLS, cn, createHref } from "@dub/utils";
import Image from "next/image";
import Link from "next/link";
import { useParams } from "next/navigation";
-import { useEffect, useState } from "react";
-import useSWR from "swr";
import { COMPARE_PAGES, FEATURES_LIST, LEGAL_PAGES } from "./content";
+import { DubStatusBadge } from "./dub-status-badge";
import { Github, LinkedIn, ReferredVia, Twitter, YouTube } from "./icons";
import { MaxWidthWrapper } from "./max-width-wrapper";
import { NavWordmark } from "./nav-wordmark";
@@ -277,7 +276,7 @@ export function Footer({
{/* Bottom row (status, SOC2, copyright) */}
-
+
);
}
-
-function StatusBadge() {
- const { data } = useSWR<{
- ongoing_incidents: {
- name: string;
- current_worst_impact:
- | "degraded_performance"
- | "partial_outage"
- | "full_outage";
- }[];
- }>("https://status.dub.co/api/v1/summary", fetcher);
-
- const [color, setColor] = useState("bg-neutral-200");
- const [status, setStatus] = useState("Loading status...");
-
- useEffect(() => {
- if (!data) return;
- const { ongoing_incidents } = data;
- if (ongoing_incidents.length > 0) {
- const { current_worst_impact, name } = ongoing_incidents[0];
- const color =
- current_worst_impact === "degraded_performance"
- ? "bg-yellow-500"
- : "bg-red-500";
- setStatus(name);
- setColor(color);
- } else {
- setStatus("All systems operational");
- setColor("bg-green-500");
- }
- }, [data]);
-
- return (
-
-
-
- {status}
-
-
- );
-}
diff --git a/packages/ui/src/icons/nucleo/index.ts b/packages/ui/src/icons/nucleo/index.ts
index 93ee87d798b..a574ad8571e 100644
--- a/packages/ui/src/icons/nucleo/index.ts
+++ b/packages/ui/src/icons/nucleo/index.ts
@@ -142,6 +142,7 @@ export * from "./mobile-phone";
export * from "./money-bill";
export * from "./money-bill2";
export * from "./money-bills2";
+export * from "./msgs";
export * from "./note";
export * from "./office-building";
export * from "./page2";
@@ -168,6 +169,7 @@ export * from "./shield-alert";
export * from "./shield-check";
export * from "./shield-keyhole";
export * from "./shield-slash";
+export * from "./shield-user";
export * from "./shuffle";
export * from "./sliders";
export * from "./sparkle3";
diff --git a/packages/ui/src/icons/nucleo/msgs.tsx b/packages/ui/src/icons/nucleo/msgs.tsx
new file mode 100644
index 00000000000..6a1e1e92778
--- /dev/null
+++ b/packages/ui/src/icons/nucleo/msgs.tsx
@@ -0,0 +1,32 @@
+import { SVGProps } from "react";
+
+export function Msgs(props: SVGProps
) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/icons/nucleo/shield-user.tsx b/packages/ui/src/icons/nucleo/shield-user.tsx
new file mode 100644
index 00000000000..9b753a8ea2e
--- /dev/null
+++ b/packages/ui/src/icons/nucleo/shield-user.tsx
@@ -0,0 +1,42 @@
+import { SVGProps } from "react";
+
+export function ShieldUser(props: SVGProps) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx
index 9e61a28e158..03fc87282ec 100644
--- a/packages/ui/src/index.tsx
+++ b/packages/ui/src/index.tsx
@@ -12,6 +12,7 @@ export * from "./carousel";
export * from "./checkbox";
export * from "./combobox";
export * from "./date-picker";
+export * from "./dub-status-badge";
export * from "./empty-state";
export * from "./file-upload";
export * from "./filter";