diff --git a/apps/web/app/(ee)/api/applications/analytics/route.ts b/apps/web/app/(ee)/api/applications/analytics/route.ts new file mode 100644 index 00000000000..6bee9bce447 --- /dev/null +++ b/apps/web/app/(ee)/api/applications/analytics/route.ts @@ -0,0 +1,359 @@ +import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates"; +import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { + applicationEventAnalyticsQuerySchema, + applicationEventAnalyticsSchema, +} from "@/lib/application-events/schema"; +import { withWorkspace } from "@/lib/auth"; +import { sqlGranularityMap } from "@/lib/planetscale/granularity"; +import { ApplicationEventAnalyticsQuery } from "@/lib/types"; +import { TZDate, tz } from "@date-fns/tz"; +import { prisma } from "@dub/prisma"; +import { Prisma } from "@dub/prisma/client"; +import { parseFilterValue } from "@dub/utils"; +import { format } from "date-fns/format"; +import { NextResponse } from "next/server"; +import * as z from "zod/v4"; + +type TimeseriesApplicationRow = { + start: string | Date; + visits: bigint; + starts: bigint; + submissions: bigint; + approvals: bigint; + rejections: bigint; +}; + +const aggregations = { + _count: { + visitedAt: true, + startedAt: true, + submittedAt: true, + approvedAt: true, + rejectedAt: true, + }, +} as const; + +// GET /api/applications/analytics +export const GET = withWorkspace(async ({ workspace, searchParams }) => { + const programId = getDefaultProgramIdOrThrow(workspace); + + const parsedFilters = + applicationEventAnalyticsQuerySchema.parse(searchParams); + + const { + groupBy, + partnerId, + country, + referralSource, + start, + end, + interval, + timezone: timezoneParam, + } = parsedFilters; + + // Align with CONVERT_TZ in raw SQL and analyticsQuerySchema default (UTC when omitted). + const timezone = timezoneParam ?? "UTC"; + + const { startDate, endDate } = getStartEndDates({ + interval, + start, + end, + timezone, + }); + + const partnerFilter = parseFilterValue(partnerId); + const countryFilter = parseFilterValue(country); + const referralSourceFilter = parseFilterValue(referralSource); + + const where: Prisma.ProgramApplicationEventWhereInput = { + programId, + ...(partnerFilter && { + referredByPartnerId: + partnerFilter.sqlOperator === "NOT IN" + ? { notIn: partnerFilter.values } + : { in: partnerFilter.values }, + }), + ...(referralSourceFilter && { + referralSource: + referralSourceFilter.sqlOperator === "NOT IN" + ? { notIn: referralSourceFilter.values } + : { in: referralSourceFilter.values }, + }), + ...(countryFilter && { + country: + countryFilter.sqlOperator === "NOT IN" + ? { notIn: countryFilter.values } + : { in: countryFilter.values }, + }), + visitedAt: { + gte: startDate, + lt: endDate, + }, + }; + + const responseSchema = applicationEventAnalyticsSchema[groupBy]; + + // Get the absolute counts + if (groupBy === "count") { + const { _count } = await prisma.programApplicationEvent.aggregate({ + where, + ...aggregations, + }); + + return NextResponse.json(responseSchema.parse(formatCounts(_count))); + } + + // Get the counts grouped by the specified column + if (["referralSource", "country"].includes(groupBy)) { + const groupByColumnMap = { + referralSource: "referralSource", + country: "country", + }; + + const groupByColumn = groupByColumnMap[groupBy]; + + const events = await prisma.programApplicationEvent.groupBy({ + by: [groupByColumn], + where, + ...aggregations, + orderBy: { + _count: { + [groupBy]: "desc", + }, + }, + }); + + const results = events + .filter((row) => row[groupByColumn] !== null) + .map((row) => ({ + [groupBy]: row[groupByColumn], + ...formatCounts(row._count), + })); + + return NextResponse.json(z.array(responseSchema).parse(results)); + } + + // Get the counts grouped by the partner + if (groupBy === "partnerId") { + return byPartnerId({ + where, + }); + } + + // Get the timeseries + if (groupBy === "timeseries") { + return byTimeseries({ + ...parsedFilters, + programId, + timezone, + }); + } + + return NextResponse.json(null); +}); + +async function byPartnerId({ + where, +}: { + where: Prisma.ProgramApplicationEventWhereInput; +}) { + const events = await prisma.programApplicationEvent.groupBy({ + by: ["referredByPartnerId"], + where, + ...aggregations, + }); + + const partnerIds = events + .map(({ referredByPartnerId }) => referredByPartnerId) + .filter((id): id is string => Boolean(id)); + + const partners = + partnerIds.length > 0 + ? await prisma.partner.findMany({ + where: { + id: { + in: partnerIds, + }, + }, + select: { + id: true, + name: true, + image: true, + email: true, + }, + }) + : []; + + const eventCountByPartnerId = new Map( + events.map(({ referredByPartnerId, _count }) => [ + referredByPartnerId, + _count, + ]), + ); + + const results = partners + .map((partner) => { + const partnerEvents = eventCountByPartnerId.get(partner.id); + + if (!partnerEvents) { + return null; + } + + return { + partner, + ...formatCounts(partnerEvents), + }; + }) + .filter((r): r is NonNullable => r !== null); + + return NextResponse.json( + z.array(applicationEventAnalyticsSchema["partnerId"]).parse(results), + ); +} + +async function byTimeseries({ + programId, + partnerId, + country, + referralSource, + timezone, + interval, + start, + end, +}: ApplicationEventAnalyticsQuery & { programId: string }) { + const tzId = timezone ?? "UTC"; + + const { startDate, endDate, granularity } = getStartEndDates({ + interval, + start, + end, + timezone: tzId, + }); + + const { dateFormat, dateIncrement, startFunction, formatString } = + sqlGranularityMap[granularity]; + + const partnerFilter = parseFilterValue(partnerId); + const countryFilter = parseFilterValue(country); + const referralSourceFilter = parseFilterValue(referralSource); + + const conditions: Prisma.Sql[] = [ + Prisma.sql`e.programId = ${programId}`, + Prisma.sql`e.visitedAt >= ${startDate}`, + Prisma.sql`e.visitedAt < ${endDate}`, + ]; + + if (partnerFilter) { + const list = Prisma.join(partnerFilter.values.map((v) => Prisma.sql`${v}`)); + conditions.push( + partnerFilter.sqlOperator === "NOT IN" + ? Prisma.sql`e.partnerId NOT IN (${list})` + : Prisma.sql`e.partnerId IN (${list})`, + ); + } + + if (referralSourceFilter) { + const list = Prisma.join( + referralSourceFilter.values.map((v) => Prisma.sql`${v}`), + ); + conditions.push( + referralSourceFilter.sqlOperator === "NOT IN" + ? Prisma.sql`e.referralSource NOT IN (${list})` + : Prisma.sql`e.referralSource IN (${list})`, + ); + } + + if (countryFilter) { + const list = Prisma.join(countryFilter.values.map((v) => Prisma.sql`${v}`)); + conditions.push( + countryFilter.sqlOperator === "NOT IN" + ? Prisma.sql`e.country NOT IN (${list})` + : Prisma.sql`e.country IN (${list})`, + ); + } + + const whereClause = Prisma.join(conditions, " AND "); + + const rows = await prisma.$queryRaw( + Prisma.sql` + SELECT + DATE_FORMAT(CONVERT_TZ(e.visitedAt, "UTC", ${tzId}), ${dateFormat}) AS start, + COUNT(e.visitedAt) AS visits, + COUNT(e.startedAt) AS starts, + COUNT(e.submittedAt) AS submissions, + COUNT(e.approvedAt) AS approvals, + COUNT(e.rejectedAt) AS rejections + FROM ProgramApplicationEvent e + WHERE ${whereClause} + GROUP BY start + ORDER BY start ASC`, + ); + + const periodKeyFromSql = (start: TimeseriesApplicationRow["start"]) => + typeof start === "string" + ? start + : format(new TZDate(start, tzId), formatString, { + in: tz(tzId), + }); + + const lookup = Object.fromEntries( + rows.map((r) => [ + periodKeyFromSql(r.start), + { + visits: Number(r.visits), + starts: Number(r.starts), + submissions: Number(r.submissions), + approvals: Number(r.approvals), + rejections: Number(r.rejections), + }, + ]), + ); + + const tzStartDate = new TZDate(startDate, tzId); + const tzEndDate = new TZDate(endDate, tzId); + + let currentDate = startFunction(tzStartDate); + const timeseries: z.infer< + (typeof applicationEventAnalyticsSchema)["timeseries"] + >[] = []; + + while (currentDate < tzEndDate) { + const periodKey = format(currentDate, formatString, { + in: tz(tzId), + }); + + timeseries.push({ + start: currentDate.toISOString(), + ...(lookup[periodKey] ?? { + visits: 0, + starts: 0, + submissions: 0, + approvals: 0, + rejections: 0, + }), + }); + + currentDate = dateIncrement(currentDate); + } + + return NextResponse.json( + z.array(applicationEventAnalyticsSchema["timeseries"]).parse(timeseries), + ); +} + +function formatCounts(c: { + visitedAt: number; + startedAt: number; + submittedAt: number; + approvedAt: number; + rejectedAt: number; +}) { + return { + visits: c.visitedAt, + starts: c.startedAt, + submissions: c.submittedAt, + approvals: c.approvedAt, + rejections: c.rejectedAt, + }; +} diff --git a/apps/web/app/(ee)/api/applications/events/route.ts b/apps/web/app/(ee)/api/applications/events/route.ts new file mode 100644 index 00000000000..5e293b5bd6f --- /dev/null +++ b/apps/web/app/(ee)/api/applications/events/route.ts @@ -0,0 +1,145 @@ +import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates"; +import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { assertValidDateRangeForPlan } from "@/lib/api/utils/assert-valid-date-range-for-plan"; +import { + applicationEventSchema, + applicationEventsQuerySchema, +} from "@/lib/application-events/schema"; +import { withWorkspace } from "@/lib/auth"; +import { prisma } from "@dub/prisma"; +import { Prisma } from "@dub/prisma/client"; +import { parseFilterValue } from "@dub/utils"; +import { NextResponse } from "next/server"; +import * as z from "zod/v4"; + +// GET /api/applications/events – list application events +export const GET = withWorkspace(async ({ workspace, searchParams }) => { + const programId = getDefaultProgramIdOrThrow(workspace); + + const { + partnerId, + country, + referralSource, + event, + start, + end, + interval, + timezone, + page, + pageSize, + sortBy, + sortOrder, + } = applicationEventsQuerySchema.parse(searchParams); + + assertValidDateRangeForPlan({ + plan: workspace.plan, + dataAvailableFrom: workspace.createdAt, + interval, + start, + end, + }); + + const { startDate, endDate } = getStartEndDates({ + interval, + start, + end, + timezone, + }); + + const partnerFilter = parseFilterValue(partnerId); + const countryFilter = parseFilterValue(country); + const referralSourceFilter = parseFilterValue(referralSource); + + const where: Prisma.ProgramApplicationEventWhereInput = { + programId, + ...(partnerFilter && { + partnerId: + partnerFilter.sqlOperator === "NOT IN" + ? { notIn: partnerFilter.values } + : { in: partnerFilter.values }, + }), + ...(countryFilter && { + country: + countryFilter.sqlOperator === "NOT IN" + ? { notIn: countryFilter.values } + : { in: countryFilter.values }, + }), + ...(referralSourceFilter && { + referralSource: + referralSourceFilter.sqlOperator === "NOT IN" + ? { notIn: referralSourceFilter.values } + : { in: referralSourceFilter.values }, + }), + ...(event === "visited" && { + visitedAt: { + gte: startDate, + lt: endDate, + }, + }), + ...(event === "started" && { + startedAt: { + gte: startDate, + lt: endDate, + }, + }), + ...(event === "submitted" && { + submittedAt: { + gte: startDate, + lt: endDate, + }, + }), + ...(event === "approved" && { + approvedAt: { + gte: startDate, + lt: endDate, + }, + }), + ...(event === "rejected" && { + rejectedAt: { + gte: startDate, + lt: endDate, + }, + }), + }; + + const programApplicationEvents = + await prisma.programApplicationEvent.findMany({ + where, + orderBy: { + [sortBy]: sortOrder, + }, + skip: (page - 1) * pageSize, + take: pageSize, + include: { + programEnrollment: { + select: { + partner: { + select: { + id: true, + name: true, + image: true, + }, + }, + partnerGroup: { + select: { + id: true, + name: true, + slug: true, + color: true, + }, + }, + }, + }, + }, + }); + + const response = programApplicationEvents.map( + ({ programEnrollment, ...rest }) => ({ + ...rest, + partner: programEnrollment?.partner ?? null, + group: programEnrollment?.partnerGroup ?? null, + }), + ); + + return NextResponse.json(z.array(applicationEventSchema).parse(response)); +}); diff --git a/apps/web/app/(ee)/api/commissions/analytics/route.ts b/apps/web/app/(ee)/api/commissions/analytics/route.ts index fd7df46dde5..8b977a3b7f2 100644 --- a/apps/web/app/(ee)/api/commissions/analytics/route.ts +++ b/apps/web/app/(ee)/api/commissions/analytics/route.ts @@ -1,7 +1,6 @@ import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; -import { assertValidDateRangeForPlan } from "@/lib/api/utils/assert-valid-date-range-for-plan"; import { withWorkspace } from "@/lib/auth"; import { commissionAnalyticsQuerySchema, @@ -109,14 +108,6 @@ export const GET = withWorkspace(async ({ workspace, searchParams }) => { return byTimeseries({ workspace, programId, parsed }); } - assertValidDateRangeForPlan({ - plan: workspace.plan, - dataAvailableFrom: workspace.createdAt, - interval: parsed.interval, - start: parsed.start, - end: parsed.end, - }); - const { startDate, endDate } = getStartEndDates({ interval: parsed.interval, start: parsed.start, diff --git a/apps/web/app/(ee)/api/cron/partners/auto-reject/route.ts b/apps/web/app/(ee)/api/cron/partners/auto-reject/route.ts index b7858819e5e..85a9183ff60 100644 --- a/apps/web/app/(ee)/api/cron/partners/auto-reject/route.ts +++ b/apps/web/app/(ee)/api/cron/partners/auto-reject/route.ts @@ -1,4 +1,5 @@ import { resolveFraudGroups } from "@/lib/api/fraud/resolve-fraud-groups"; +import { trackApplicationEvents } from "@/lib/application-events/update-application-event"; import { withCron } from "@/lib/cron/with-cron"; import { evaluateApplicationRequirements } from "@/lib/partners/evaluate-application-requirements"; import { sendEmail } from "@dub/email"; @@ -123,36 +124,43 @@ export const POST = withCron(async ({ rawBody }) => { ); } - await resolveFraudGroups({ - where: { - programId, - partnerId, - }, - resolutionReason: - "Resolved automatically because the partner application was automatically rejected.", - }); - const { partner, program } = programEnrollment; - if (partner.email) { - await sendEmail({ - to: partner.email, - subject: `Your application to ${program.name} was not approved`, - variant: "notifications", - replyTo: program.supportEmail || "noreply", - react: PartnerApplicationRejected({ - partner: { - name: partner.name ?? "there", - email: partner.email, - }, - program: { - name: program.name, - slug: program.slug, - supportEmail: program.supportEmail ?? undefined, - }, + await Promise.allSettled([ + resolveFraudGroups({ + where: { + programId, + partnerId, + }, + resolutionReason: + "Resolved automatically because the partner application was automatically rejected.", + }), + + trackApplicationEvents({ + event: "rejected", + programId, + partnerIds: [partnerId], + }), + + partner.email && + sendEmail({ + to: partner.email, + subject: `Your application to ${program.name} was not approved`, + variant: "notifications", + replyTo: program.supportEmail || "noreply", + react: PartnerApplicationRejected({ + partner: { + name: partner.name ?? "there", + email: partner.email, + }, + program: { + name: program.name, + slug: program.slug, + supportEmail: program.supportEmail ?? undefined, + }, + }), }), - }); - } + ]); return logAndRespond( `Successfully auto-rejected partner ${partnerId} in program ${programId}.`, diff --git a/apps/web/app/(ee)/api/track/application/route.ts b/apps/web/app/(ee)/api/track/application/route.ts new file mode 100644 index 00000000000..c0714a365f8 --- /dev/null +++ b/apps/web/app/(ee)/api/track/application/route.ts @@ -0,0 +1,313 @@ +import { COMMON_CORS_HEADERS } from "@/lib/api/cors"; +import { createId } from "@/lib/api/create-id"; +import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { parseRequestBody } from "@/lib/api/utils"; +import { getIP } from "@/lib/api/utils/get-ip"; +import { + APPLICATION_ID_COOKIE_MAX_AGE, + trackApplicationEventSchema, +} from "@/lib/application-events/schema"; +import { getApplicationEventCookieName } from "@/lib/application-events/utils"; +import { getSession } from "@/lib/auth"; +import { withAxiom } from "@/lib/axiom/server"; +import { detectBot } from "@/lib/middleware/utils/detect-bot"; +import { getIdentityHash } from "@/lib/middleware/utils/get-identity-hash"; +import { ratelimit } from "@/lib/upstash"; +import { prisma } from "@dub/prisma"; +import { Partner, Program } from "@dub/prisma/client"; +import { + capitalize, + EU_COUNTRY_CODES, + getDomainWithoutWWW, + getSearchParams, + LOCALHOST_GEO_DATA, + LOCALHOST_IP, +} from "@dub/utils"; +import { geolocation, ipAddress } from "@vercel/functions"; +import { cookies } from "next/headers"; +import { NextRequest, NextResponse, userAgent } from "next/server"; + +// POST /api/track/application – Track an application event +export const POST = withAxiom(async (req) => { + try { + if (detectBot(req)) { + return NextResponse.json( + { ok: true }, + { status: 202, headers: COMMON_CORS_HEADERS }, + ); + } + + const ip = await getIP(); + const { success } = await ratelimit(10, "10 s").limit( + `track-application:${ip}`, + ); + + if (!success) { + throw new DubApiError({ + code: "rate_limit_exceeded", + message: "Too many requests. Please try again later.", + }); + } + + const { eventName, url, referrer } = trackApplicationEventSchema.parse( + await parseRequestBody(req), + ); + + const { programSlug, isMarketplace } = identityProgramSlug(url); + + if (!programSlug) { + throw new DubApiError({ + code: "bad_request", + message: + "Couldn't identify the program slug from the URL. Skipping tracking...", + }); + } + + const program = await prisma.program.findUnique({ + where: { + slug: programSlug.toLowerCase(), + }, + select: { + id: true, + }, + }); + + if (!program) { + throw new DubApiError({ + code: "bad_request", + message: `Program not found for slug ${programSlug}. Skipping tracking...`, + }); + } + + if (eventName === "visit") { + await trackVisitEvent({ + req, + program, + url, + referrer, + isMarketplace, + }); + } else if (eventName === "start") { + await trackStartEvent({ + req, + program, + }); + } + + return NextResponse.json( + { ok: true }, + { status: 202, headers: COMMON_CORS_HEADERS }, + ); + } catch (error) { + return handleAndReturnErrorResponse(error, COMMON_CORS_HEADERS); + } +}); + +export const OPTIONS = () => { + return new Response(null, { + status: 204, + headers: COMMON_CORS_HEADERS, + }); +}; + +// Track the "visit" event +async function trackVisitEvent({ + req, + program, + url, + referrer, + isMarketplace, +}: { + req: NextRequest; + program: Pick; + url: string; + referrer: string | null | undefined; + isMarketplace: boolean; +}) { + const cookieName = getApplicationEventCookieName(program.id); + const existingEventId = req.cookies.get(cookieName)?.value; + + if (existingEventId) { + console.log( + `"visit" event already tracked for program ${program.id}. Skipping tracking...`, + ); + return; + } + + // Find the partner who referred the application + const searchParams = getSearchParams(url); + let referredByPartner: Pick | null = null; + + if (searchParams.via) { + const partner = await prisma.partner.findUnique({ + where: { + username: searchParams.via.toLowerCase(), + }, + select: { + id: true, + }, + }); + + if (partner) { + referredByPartner = partner; + } else { + console.log( + `Partner not found for username ${searchParams.via}. Not setting referredByPartnerId.`, + ); + } + } + + const session = await getSession(); + const requestContext = await getRequestContext(req); + + try { + const programApplicationEvent = await prisma.programApplicationEvent.create( + { + data: { + id: createId({ prefix: "pga_evt_" }), + programId: program.id, + referralSource: isMarketplace + ? "marketplace" + : referrer + ? getDomainWithoutWWW(referrer) || "direct" + : "direct", + referredByPartnerId: referredByPartner?.id, + partnerId: session?.user?.defaultPartnerId, + visitedAt: new Date(), + country: requestContext.country, + metadata: requestContext, + }, + }, + ); + + console.log( + `Created "visit" event for program ${program.id} with eventId: ${programApplicationEvent.id}`, + ); + + const cookieStore = await cookies(); + + cookieStore.set(cookieName, programApplicationEvent.id, { + httpOnly: true, + maxAge: APPLICATION_ID_COOKIE_MAX_AGE, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + }); + } catch {} +} + +// Track the "start" event +async function trackStartEvent({ + req, + program, +}: { + req: NextRequest; + program: Pick; +}) { + const cookieName = getApplicationEventCookieName(program.id); + const eventId = req.cookies.get(cookieName)?.value; + + if (!eventId) { + throw new DubApiError({ + code: "bad_request", + message: `"start" event not tracked for program ${program.id} because cookie was not found. Skipping tracking...`, + }); + } + + const session = await getSession(); + const partnerId = session?.user?.defaultPartnerId; + + try { + await prisma.programApplicationEvent.update({ + where: { + id: eventId, + startedAt: null, + }, + data: { + startedAt: new Date(), + ...(partnerId ? { partnerId } : {}), + }, + }); + + console.log( + `Tracked "start" event for program ${program.id} with eventId: ${eventId}`, + ); + } catch {} +} + +// Get the request context +async function getRequestContext(req: NextRequest) { + const isVercel = process.env.VERCEL === "1"; + + const identityHash = await getIdentityHash(req); + const ua = userAgent(req); + + const ip = isVercel ? ipAddress(req) : LOCALHOST_IP; + const geo = isVercel ? geolocation(req) : LOCALHOST_GEO_DATA; + + const continent = isVercel + ? req.headers.get("x-vercel-ip-continent") + : LOCALHOST_GEO_DATA.continent; + + const region = isVercel + ? req.headers.get("x-vercel-ip-country-region") + : LOCALHOST_GEO_DATA.region; + + const isEuCountry = geo.country && EU_COUNTRY_CODES.includes(geo.country); + + return { + identityHash, + continent, + country: geo.country, + region, + city: geo.city, + latitude: geo.latitude, + longitude: geo.longitude, + vercel_region: geo.region, + device: capitalize(ua.device.type), + device_vendor: ua.device.vendor, + device_model: ua.device.model, + browser: ua.browser.name, + browser_version: ua.browser.version, + engine: ua.engine.name, + engine_version: ua.engine.version, + os: ua.os.name, + os_version: ua.os.version, + cpu_architecture: ua.cpu?.architecture, + ua: ua.ua, + ip: + typeof ip === "string" && ip.trim().length > 0 && !isEuCountry + ? ip + : undefined, + }; +} + +// Identify the program slug from the URL +// Supports: +// - https://partners.dub.co/{programSlug} +// - https://partners.dub.co/programs/{programSlug}/apply +// - https://partners.dub.co/programs/marketplace/{programSlug} +function identityProgramSlug(url: string) { + try { + const urlObj = new URL(url); + const parts = urlObj.pathname.split("/").filter(Boolean); + + if (parts.length === 0) { + return { programSlug: null, isMarketplace: false }; + } + + const isMarketplace = parts[0] === "programs" && parts[1] === "marketplace"; + const programSlug = isMarketplace // e.g. https://partners.dub.co/programs/marketplace/acme + ? parts[2] + : parts[0] === "programs" // e.g. https://partners.dub.co/programs/acme/apply + ? parts[1] + : parts[0]; // e.g. https://partners.dub.co/acme, or https://partners.dub.co/acme/apply, or https://partners.dub.co/acme/group/apply + + return { + programSlug: programSlug.toLowerCase(), + isMarketplace, + }; + } catch (error) { + return { programSlug: null, isMarketplace: false }; + } +} diff --git a/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/page.tsx b/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/page.tsx index 867a5717ee0..47657b02801 100644 --- a/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/page.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/(default)/apply/page.tsx @@ -1,6 +1,7 @@ import { getProgram } from "@/lib/fetchers/get-program"; import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { programApplicationFormSchema } from "@/lib/zod/schemas/program-application-form"; +import { ApplicationAnalytics } from "@/ui/application-analytics"; import { ApplicationFormHero } from "@/ui/partners/groups/design/application-form/application-hero-preview"; import { ProgramApplicationForm } from "@/ui/partners/groups/design/application-form/program-application-form"; import { LanderRewards } from "@/ui/partners/lander/lander-rewards"; @@ -51,6 +52,7 @@ export default async function ApplicationPage(props: { } > +
{/* Hero section */} +
diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx index 1ad34d8fd1a..14bc08677ac 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/profile-details-form.tsx @@ -46,6 +46,7 @@ import { SettingsRow } from "./settings-row"; type BasicInfoFormData = { name: string; email: string; + username: string; image: string | null; country: string; profileType: PartnerProfileType; @@ -69,6 +70,7 @@ export function ProfileDetailsForm({ defaultValues: { name: partner?.name, email: partner?.email ?? "", + username: partner?.username ?? "", image: partner?.image, country: partner?.country ?? "", profileType: partner?.profileType ?? "individual", @@ -256,6 +258,7 @@ function BasicInfoForm({ await executeAsync({ ...data, + username: data.username?.trim() ? data.username : null, image: imageChanged ? data.image : null, }); })} @@ -339,6 +342,29 @@ function BasicInfoForm({ })} /> +