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