diff --git a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts index 6224f2e9c27..4b376c4cdac 100644 --- a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts @@ -154,6 +154,7 @@ export async function POST(req: Request) { name: bounty.name, type: bounty.type, endsAt: bounty.endsAt, + rewardAmount: bounty.rewardAmount, description: bounty.description, }, program: { diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/group-additional-links.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/group-additional-links.tsx index 9973938d903..0976bfcfbac 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/group-additional-links.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/group-additional-links.tsx @@ -88,6 +88,10 @@ export function GroupAdditionalLinksForm({ group }: { group: GroupProps }) { const onSubmit = async (data: FormData) => { if (!group) return; + if (enableAdditionalLinks && (data.additionalLinks?.length ?? 0) === 0) { + return; + } + await updateGroup(`/api/groups/${group.id}`, { method: "PATCH", body: data, @@ -102,6 +106,8 @@ export function GroupAdditionalLinksForm({ group }: { group: GroupProps }) { const additionalLinks = watch("additionalLinks") || []; const maxPartnerLinks = watch("maxPartnerLinks") || 0; + const hasLinkFormat = additionalLinks.length > 0; + const cannotSaveWithoutLinkFormat = enableAdditionalLinks && !hasLinkFormat; const { addDestinationUrlModal, setIsOpen } = useAddDestinationUrlModal({ additionalLinks, @@ -120,25 +126,6 @@ export function GroupAdditionalLinksForm({ group }: { group: GroupProps }) { > {enableAdditionalLinks && ( <> - - - setValue("maxPartnerLinks", v, { - shouldDirty: true, - shouldValidate: true, - }) - } - min={0} - max={MAX_ADDITIONAL_PARTNER_LINKS} - step={1} - className="w-full" - /> - - + + + + setValue("maxPartnerLinks", v, { + shouldDirty: true, + shouldValidate: true, + }) + } + min={0} + max={MAX_ADDITIONAL_PARTNER_LINKS} + step={1} + className="w-full" + /> + )} @@ -218,7 +224,12 @@ export function GroupAdditionalLinksForm({ group }: { group: GroupProps }) { text="Save changes" className="h-8" loading={isSubmitting} - disabled={!isValid || !isDirty} + disabled={!isValid || !isDirty || cannotSaveWithoutLinkFormat} + disabledTooltip={ + cannotSaveWithoutLinkFormat + ? "Add at least one link format before saving." + : undefined + } /> diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-stats.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-stats.tsx index 518b76c54cf..0fccfdcb963 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-stats.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-stats.tsx @@ -1,6 +1,6 @@ -import { EnrolledPartnerProps } from "@/lib/types"; -import { ArrowUpRight2 } from "@dub/ui"; -import { cn, currencyFormatter, nFormatter } from "@dub/utils"; +import { EnrolledPartnerExtendedProps } from "@/lib/types"; +import { ArrowUpRight2, TimestampTooltip } from "@dub/ui"; +import { cn, currencyFormatter, nFormatter, timeAgo } from "@dub/utils"; import Link from "next/link"; import { useParams } from "next/navigation"; @@ -8,10 +8,29 @@ export function PartnerStats({ partner, error, }: { - partner?: EnrolledPartnerProps; + partner?: EnrolledPartnerExtendedProps; error?: boolean; }) { const { slug } = useParams() as { slug: string }; + + const lastLeadDate = partner?.lastLeadAt + ? new Date(partner.lastLeadAt) + : null; + const lastConversionDate = partner?.lastConversionAt + ? new Date(partner.lastConversionAt) + : null; + const approved = partner?.status === "approved"; + const leadsLastAt = + approved && lastLeadDate && !Number.isNaN(lastLeadDate.getTime()) + ? lastLeadDate + : undefined; + const conversionsLastAt = + approved && + lastConversionDate && + !Number.isNaN(lastConversionDate.getTime()) + ? lastConversionDate + : undefined; + return (
{ + ].map(({ label, value, href, lastAt }) => { const As = href ? Link : "div"; return ( )} + {lastAt ? ( + + + Last {timeAgo(lastAt, { withAgo: true })} + + + ) : null} ); })} diff --git a/apps/web/lib/api/scrape-creators/get-social-content.ts b/apps/web/lib/api/scrape-creators/get-social-content.ts index 8edff4b90d3..d457141ebda 100644 --- a/apps/web/lib/api/scrape-creators/get-social-content.ts +++ b/apps/web/lib/api/scrape-creators/get-social-content.ts @@ -60,7 +60,7 @@ export async function getSocialContent({ } const contentType = PLATFORM_CONTENT_TYPE[platform]; - const version = ["tiktok", "instagram"].includes(platform) ? "v2" : "v1"; + const version = platform === "tiktok" ? "v2" : "v1"; const { data, error } = await scrapeCreatorsFetch( "/:version/:platform/:contentType", @@ -91,6 +91,8 @@ export async function getSocialContent({ return EMPTY_SOCIAL_CONTENT; } + console.log(`Response from ScrapeCreators: ${JSON.stringify(data, null, 2)}`); + let result: SocialContent; switch (data.platform) { @@ -134,7 +136,10 @@ export async function getSocialContent({ mediaType = "carousel"; } else if (data.__typename === "GraphImage") { mediaType = "image"; - } else if (thumbnailUrls === undefined && data.video_play_count > 0) { + } else if ( + thumbnailUrls === undefined && + (data.video_play_count || data.video_view_count) > 0 + ) { mediaType = "video"; } @@ -142,7 +147,7 @@ export async function getSocialContent({ publishedAt: new Date(data.taken_at_timestamp * 1000), handle: data.owner.username, platformId: null, - views: data.video_play_count, + views: data.video_play_count ?? data.video_view_count, likes: data.edge_media_preview_like.count, title: null, description: data.edge_media_to_caption?.edges?.[0]?.node?.text ?? null, diff --git a/apps/web/lib/api/scrape-creators/schema.ts b/apps/web/lib/api/scrape-creators/schema.ts index 6bfe441fd73..8125409d023 100644 --- a/apps/web/lib/api/scrape-creators/schema.ts +++ b/apps/web/lib/api/scrape-creators/schema.ts @@ -234,6 +234,10 @@ export const socialContentSchema = z.preprocess( .number() .nullish() .transform((val) => val ?? 0), + video_view_count: z + .number() + .nullish() + .transform((val) => val ?? 0), edge_media_preview_like: z.object({ count: z .number() diff --git a/apps/web/lib/email/email-templates-map.ts b/apps/web/lib/email/email-templates-map.ts index d8abaec80f1..8e8bee323e3 100644 --- a/apps/web/lib/email/email-templates-map.ts +++ b/apps/web/lib/email/email-templates-map.ts @@ -1,5 +1,6 @@ import BountyApproved from "@dub/email/templates/bounty-approved"; import IdentityVerificationAnnouncement from "@dub/email/templates/broadcasts/identity-verification-announcement"; +import StablecoinPayoutsAnnouncement from "@dub/email/templates/broadcasts/stablecoin-payouts-announcement"; import ConnectPayoutReminder from "@dub/email/templates/connect-payout-reminder"; import ConnectPlatformsReminder from "@dub/email/templates/connect-platforms-reminder"; import PartnerBanned from "@dub/email/templates/partner-banned"; @@ -28,5 +29,5 @@ export const EMAIL_TEMPLATES_MAP = { IdentityVerificationAnnouncement, // PayoutAutoWithdrawals, // ProgramMarketplaceAnnouncement, - // StablecoinPayoutsAnnouncement, + StablecoinPayoutsAnnouncement, } as const; diff --git a/apps/web/ui/partners/partner-info-cards.tsx b/apps/web/ui/partners/partner-info-cards.tsx index c1360046785..5821800b2d5 100644 --- a/apps/web/ui/partners/partner-info-cards.tsx +++ b/apps/web/ui/partners/partner-info-cards.tsx @@ -13,7 +13,6 @@ import { usePartnerGroupHistorySheet } from "@/ui/activity-logs/partner-group-hi import { Button, CalendarIcon, - ChartActivity2, CopyButton, Globe, Heart, @@ -21,14 +20,14 @@ import { TimestampTooltip, Trophy, } from "@dub/ui"; -import { VerifiedBadge } from "@dub/ui/icons"; +import { TriangleWarning, Users, VerifiedBadge } from "@dub/ui/icons"; import { COUNTRIES, fetcher, formatDate, formatDateTimeSmart, - timeAgo, } from "@dub/utils"; +import { CircleMinus } from "lucide-react"; import Link from "next/link"; import { Fragment, ReactNode, createElement } from "react"; import useSWR from "swr"; @@ -93,10 +92,7 @@ export function PartnerInfoCards({ const isNetwork = type === "network"; const showPayoutMethodField = - isEnrolled && - program?.payoutMode !== "external" && - partner?.payoutsEnabledAt != null && - partner?.defaultPayoutMethod != null; + isEnrolled && program?.payoutMode !== "external"; const { partnerGroupHistorySheet, @@ -143,56 +139,52 @@ export function PartnerInfoCards({ }, ]; - if (isEnrolled) { + if (isEnrolled && partner) { basicFields = basicFields.concat([ - ...(partner?.status === "approved" - ? [ - { - id: "lastLeadAt", - icon: , - text: partner.lastLeadAt - ? `Last lead event ${timeAgo(new Date(partner.lastLeadAt), { withAgo: true })}` - : null, - timestamp: partner.lastLeadAt ?? undefined, - }, - { - id: "lastConversionAt", - icon: , - text: partner.lastConversionAt - ? `Last conversion event ${timeAgo(new Date(partner.lastConversionAt), { withAgo: true })}` - : null, - timestamp: partner.lastConversionAt ?? undefined, - }, - ] - : []), { id: "createdAt", - icon: , - text: partner - ? `${partner.status === "approved" ? "Partner since" : "Applied"} ${formatDate(partner.createdAt)}` - : undefined, - timestamp: partner?.createdAt, + icon: , + text: `${partner.status === "approved" ? "Partner since" : "Applied"} ${formatDate(partner.createdAt)}`, + timestamp: partner.createdAt, }, - ...(showPayoutMethodField && partner + ...(showPayoutMethodField ? [ { id: "payoutMethod" as const, - icon: createElement( - getPayoutMethodIconConfig(partner.defaultPayoutMethod!).Icon, - { className: "size-3.5 shrink-0" }, + icon: partner.defaultPayoutMethod ? ( + createElement( + getPayoutMethodIconConfig(partner.defaultPayoutMethod).Icon, + { className: "size-3.5 shrink-0" }, + ) + ) : ( + ), - text: `${getPayoutMethodLabel(partner.defaultPayoutMethod!)} connected ${formatDateTimeSmart(partner.payoutsEnabledAt!)}`, - timestamp: partner.payoutsEnabledAt!, + text: + partner.defaultPayoutMethod && partner.payoutsEnabledAt + ? `${getPayoutMethodLabel(partner.defaultPayoutMethod)} connected ${formatDateTimeSmart(partner.payoutsEnabledAt)}` + : "No payout method connected", + ...(partner.payoutsEnabledAt + ? { timestamp: partner.payoutsEnabledAt } + : {}), }, ] : []), - ...(partner?.identityVerifiedAt + // TODO: once more partners verify their identity, we can show this by default + ...(partner.identityVerifiedAt ? [ { id: "identityVerifiedAt", - icon: , - text: `Identity verified ${formatDate(partner.identityVerifiedAt, { month: "short" })}`, - timestamp: partner.identityVerifiedAt, + icon: partner.identityVerifiedAt ? ( + + ) : ( + + ), + text: partner.identityVerifiedAt + ? `Identity verified ${formatDate(partner.identityVerifiedAt, { month: "short" })}` + : "Identity not verified", + ...(partner.identityVerifiedAt + ? { timestamp: partner.identityVerifiedAt } + : {}), }, ] : []), diff --git a/apps/web/ui/partners/program-marketplace/programs-promo-banner.tsx b/apps/web/ui/partners/program-marketplace/programs-promo-banner.tsx index 2965b68405d..dad7425c6f7 100644 --- a/apps/web/ui/partners/program-marketplace/programs-promo-banner.tsx +++ b/apps/web/ui/partners/program-marketplace/programs-promo-banner.tsx @@ -21,7 +21,7 @@ export function ProgramsPromoBanner() { if ( !partner.identityVerifiedAt && - partner.country !== "IN" && + !["IN", "GE"].includes(partner.country ?? "") && (payoutsCount[0]?.amount ?? 0) > 10000 ) { return ; diff --git a/apps/web/ui/partners/program-marketplace/programs-promo-card.tsx b/apps/web/ui/partners/program-marketplace/programs-promo-card.tsx index 7b3a2e4e4e5..86949fa39ca 100644 --- a/apps/web/ui/partners/program-marketplace/programs-promo-card.tsx +++ b/apps/web/ui/partners/program-marketplace/programs-promo-card.tsx @@ -21,7 +21,7 @@ export function ProgramsPromoCard() { if ( !partner.identityVerifiedAt && - partner.country !== "IN" && + !["IN", "GE"].includes(partner.country ?? "") && (payoutsCount[0]?.amount ?? 0) > 10000 ) { return ; diff --git a/packages/email/src/templates/new-bounty-available.tsx b/packages/email/src/templates/new-bounty-available.tsx index 3b40d5dd992..afa583ec44d 100644 --- a/packages/email/src/templates/new-bounty-available.tsx +++ b/packages/email/src/templates/new-bounty-available.tsx @@ -1,6 +1,7 @@ -import { DUB_WORDMARK, formatDate } from "@dub/utils"; +import { currencyFormatter, DUB_WORDMARK, formatDate } from "@dub/utils"; import { Body, + Column, Container, Head, Heading, @@ -9,6 +10,7 @@ import { Link, Markdown, Preview, + Row, Section, Tailwind, Text, @@ -16,12 +18,20 @@ import { import { BountyThumbnailImage } from "../components/bounty-thumbnail"; import { Footer } from "../components/footer"; +const ICONS = { + calendar: "https://assets.dub.co/cms/icon-calendar-bounty.png", + gift: "https://assets.dub.co/cms/icon-gift-bounty.png", +} as const; + +type Icon = keyof typeof ICONS; + export default function NewBountyAvailable({ bounty = { id: "bty_xxx", name: "Promote Acme at your campus and earn $500", type: "performance", endsAt: new Date(), + rewardAmount: 10000, description: "How **does** it work?\n\nGet a group _together_ of at least 15 other people interested in trying out [Acme](https://dub.co). Then, during the event, take a photo of the group using Acme. When submitting, provide any links to the event or photos. Once confirmed, we'll create a one-time commission for you.", }, @@ -36,6 +46,7 @@ export default function NewBountyAvailable({ name: string; type: "performance" | "submission"; endsAt: Date | null; + rewardAmount: number | null; description: string | null; }; program: { @@ -44,6 +55,18 @@ export default function NewBountyAvailable({ }; email: string; }) { + const formattedRewardAmount = + bounty.rewardAmount != null + ? currencyFormatter(bounty.rewardAmount, { + trailingZeroDisplay: "stripIfInteger", + }) + : null; + + const iconSizeClassByIcon: Record = { + calendar: "h-4.5 w-4.5", + gift: "h-4.5 w-4.5", + }; + return ( @@ -68,10 +91,41 @@ export default function NewBountyAvailable({ {bounty.name} - {bounty.endsAt && ( - - Ends {formatDate(bounty.endsAt)} - + {(bounty.endsAt || formattedRewardAmount) && ( +
+ {bounty.endsAt && ( + + + + + + + Ends {formatDate(bounty.endsAt)} + + + + )} + {formattedRewardAmount && ( + + + + + + + Earn {formattedRewardAmount} + + + + )} +
)}