From 9a24c31e494cf61288be9b0bd64494b9f4fcc4dd Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Wed, 27 Aug 2025 15:38:10 -0400 Subject: [PATCH 01/81] WIP messages --- .../(ee)/program/messages/page-client.tsx | 92 +++++++++++++++++++ .../[slug]/(ee)/program/messages/page.tsx | 5 + apps/web/ui/layout/main-nav.tsx | 2 +- .../web/ui/layout/sidebar/app-sidebar-nav.tsx | 8 +- .../ui/messages/toggle-side-panel-button.tsx | 66 +++++++++++++ 5 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx create mode 100644 apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page.tsx create mode 100644 apps/web/ui/messages/toggle-side-panel-button.tsx diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx new file mode 100644 index 00000000000..62c5b230e33 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { NavButton } from "@/ui/layout/page-content/nav-button"; +import { ToggleSidePanelButton } from "@/ui/messages/toggle-side-panel-button"; +import { ChevronLeft } from "@dub/ui/icons"; +import { cn } from "@dub/utils"; +import { CSSProperties, useState } from "react"; + +export function ProgramMessagesPageClient() { + const [currentPanel, setCurrentPanel] = useState<0 | 1 | 2>(0); + + const isRightPanelOpen = currentPanel === 2; + + return ( +
+
+ {/* Left panel - Partner/messages list */} +
+
+
+ +
+

+ Messages +

+
+
+
+
setCurrentPanel(1)}>[side panel content]
+
+ + {/* Middle panel - Messages */} +
+
+
+ +

+ [Partner Name] +

+
+ { + setCurrentPanel((p) => (p === 2 ? 1 : 2)); + }} + /> +
+
[messages content]
+
+ + {/* Right panel - Profile */} +
+
+
+
+ +

+ Profile +

+
+
+
[profile panel content]
+
+
+
+
+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page.tsx new file mode 100644 index 00000000000..b04783ec6d8 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page.tsx @@ -0,0 +1,5 @@ +import { ProgramMessagesPageClient } from "./page-client"; + +export default function ProgramMessages() { + return ; +} diff --git a/apps/web/ui/layout/main-nav.tsx b/apps/web/ui/layout/main-nav.tsx index ccfd3149546..d7f8bda4b1e 100644 --- a/apps/web/ui/layout/main-nav.tsx +++ b/apps/web/ui/layout/main-nav.tsx @@ -79,7 +79,7 @@ export function MainNav({ -
+
{children} diff --git a/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx b/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx index 4ebd9d9cd95..28bf87a1a9d 100644 --- a/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx +++ b/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx @@ -21,6 +21,7 @@ import { LifeRing, LinesY as LinesYStatic, MoneyBills2, + Msgs, Receipt2, ShieldCheck, ShieldKeyhole, @@ -199,6 +200,12 @@ const NAV_AREAS: SidebarNavAreas = { icon: MoneyBills2, href: `/${slug}/program/payouts?status=pending&sortBy=amount`, }, + { + name: "Messages", + icon: Msgs, + href: `/${slug}/program/messages`, + badge: "New", + }, ], }, { @@ -224,7 +231,6 @@ const NAV_AREAS: SidebarNavAreas = { name: "Groups", icon: Users6, href: `/${slug}/program/groups`, - badge: "New", }, ], }, diff --git a/apps/web/ui/messages/toggle-side-panel-button.tsx b/apps/web/ui/messages/toggle-side-panel-button.tsx new file mode 100644 index 00000000000..dee84758dc8 --- /dev/null +++ b/apps/web/ui/messages/toggle-side-panel-button.tsx @@ -0,0 +1,66 @@ +import { Button } from "@dub/ui"; +import { cn } from "@dub/utils"; + +export function ToggleSidePanelButton({ + side = "right", + isOpen, + onClick, +}: { + side?: "left" | "right"; + isOpen: boolean; + onClick: () => void; +}) { + return ( +
+
{/* Middle panel - Messages */} From ec4050ab0ac0cba2c553ccab6e831128a8b12fbf Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Thu, 28 Aug 2025 12:37:27 -0400 Subject: [PATCH 03/81] Messages list panel --- .../(ee)/program/messages/page-client.tsx | 90 ++++++++++++++----- apps/web/ui/messages/messages-list.tsx | 69 ++++++++++++++ 2 files changed, 138 insertions(+), 21 deletions(-) create mode 100644 apps/web/ui/messages/messages-list.tsx diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx index 6a99099474b..67a946017e8 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx @@ -1,16 +1,55 @@ "use client"; +import useWorkspace from "@/lib/swr/use-workspace"; import { NavButton } from "@/ui/layout/page-content/nav-button"; +import { MessagesList } from "@/ui/messages/messages-list"; import { ToggleSidePanelButton } from "@/ui/messages/toggle-side-panel-button"; import { Button } from "@dub/ui"; import { ChevronLeft, Msgs } from "@dub/ui/icons"; import { cn } from "@dub/utils"; +import { subMinutes } from "date-fns"; import { CSSProperties, useState } from "react"; import { toast } from "sonner"; export function ProgramMessagesPageClient() { - const [currentPanel, setCurrentPanel] = useState<0 | 1 | 2>(0); + const { slug: workspaceSlug } = useWorkspace(); + + const partnersWithMessages = [ + { + id: "pn_1KcRT7do2foT1PZ9zZhLF0Cq", + name: "Tim Wilson", + avatar: "https://dubassets.com/avatars/clro5ctqd0000jv084g63ua08", + messages: [ + { + text: "Hello, how are you?", + createdAt: subMinutes(new Date(), 5), + readStatus: "read-app", + }, + { + text: "Great, thanks! What about you?", + createdAt: subMinutes(new Date(), 5), + readStatus: "read-app", + }, + ], + }, + { + id: "pn_1JZ8GFVXAMTXEYF33QKWZAZ0Y", + name: "Tim Partner11", + avatar: + "https://dev.dubassets.com/partners/pn_1JZ8GFVXAMTXEYF33QKWZAZ0Y/image_nMMv6kL", + messages: [ + { + text: "Thanks for approving my application!", + createdAt: subMinutes(new Date(), 5), + readStatus: "read-app", + }, + ], + }, + ]; + const isLoading = false; + const error = null; + const [currentPanel, setCurrentPanel] = useState<0 | 1 | 2>(0); const isRightPanelOpen = currentPanel === 2; return ( @@ -36,29 +75,38 @@ export function ProgramMessagesPageClient() {
-
- {/* TODO: Remove onClick (it's there for testing) */} - setCurrentPanel(1)} + {partnersWithMessages?.length || isLoading ? ( + ({ + ...p, + href: `/${workspaceSlug}/program/messages/${p.id}`, + }))} /> -
- - You don't have any messages - -

- When you receive a new message, it will appear here. You can - also start a conversation at any time. -

+ ) : error ? ( +
+ Failed to load messages
+ ) : ( +
+ +
+ + You don't have any messages + +

+ When you receive a new message, it will appear here. You can + also start a conversation at any time. +

+
-
+
+ )}
diff --git a/apps/web/ui/messages/messages-list.tsx b/apps/web/ui/messages/messages-list.tsx new file mode 100644 index 00000000000..169fe95cbad --- /dev/null +++ b/apps/web/ui/messages/messages-list.tsx @@ -0,0 +1,69 @@ +import { OG_AVATAR_URL, timeAgo } from "@dub/utils"; +import Link from "next/link"; + +export function MessagesList({ + groupedMessages, +}: { + groupedMessages: + | { + id: string; + name: string; + avatar: string | null; + messages: { text: string; createdAt: Date }[]; + href: string; + }[] + | undefined; +}) { + return ( +
+ {groupedMessages + ? groupedMessages.map((group) => { + const lastMessage = group.messages.at(-1); + + return ( + + {`${group.name} +
+
+ + {group.name} + + {lastMessage && ( + + {timeAgo(lastMessage.createdAt, { withAgo: true })} + + )} +
+ + {lastMessage?.text} + +
+ + ); + }) + : [...Array(3)].map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} From db7798421b07fc3f8b59141c638163d167c28a42 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Thu, 28 Aug 2025 16:34:46 -0400 Subject: [PATCH 04/81] WIP messages --- .../messages/[partnerId]/page-client.tsx | 72 ++++++++ .../program/messages/[partnerId]/page.tsx | 5 + .../[slug]/(ee)/program/messages/layout.tsx | 124 +++++++++++++ .../(ee)/program/messages/page-client.tsx | 174 +++--------------- apps/web/ui/messages/messages-context.tsx | 15 ++ apps/web/ui/messages/messages-list.tsx | 4 + 6 files changed, 241 insertions(+), 153 deletions(-) create mode 100644 apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx create mode 100644 apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page.tsx create mode 100644 apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx create mode 100644 apps/web/ui/messages/messages-context.tsx diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx new file mode 100644 index 00000000000..91753f9e642 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useMessagesContext } from "@/ui/messages/messages-context"; +import { ToggleSidePanelButton } from "@/ui/messages/toggle-side-panel-button"; +import { X } from "@/ui/shared/icons"; +import { ChevronLeft } from "@dub/ui/icons"; +import { cn } from "@dub/utils"; +import { useState } from "react"; + +export function ProgramMessagesPartnerPageClient() { + const [isRightPanelOpen, setIsRightPanelOpen] = useState(false); + + const { setCurrentPanel } = useMessagesContext(); + + return ( +
+
+
+
+ +

+ [Partner Name] +

+
+ setIsRightPanelOpen((o) => !o)} + /> +
+ [messages with partner] +
+ + {/* Right panel - Profile */} +
+
+
+

+ Profile +

+
+ +
+
+
[profile panel content]
+
+
+
+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page.tsx new file mode 100644 index 00000000000..ac151fc1ec6 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page.tsx @@ -0,0 +1,5 @@ +import { ProgramMessagesPartnerPageClient } from "./page-client"; + +export default function ProgramMessagesPartnerPage() { + return ; +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx new file mode 100644 index 00000000000..3cd8627fb63 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx @@ -0,0 +1,124 @@ +"use client"; + +import useWorkspace from "@/lib/swr/use-workspace"; +import { NavButton } from "@/ui/layout/page-content/nav-button"; +import { MessagesContext, MessagesPanel } from "@/ui/messages/messages-context"; +import { MessagesList } from "@/ui/messages/messages-list"; +import { Button } from "@dub/ui"; +import { Msgs, Pen2 } from "@dub/ui/icons"; +import { subMinutes } from "date-fns"; +import { CSSProperties, ReactNode, useState } from "react"; +import { toast } from "sonner"; + +export default function MessagesLayout({ children }: { children: ReactNode }) { + const { slug: workspaceSlug } = useWorkspace(); + + // TODO: [Messages] fetch real data + const partnersWithMessages = [ + { + id: "pn_1KcRT7do2foT1PZ9zZhLF0Cq", + name: "Tim Wilson", + avatar: "https://dubassets.com/avatars/clro5ctqd0000jv084g63ua08", + messages: [ + { + text: "Hello, how are you?", + createdAt: subMinutes(new Date(), 5), + readStatus: "read-app", + }, + { + text: "Great, thanks! What about you?", + createdAt: subMinutes(new Date(), 5), + readStatus: "read-app", + }, + ], + }, + { + id: "pn_1JZ8GFVXAMTXEYF33QKWZAZ0Y", + name: "Tim Partner11", + avatar: + "https://dev.dubassets.com/partners/pn_1JZ8GFVXAMTXEYF33QKWZAZ0Y/image_nMMv6kL", + messages: [ + { + text: "Thanks for approving my application!", + createdAt: subMinutes(new Date(), 5), + readStatus: "read-app", + }, + ], + }, + ]; + const isLoading = false; + const error = null; + + const [currentPanel, setCurrentPanel] = useState("index"); + + return ( + +
+
+ {/* Left panel - 800px/messages list */} +
+
+
+ +

+ Messages +

+
+
+
+ {partnersWithMessages?.length || isLoading ? ( + ({ + ...p, + href: `/${workspaceSlug}/program/messages/${p.id}`, + }))} + /> + ) : error ? ( +
+ Failed to load messages +
+ ) : ( +
+ +
+ + You don't have any messages + +

+ When you receive a new message, it will appear here. You + can also start a conversation at any time. +

+
+ +
+ )} +
+
+ +
+ {children} +
+
+
+
+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx index 67a946017e8..ad5954bdd25 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx @@ -1,165 +1,33 @@ "use client"; -import useWorkspace from "@/lib/swr/use-workspace"; -import { NavButton } from "@/ui/layout/page-content/nav-button"; -import { MessagesList } from "@/ui/messages/messages-list"; -import { ToggleSidePanelButton } from "@/ui/messages/toggle-side-panel-button"; -import { Button } from "@dub/ui"; +import { useMessagesContext } from "@/ui/messages/messages-context"; import { ChevronLeft, Msgs } from "@dub/ui/icons"; -import { cn } from "@dub/utils"; -import { subMinutes } from "date-fns"; -import { CSSProperties, useState } from "react"; -import { toast } from "sonner"; export function ProgramMessagesPageClient() { - const { slug: workspaceSlug } = useWorkspace(); - - const partnersWithMessages = [ - { - id: "pn_1KcRT7do2foT1PZ9zZhLF0Cq", - name: "Tim Wilson", - avatar: "https://dubassets.com/avatars/clro5ctqd0000jv084g63ua08", - messages: [ - { - text: "Hello, how are you?", - createdAt: subMinutes(new Date(), 5), - readStatus: "read-app", - }, - { - text: "Great, thanks! What about you?", - createdAt: subMinutes(new Date(), 5), - readStatus: "read-app", - }, - ], - }, - { - id: "pn_1JZ8GFVXAMTXEYF33QKWZAZ0Y", - name: "Tim Partner11", - avatar: - "https://dev.dubassets.com/partners/pn_1JZ8GFVXAMTXEYF33QKWZAZ0Y/image_nMMv6kL", - messages: [ - { - text: "Thanks for approving my application!", - createdAt: subMinutes(new Date(), 5), - readStatus: "read-app", - }, - ], - }, - ]; - const isLoading = false; - const error = null; - - const [currentPanel, setCurrentPanel] = useState<0 | 1 | 2>(0); - const isRightPanelOpen = currentPanel === 2; + const { setCurrentPanel } = useMessagesContext(); return ( -
-
- {/* Left panel - Partner/messages list */} -
-
-
- -
-

- Messages -

-
-
-
-
- {partnersWithMessages?.length || isLoading ? ( - ({ - ...p, - href: `/${workspaceSlug}/program/messages/${p.id}`, - }))} - /> - ) : error ? ( -
- Failed to load messages -
- ) : ( -
- -
- - You don't have any messages - -

- When you receive a new message, it will appear here. You can - also start a conversation at any time. -

-
- -
- )} -
-
- - {/* Middle panel - Messages */} -
-
-
- -

- [Partner Name] -

-
- { - setCurrentPanel((p) => (p === 2 ? 1 : 2)); - }} - /> -
-
[messages content]
+
+
+
+ +

+ [Partner Name] +

+
- {/* Right panel - Profile */} -
-
-
-
- -

- Profile -

-
-
-
[profile panel content]
-
-
+
+ +

+ Select or compose a message +

); diff --git a/apps/web/ui/messages/messages-context.tsx b/apps/web/ui/messages/messages-context.tsx new file mode 100644 index 00000000000..06a55feb908 --- /dev/null +++ b/apps/web/ui/messages/messages-context.tsx @@ -0,0 +1,15 @@ +import { Dispatch, SetStateAction, createContext, useContext } from "react"; + +export type MessagesPanel = "index" | "main"; + +export const MessagesContext = createContext<{ + currentPanel: MessagesPanel; + setCurrentPanel: Dispatch>; +}>({ + currentPanel: "index", + setCurrentPanel: () => {}, +}); + +export function useMessagesContext() { + return useContext(MessagesContext); +} diff --git a/apps/web/ui/messages/messages-list.tsx b/apps/web/ui/messages/messages-list.tsx index 169fe95cbad..1d976d9f257 100644 --- a/apps/web/ui/messages/messages-list.tsx +++ b/apps/web/ui/messages/messages-list.tsx @@ -1,5 +1,6 @@ import { OG_AVATAR_URL, timeAgo } from "@dub/utils"; import Link from "next/link"; +import { useMessagesContext } from "./messages-context"; export function MessagesList({ groupedMessages, @@ -14,6 +15,8 @@ export function MessagesList({ }[] | undefined; }) { + const { setCurrentPanel } = useMessagesContext(); + return (
{groupedMessages @@ -24,6 +27,7 @@ export function MessagesList({ setCurrentPanel("main")} className="hover:bg-bg-muted border-border-subtle flex w-full items-center gap-2.5 border-b bg-white px-6 py-4" > Date: Thu, 28 Aug 2025 16:51:10 -0400 Subject: [PATCH 05/81] Active items + partner info --- .../messages/[partnerId]/page-client.tsx | 39 +++++++++++++++---- .../[slug]/(ee)/program/messages/layout.tsx | 7 +++- .../(ee)/program/messages/page-client.tsx | 4 +- apps/web/ui/messages/messages-list.tsx | 13 +++++-- .../ui/messages/toggle-side-panel-button.tsx | 2 +- 5 files changed, 49 insertions(+), 16 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx index 91753f9e642..1a83eef7560 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx @@ -1,16 +1,25 @@ "use client"; +import usePartner from "@/lib/swr/use-partner"; +import useWorkspace from "@/lib/swr/use-workspace"; import { useMessagesContext } from "@/ui/messages/messages-context"; import { ToggleSidePanelButton } from "@/ui/messages/toggle-side-panel-button"; import { X } from "@/ui/shared/icons"; import { ChevronLeft } from "@dub/ui/icons"; -import { cn } from "@dub/utils"; +import { OG_AVATAR_URL, cn } from "@dub/utils"; +import { redirect, useParams } from "next/navigation"; import { useState } from "react"; export function ProgramMessagesPartnerPageClient() { - const [isRightPanelOpen, setIsRightPanelOpen] = useState(false); + const { slug: workspaceSlug } = useWorkspace(); + + const { partnerId } = useParams() as { partnerId: string }; + const { partner, loading, error } = usePartner({ partnerId }); const { setCurrentPanel } = useMessagesContext(); + const [isRightPanelOpen, setIsRightPanelOpen] = useState(false); + + if (error) redirect(`/${workspaceSlug}/program/messages`); return (
-
+
-

- [Partner Name] -

+
+ {loading ? ( + <> +
+
+ + ) : ( + <> + {`${partner?.name} +

+ {partner?.name ?? "Partner"} +

+ + )} +
) : error ? (
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx index ad5954bdd25..5427f10c1ad 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx @@ -17,9 +17,9 @@ export function ProgramMessagesPageClient() { > -

+ {/*

[Partner Name] -

+ */}
diff --git a/apps/web/ui/messages/messages-list.tsx b/apps/web/ui/messages/messages-list.tsx index 1d976d9f257..a58f2e65256 100644 --- a/apps/web/ui/messages/messages-list.tsx +++ b/apps/web/ui/messages/messages-list.tsx @@ -1,19 +1,21 @@ -import { OG_AVATAR_URL, timeAgo } from "@dub/utils"; +import { OG_AVATAR_URL, cn, timeAgo } from "@dub/utils"; import Link from "next/link"; import { useMessagesContext } from "./messages-context"; export function MessagesList({ groupedMessages, + activeId, }: { groupedMessages: | { id: string; name: string; - avatar: string | null; + image: string | null; messages: { text: string; createdAt: Date }[]; href: string; }[] | undefined; + activeId?: string; }) { const { setCurrentPanel } = useMessagesContext(); @@ -28,10 +30,13 @@ export function MessagesList({ key={group.id} href={group.href} onClick={() => setCurrentPanel("main")} - className="hover:bg-bg-muted border-border-subtle flex w-full items-center gap-2.5 border-b bg-white px-6 py-4" + className={cn( + "border-border-subtle flex w-full items-center gap-2.5 border-b bg-white px-6 py-4", + group.id === activeId ? "bg-bg-subtle" : "hover:bg-bg-muted", + )} > {`${group.name} diff --git a/apps/web/ui/messages/toggle-side-panel-button.tsx b/apps/web/ui/messages/toggle-side-panel-button.tsx index dee84758dc8..53f2a5275e6 100644 --- a/apps/web/ui/messages/toggle-side-panel-button.tsx +++ b/apps/web/ui/messages/toggle-side-panel-button.tsx @@ -14,7 +14,7 @@ export function ToggleSidePanelButton({
- [messages with partner] +
+ [WIP] +
{/* Right panel - Profile */} @@ -80,6 +86,17 @@ export function ProgramMessagesPartnerPageClient() { Profile
+
-
[profile panel content]
+
+ {partner ? ( + <> + + + + + ) : ( +
+ +
+ )} +
diff --git a/apps/web/ui/partners/partner-details-sheet.tsx b/apps/web/ui/partners/partner-details-sheet.tsx index 3d9333b76ff..ffa1318757a 100644 --- a/apps/web/ui/partners/partner-details-sheet.tsx +++ b/apps/web/ui/partners/partner-details-sheet.tsx @@ -1,7 +1,6 @@ import { revokeProgramInviteAction } from "@/lib/actions/partners/revoke-program-invite"; import { PAYOUTS_SHEET_ITEMS_LIMIT } from "@/lib/partners/constants"; import { mutatePrefix } from "@/lib/swr/mutate"; -import useGroups from "@/lib/swr/use-groups"; import usePayouts from "@/lib/swr/use-payouts"; import useWorkspace from "@/lib/swr/use-workspace"; import { EnrolledPartnerProps } from "@/lib/types"; @@ -30,10 +29,10 @@ import { toast } from "sonner"; import { AnimatedEmptyState } from "../shared/animated-empty-state"; import { useAddPartnerLinkModal } from "./add-partner-link-modal"; import { useBanPartnerModal } from "./ban-partner-modal"; -import { useChangeGroupModal } from "./change-group-modal"; -import { GroupColorCircle } from "./groups/group-color-circle"; import { usePartnerApplicationSheet } from "./partner-application-sheet"; +import { PartnerInfoGroup } from "./partner-info-group"; import { PartnerInfoSection } from "./partner-info-section"; +import { PartnerInfoStats } from "./partner-info-stats"; import { usePartnerProfileSheet } from "./partner-profile-sheet"; import { PayoutStatusBadges } from "./payout-status-badges"; import { useUnbanPartnerModal } from "./unban-partner-modal"; @@ -49,14 +48,6 @@ function PartnerDetailsSheetContent({ partner }: PartnerDetailsSheetProps) { const { slug } = useWorkspace(); const [tab, setTab] = useState("links"); - const { groups } = useGroups(); - - const group = groups?.find((g) => g.id === partner.groupId); - - const { ChangeGroupModal, setShowChangeGroupModal } = useChangeGroupModal({ - partners: [partner], - }); - const { createCommissionSheet, setIsOpen: setCreateCommissionSheetOpen } = useCreateCommissionSheet({ nested: true, @@ -70,7 +61,6 @@ function PartnerDetailsSheetContent({ partner }: PartnerDetailsSheetProps) { return (
-
@@ -94,93 +84,14 @@ function PartnerDetailsSheetContent({ partner }: PartnerDetailsSheetProps) { {/* Group */} -
-
- {group ? ( - - ) : ( -
- )} - {group ? ( - - {group.name} - - ) : ( -
- )} -
- {group ? ( -
-
- [WIP] +
+ toast.info(message)} />
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx index 8265bf64256..ed17c1506f1 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx @@ -55,7 +55,7 @@ export default function MessagesLayout({ children }: { children: ReactNode }) { return ( -
+
-
+
{children} diff --git a/apps/web/ui/messages/emoji-picker.tsx b/apps/web/ui/messages/emoji-picker.tsx new file mode 100644 index 00000000000..a636c0386a1 --- /dev/null +++ b/apps/web/ui/messages/emoji-picker.tsx @@ -0,0 +1,74 @@ +import { Button, Popover } from "@dub/ui"; +import { FaceSmile } from "@dub/ui/icons"; +import { EmojiPicker as EmojiPickerBase } from "frimousse"; +import { useState } from "react"; + +export function EmojiPicker({ + onSelect, +}: { + onSelect: (emoji: string) => void; +}) { + const [isOpen, setIsOpen] = useState(false); + + return ( + { + onSelect(emoji); + setIsOpen(false); + }} + > + + + + Loading… + + + No emoji found. + + ( +
+ {category.label} +
+ ), + Row: ({ children, ...props }) => ( +
+ {children} +
+ ), + Emoji: ({ emoji, ...props }) => ( + + ), + }} + /> +
+ + } + > +
+
+
+
+ ); +} diff --git a/packages/ui/src/icons/nucleo/face-smile.tsx b/packages/ui/src/icons/nucleo/face-smile.tsx new file mode 100644 index 00000000000..a57efae1daf --- /dev/null +++ b/packages/ui/src/icons/nucleo/face-smile.tsx @@ -0,0 +1,36 @@ +import { SVGProps } from "react"; + +export function FaceSmile(props: SVGProps) { + return ( + + + + + + + + + ); +} diff --git a/packages/ui/src/icons/nucleo/index.ts b/packages/ui/src/icons/nucleo/index.ts index 8c435c07fea..596c0c6ac4f 100644 --- a/packages/ui/src/icons/nucleo/index.ts +++ b/packages/ui/src/icons/nucleo/index.ts @@ -89,6 +89,7 @@ export * from "./envelope-arrow-right"; export * from "./envelope-fill"; export * from "./eye"; export * from "./eye-slash"; +export * from "./face-smile"; export * from "./feather-fill"; export * from "./file-content"; export * from "./file-zip2"; diff --git a/packages/ui/src/popover.tsx b/packages/ui/src/popover.tsx index cb5cec8602d..94b644cc883 100644 --- a/packages/ui/src/popover.tsx +++ b/packages/ui/src/popover.tsx @@ -19,6 +19,7 @@ export type PopoverProps = PropsWithChildren<{ sticky?: "partial" | "always"; onEscapeKeyDown?: (event: KeyboardEvent) => void; onWheel?: WheelEventHandler; + sideOffset?: number; }>; export function Popover({ @@ -35,6 +36,7 @@ export function Popover({ sticky, onEscapeKeyDown, onWheel, + sideOffset = 8, }: PopoverProps) { const { isMobile } = useMediaQuery(); @@ -79,7 +81,7 @@ export function Popover({ =5.1.0' + peerDependenciesMeta: + typescript: + optional: true + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -11522,7 +11534,7 @@ snapshots: '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.29.0 - '@boxyhq/saml-jackson@1.41.0(socks@2.8.4)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.4.4))': + '@boxyhq/saml-jackson@1.41.0(socks@2.8.4)(ts-node@10.9.2(@types/node@18.11.9)(typescript@5.4.4))': dependencies: '@aws-sdk/client-dynamodb': 3.758.0 '@aws-sdk/credential-providers': 3.758.0 @@ -11547,7 +11559,7 @@ snapshots: reflect-metadata: 0.2.2 ripemd160: 2.0.2 sqlite3: 5.1.7 - typeorm: 0.3.21(mongodb@6.14.2(@aws-sdk/credential-providers@3.758.0)(socks@2.8.4))(mssql@11.0.1)(mysql2@3.13.0)(pg@8.13.3)(redis@4.7.0)(reflect-metadata@0.2.2)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.4.4)) + typeorm: 0.3.21(mongodb@6.14.2(@aws-sdk/credential-providers@3.758.0)(socks@2.8.4))(mssql@11.0.1)(mysql2@3.13.0)(pg@8.13.3)(redis@4.7.0)(reflect-metadata@0.2.2)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@18.11.9)(typescript@5.4.4)) transitivePeerDependencies: - '@google-cloud/spanner' - '@mongodb-js/zstd' @@ -12151,7 +12163,7 @@ snapshots: jest-util: 27.5.1 slash: 3.0.0 - '@jest/core@27.5.1(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@4.9.5))': + '@jest/core@27.5.1(ts-node@10.9.2(typescript@4.9.5))': dependencies: '@jest/console': 27.5.1 '@jest/reporters': 27.5.1 @@ -12165,7 +12177,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 27.5.1 - jest-config: 27.5.1(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@4.9.5)) + jest-config: 27.5.1(ts-node@10.9.2(typescript@4.9.5)) jest-haste-map: 27.5.1 jest-message-util: 27.5.1 jest-regex-util: 27.5.1 @@ -14819,7 +14831,7 @@ snapshots: react: 17.0.2 stripe: 13.11.0 - '@stripe/ui-extension-tools@0.0.1(@babel/core@7.24.5)(babel-jest@27.5.1(@babel/core@7.24.5))(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@4.9.5))': + '@stripe/ui-extension-tools@0.0.1(@babel/core@7.24.5)(babel-jest@27.5.1(@babel/core@7.24.5))(ts-node@10.9.2(typescript@4.9.5))': dependencies: '@types/jest': 28.1.8 '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.48.0)(typescript@4.9.5))(eslint@8.48.0)(typescript@4.9.5) @@ -14827,9 +14839,9 @@ snapshots: eslint: 8.48.0 eslint-plugin-react: 7.34.1(eslint@8.48.0) eslint-plugin-react-hooks: 4.6.2(eslint@8.48.0) - jest: 27.5.1(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@4.9.5)) + jest: 27.5.1(ts-node@10.9.2(typescript@4.9.5)) jest-transform-stub: 2.0.0 - ts-jest: 27.1.5(@babel/core@7.24.5)(@types/jest@28.1.8)(babel-jest@27.5.1(@babel/core@7.24.5))(jest@27.5.1(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@4.9.5)))(typescript@4.9.5) + ts-jest: 27.1.5(@babel/core@7.24.5)(@types/jest@28.1.8)(babel-jest@27.5.1(@babel/core@7.24.5))(jest@27.5.1(ts-node@10.9.2(typescript@4.9.5)))(typescript@4.9.5) typescript: 4.9.5 transitivePeerDependencies: - '@babel/core' @@ -14914,22 +14926,22 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2)))': + '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.4(ts-node@10.9.2(typescript@5.6.2)))': dependencies: - tailwindcss: 3.4.4(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2)) + tailwindcss: 3.4.4(ts-node@10.9.2(typescript@5.6.2)) - '@tailwindcss/forms@0.5.6(tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2)))': + '@tailwindcss/forms@0.5.6(tailwindcss@3.4.4(ts-node@10.9.2(typescript@5.6.2)))': dependencies: mini-svg-data-uri: 1.4.4 - tailwindcss: 3.4.4(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2)) + tailwindcss: 3.4.4(ts-node@10.9.2(typescript@5.6.2)) - '@tailwindcss/typography@0.5.9(tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2)))': + '@tailwindcss/typography@0.5.9(tailwindcss@3.4.4(ts-node@10.9.2(typescript@5.6.2)))': dependencies: lodash.castarray: 4.4.0 lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.4(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2)) + tailwindcss: 3.4.4(ts-node@10.9.2(typescript@5.6.2)) '@tanstack/react-table@8.17.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: @@ -17513,6 +17525,12 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + frimousse@0.3.0(react@18.2.0)(typescript@5.4.4): + dependencies: + react: 18.2.0 + optionalDependencies: + typescript: 5.4.4 + fs-constants@1.0.0: {} fs-extra@11.2.0: @@ -18243,16 +18261,16 @@ snapshots: transitivePeerDependencies: - supports-color - jest-cli@27.5.1(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@4.9.5)): + jest-cli@27.5.1(ts-node@10.9.2(typescript@4.9.5)): dependencies: - '@jest/core': 27.5.1(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@4.9.5)) + '@jest/core': 27.5.1(ts-node@10.9.2(typescript@4.9.5)) '@jest/test-result': 27.5.1 '@jest/types': 27.5.1 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 import-local: 3.1.0 - jest-config: 27.5.1(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@4.9.5)) + jest-config: 27.5.1(ts-node@10.9.2(typescript@4.9.5)) jest-util: 27.5.1 jest-validate: 27.5.1 prompts: 2.4.2 @@ -18264,7 +18282,7 @@ snapshots: - ts-node - utf-8-validate - jest-config@27.5.1(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@4.9.5)): + jest-config@27.5.1(ts-node@10.9.2(typescript@4.9.5)): dependencies: '@babel/core': 7.24.5 '@jest/test-sequencer': 27.5.1 @@ -18291,7 +18309,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - ts-node: 10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@4.9.5) + ts-node: 10.9.2(typescript@4.9.5) transitivePeerDependencies: - bufferutil - canvas @@ -18615,11 +18633,11 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@27.5.1(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@4.9.5)): + jest@27.5.1(ts-node@10.9.2(typescript@4.9.5)): dependencies: - '@jest/core': 27.5.1(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@4.9.5)) + '@jest/core': 27.5.1(ts-node@10.9.2(typescript@4.9.5)) import-local: 3.1.0 - jest-cli: 27.5.1(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@4.9.5)) + jest-cli: 27.5.1(ts-node@10.9.2(typescript@4.9.5)) transitivePeerDependencies: - bufferutil - canvas @@ -20017,61 +20035,69 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.4.38 - postcss-load-config@3.1.4(postcss@8.4.31)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.1.6)): + postcss-load-config@3.1.4(postcss@8.4.31)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.6.2)): dependencies: lilconfig: 2.1.0 yaml: 1.10.2 optionalDependencies: postcss: 8.4.31 - ts-node: 10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.1.6) + ts-node: 10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.6.2) - postcss-load-config@3.1.4(postcss@8.4.31)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2)): + postcss-load-config@3.1.4(postcss@8.4.31)(ts-node@10.9.2(@swc/core@1.3.101)(typescript@5.1.6)): dependencies: lilconfig: 2.1.0 yaml: 1.10.2 optionalDependencies: postcss: 8.4.31 - ts-node: 10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2) + ts-node: 10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.1.6) + + postcss-load-config@3.1.4(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.1.6)): + dependencies: + lilconfig: 2.1.0 + yaml: 1.10.2 + optionalDependencies: + postcss: 8.4.38 + ts-node: 10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.1.6) - postcss-load-config@3.1.4(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.1.6)): + postcss-load-config@3.1.4(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.6.2)): dependencies: lilconfig: 2.1.0 yaml: 1.10.2 optionalDependencies: postcss: 8.4.38 - ts-node: 10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.1.6) + ts-node: 10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.6.2) - postcss-load-config@3.1.4(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2)): + postcss-load-config@3.1.4(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(typescript@5.6.2)): dependencies: lilconfig: 2.1.0 yaml: 1.10.2 optionalDependencies: postcss: 8.4.38 - ts-node: 10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2) + ts-node: 10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.6.2) - postcss-load-config@4.0.1(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.1.6)): + postcss-load-config@4.0.1(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(typescript@5.1.6)): dependencies: lilconfig: 2.1.0 yaml: 2.3.4 optionalDependencies: postcss: 8.4.38 - ts-node: 10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.1.6) + ts-node: 10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.1.6) - postcss-load-config@4.0.1(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.4.4)): + postcss-load-config@4.0.1(postcss@8.4.38)(ts-node@10.9.2(@types/node@18.11.9)(typescript@5.4.4)): dependencies: lilconfig: 2.1.0 yaml: 2.3.4 optionalDependencies: postcss: 8.4.38 - ts-node: 10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.4.4) + ts-node: 10.9.2(@types/node@18.11.9)(typescript@5.4.4) - postcss-load-config@4.0.1(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2)): + postcss-load-config@4.0.1(postcss@8.4.38)(ts-node@10.9.2(typescript@5.6.2)): dependencies: lilconfig: 2.1.0 yaml: 2.3.4 optionalDependencies: postcss: 8.4.38 - ts-node: 10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2) + ts-node: 10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.6.2) postcss-nested@6.0.1(postcss@8.4.38): dependencies: @@ -20298,7 +20324,7 @@ snapshots: react: 19.0.0 scheduler: 0.25.0 - react-email@2.1.6(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.12)(eslint@8.48.0)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2)): + react-email@2.1.6(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.12)(eslint@8.48.0)(ts-node@10.9.2(typescript@5.6.2)): dependencies: '@babel/core': 7.24.5 '@babel/parser': 7.24.5 @@ -20338,7 +20364,7 @@ snapshots: source-map-js: 1.0.2 stacktrace-parser: 0.1.10 tailwind-merge: 2.2.0 - tailwindcss: 3.4.0(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2)) + tailwindcss: 3.4.0(ts-node@10.9.2(typescript@5.6.2)) typescript: 5.1.6 transitivePeerDependencies: - '@opentelemetry/api' @@ -20392,10 +20418,10 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-pdf-tailwind@2.3.0(encoding@0.1.13)(react@18.2.0)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.4.4)): + react-pdf-tailwind@2.3.0(encoding@0.1.13)(react@18.2.0)(ts-node@10.9.2(@types/node@18.11.9)(typescript@5.4.4)): dependencies: '@react-pdf/renderer': 3.4.5(encoding@0.1.13)(react@18.2.0) - tailwindcss: 3.4.4(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.4.4)) + tailwindcss: 3.4.4(ts-node@10.9.2(@types/node@18.11.9)(typescript@5.4.4)) transitivePeerDependencies: - encoding - react @@ -21311,7 +21337,7 @@ snapshots: tailwindcss-radix@2.8.0: {} - tailwindcss@3.4.0(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2)): + tailwindcss@3.4.0(ts-node@10.9.2(typescript@5.6.2)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -21330,7 +21356,7 @@ snapshots: postcss: 8.4.38 postcss-import: 15.1.0(postcss@8.4.38) postcss-js: 4.0.1(postcss@8.4.38) - postcss-load-config: 4.0.1(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2)) + postcss-load-config: 4.0.1(postcss@8.4.38)(ts-node@10.9.2(typescript@5.6.2)) postcss-nested: 6.0.1(postcss@8.4.38) postcss-selector-parser: 6.0.13 resolve: 1.22.6 @@ -21338,7 +21364,7 @@ snapshots: transitivePeerDependencies: - ts-node - tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.1.6)): + tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.3.101)(typescript@5.1.6)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -21357,7 +21383,7 @@ snapshots: postcss: 8.4.38 postcss-import: 15.1.0(postcss@8.4.38) postcss-js: 4.0.1(postcss@8.4.38) - postcss-load-config: 4.0.1(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.1.6)) + postcss-load-config: 4.0.1(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(typescript@5.1.6)) postcss-nested: 6.0.1(postcss@8.4.38) postcss-selector-parser: 6.0.13 resolve: 1.22.6 @@ -21365,7 +21391,7 @@ snapshots: transitivePeerDependencies: - ts-node - tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.4.4)): + tailwindcss@3.4.4(ts-node@10.9.2(@types/node@18.11.9)(typescript@5.4.4)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -21384,7 +21410,7 @@ snapshots: postcss: 8.4.38 postcss-import: 15.1.0(postcss@8.4.38) postcss-js: 4.0.1(postcss@8.4.38) - postcss-load-config: 4.0.1(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.4.4)) + postcss-load-config: 4.0.1(postcss@8.4.38)(ts-node@10.9.2(@types/node@18.11.9)(typescript@5.4.4)) postcss-nested: 6.0.1(postcss@8.4.38) postcss-selector-parser: 6.0.13 resolve: 1.22.6 @@ -21392,7 +21418,7 @@ snapshots: transitivePeerDependencies: - ts-node - tailwindcss@3.4.4(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2)): + tailwindcss@3.4.4(ts-node@10.9.2(typescript@5.6.2)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -21411,7 +21437,7 @@ snapshots: postcss: 8.4.38 postcss-import: 15.1.0(postcss@8.4.38) postcss-js: 4.0.1(postcss@8.4.38) - postcss-load-config: 4.0.1(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2)) + postcss-load-config: 4.0.1(postcss@8.4.38)(ts-node@10.9.2(typescript@5.6.2)) postcss-nested: 6.0.1(postcss@8.4.38) postcss-selector-parser: 6.0.13 resolve: 1.22.6 @@ -21467,7 +21493,7 @@ snapshots: ansi-escapes: 4.3.2 supports-hyperlinks: 2.3.0 - terser-webpack-plugin@5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.12))(esbuild@0.19.11)(webpack@5.90.0(@swc/core@1.3.101(@swc/helpers@0.5.12))(esbuild@0.19.11)): + terser-webpack-plugin@5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.12))(esbuild@0.19.11)(webpack@5.90.0): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 @@ -21560,11 +21586,11 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@27.1.5(@babel/core@7.24.5)(@types/jest@28.1.8)(babel-jest@27.5.1(@babel/core@7.24.5))(jest@27.5.1(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@4.9.5)))(typescript@4.9.5): + ts-jest@27.1.5(@babel/core@7.24.5)(@types/jest@28.1.8)(babel-jest@27.5.1(@babel/core@7.24.5))(jest@27.5.1(ts-node@10.9.2(typescript@4.9.5)))(typescript@4.9.5): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 27.5.1(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@4.9.5)) + jest: 27.5.1(ts-node@10.9.2(typescript@4.9.5)) jest-util: 27.5.1 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -21577,7 +21603,7 @@ snapshots: '@types/jest': 28.1.8 babel-jest: 27.5.1(@babel/core@7.24.5) - ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@4.9.5): + ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.1.6): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -21591,14 +21617,14 @@ snapshots: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 4.9.5 + typescript: 5.1.6 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: '@swc/core': 1.3.101(@swc/helpers@0.5.12) optional: true - ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.1.6): + ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.6.2): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -21612,14 +21638,13 @@ snapshots: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.1.6 + typescript: 5.6.2 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: '@swc/core': 1.3.101(@swc/helpers@0.5.12) - optional: true - ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.4.4): + ts-node@10.9.2(@types/node@18.11.9)(typescript@5.4.4): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -21636,29 +21661,25 @@ snapshots: typescript: 5.4.4 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - optionalDependencies: - '@swc/core': 1.3.101(@swc/helpers@0.5.12) optional: true - ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2): + ts-node@10.9.2(typescript@4.9.5): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 18.11.9 acorn: 8.11.3 acorn-walk: 8.3.2 arg: 4.1.3 create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.6.2 + typescript: 4.9.5 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - optionalDependencies: - '@swc/core': 1.3.101(@swc/helpers@0.5.12) + optional: true tsconfck@3.0.3(typescript@5.4.4): optionalDependencies: @@ -21670,7 +21691,7 @@ snapshots: tslib@2.8.1: {} - tsup@6.1.3(@swc/core@1.3.101(@swc/helpers@0.5.12))(postcss@8.4.31)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.1.6))(typescript@5.1.6): + tsup@6.1.3(@swc/core@1.3.101)(postcss@8.4.31)(ts-node@10.9.2(@swc/core@1.3.101)(typescript@5.1.6))(typescript@5.1.6): dependencies: bundle-require: 3.1.2(esbuild@0.14.54) cac: 6.7.14 @@ -21680,7 +21701,7 @@ snapshots: execa: 5.1.1 globby: 11.1.0 joycon: 3.1.1 - postcss-load-config: 3.1.4(postcss@8.4.31)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.1.6)) + postcss-load-config: 3.1.4(postcss@8.4.31)(ts-node@10.9.2(@swc/core@1.3.101)(typescript@5.1.6)) resolve-from: 5.0.0 rollup: 2.79.1 source-map: 0.8.0-beta.0 @@ -21694,7 +21715,7 @@ snapshots: - supports-color - ts-node - tsup@6.1.3(@swc/core@1.3.101(@swc/helpers@0.5.12))(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.1.6))(typescript@5.1.6): + tsup@6.1.3(@swc/core@1.3.101)(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.1.6))(typescript@5.1.6): dependencies: bundle-require: 3.1.2(esbuild@0.14.54) cac: 6.7.14 @@ -21704,7 +21725,7 @@ snapshots: execa: 5.1.1 globby: 11.1.0 joycon: 3.1.1 - postcss-load-config: 3.1.4(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.1.6)) + postcss-load-config: 3.1.4(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.1.6)) resolve-from: 5.0.0 rollup: 2.79.1 source-map: 0.8.0-beta.0 @@ -21718,7 +21739,7 @@ snapshots: - supports-color - ts-node - tsup@6.7.0(@swc/core@1.3.101(@swc/helpers@0.5.12))(postcss@8.4.31)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2))(typescript@5.6.2): + tsup@6.7.0(@swc/core@1.3.101)(postcss@8.4.31)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.6.2))(typescript@5.6.2): dependencies: bundle-require: 4.2.1(esbuild@0.17.19) cac: 6.7.14 @@ -21728,7 +21749,7 @@ snapshots: execa: 5.1.1 globby: 11.1.0 joycon: 3.1.1 - postcss-load-config: 3.1.4(postcss@8.4.31)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2)) + postcss-load-config: 3.1.4(postcss@8.4.31)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.6.2)) resolve-from: 5.0.0 rollup: 3.29.5 source-map: 0.8.0-beta.0 @@ -21742,7 +21763,31 @@ snapshots: - supports-color - ts-node - tsup@6.7.0(@swc/core@1.3.101(@swc/helpers@0.5.12))(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2))(typescript@5.6.2): + tsup@6.7.0(@swc/core@1.3.101)(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.6.2))(typescript@5.6.2): + dependencies: + bundle-require: 4.2.1(esbuild@0.17.19) + cac: 6.7.14 + chokidar: 3.5.3 + debug: 4.3.4 + esbuild: 0.17.19 + execa: 5.1.1 + globby: 11.1.0 + joycon: 3.1.1 + postcss-load-config: 3.1.4(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(@types/node@18.11.9)(typescript@5.6.2)) + resolve-from: 5.0.0 + rollup: 3.29.5 + source-map: 0.8.0-beta.0 + sucrase: 3.34.0 + tree-kill: 1.2.2 + optionalDependencies: + '@swc/core': 1.3.101(@swc/helpers@0.5.12) + postcss: 8.4.38 + typescript: 5.6.2 + transitivePeerDependencies: + - supports-color + - ts-node + + tsup@6.7.0(@swc/core@1.3.101)(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(typescript@5.6.2))(typescript@5.6.2): dependencies: bundle-require: 4.2.1(esbuild@0.17.19) cac: 6.7.14 @@ -21752,7 +21797,7 @@ snapshots: execa: 5.1.1 globby: 11.1.0 joycon: 3.1.1 - postcss-load-config: 3.1.4(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.6.2)) + postcss-load-config: 3.1.4(postcss@8.4.38)(ts-node@10.9.2(@swc/core@1.3.101)(typescript@5.6.2)) resolve-from: 5.0.0 rollup: 3.29.5 source-map: 0.8.0-beta.0 @@ -21922,7 +21967,7 @@ snapshots: dependencies: is-typedarray: 1.0.0 - typeorm@0.3.21(mongodb@6.14.2(@aws-sdk/credential-providers@3.758.0)(socks@2.8.4))(mssql@11.0.1)(mysql2@3.13.0)(pg@8.13.3)(redis@4.7.0)(reflect-metadata@0.2.2)(sqlite3@5.1.7)(ts-node@10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.4.4)): + typeorm@0.3.21(mongodb@6.14.2(@aws-sdk/credential-providers@3.758.0)(socks@2.8.4))(mssql@11.0.1)(mysql2@3.13.0)(pg@8.13.3)(redis@4.7.0)(reflect-metadata@0.2.2)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@18.11.9)(typescript@5.4.4)): dependencies: '@sqltools/formatter': 1.2.5 ansis: 3.17.0 @@ -21945,7 +21990,7 @@ snapshots: pg: 8.13.3 redis: 4.7.0 sqlite3: 5.1.7 - ts-node: 10.9.2(@swc/core@1.3.101(@swc/helpers@0.5.12))(@types/node@18.11.9)(typescript@5.4.4) + ts-node: 10.9.2(@types/node@18.11.9)(typescript@5.4.4) transitivePeerDependencies: - supports-color @@ -22303,7 +22348,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.12))(esbuild@0.19.11)(webpack@5.90.0(@swc/core@1.3.101(@swc/helpers@0.5.12))(esbuild@0.19.11)) + terser-webpack-plugin: 5.3.10(@swc/core@1.3.101(@swc/helpers@0.5.12))(esbuild@0.19.11)(webpack@5.90.0) watchpack: 2.4.0 webpack-sources: 3.2.3 transitivePeerDependencies: From c5daa02f48c1d433e52c953a9cc0b323ac1db165 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Fri, 29 Aug 2025 15:41:34 -0400 Subject: [PATCH 11/81] WIP messages UI --- .../messages/[partnerId]/page-client.tsx | 69 +++++++++++++- .../[slug]/(ee)/program/messages/layout.tsx | 2 +- apps/web/ui/messages/messages-panel.tsx | 89 ++++++++++++++++++- 3 files changed, 153 insertions(+), 7 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx index a0906021603..3c3d13cc246 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx @@ -14,7 +14,44 @@ import { ChevronLeft, LoadingSpinner } from "@dub/ui/icons"; import { OG_AVATAR_URL, cn } from "@dub/utils"; import { redirect, useParams } from "next/navigation"; import { useState } from "react"; -import { toast } from "sonner"; + +const DEMO_MESSAGES: { + id: string; + text: string; + createdAt: Date; + sender: { + type: "partner" | "user"; + id: string; + name: string; + avatar: string | null; + groupAvatar?: string; + }; +}[] = [ + { + id: "1", + text: "Checking to see if I'm applicable for that new product?", + sender: { + type: "partner", + id: "pn_1", + name: "Tim Wilson", + avatar: "https://dubassets.com/avatars/clro5ctqd0000jv084g63ua08", + }, + createdAt: new Date("2025-08-26T12:00:00Z"), + }, + { + id: "2", + text: "You are for sure eligible. We'll most likely make those changes within the next day or two. Stay tuned.", + sender: { + type: "user", + id: "user_1", + name: "Tim Wilson", + avatar: "https://dubassets.com/avatars/clro5ctqd0000jv084g63ua08", + groupAvatar: + "https://dev.dubassets.com/programs/pg_cm1ze1d510001ekktgfxnj76j/logo_zYzinxG", + }, + createdAt: new Date("2025-08-26T12:00:00Z"), + }, +]; export function ProgramMessagesPartnerPageClient() { const { slug: workspaceSlug } = useWorkspace(); @@ -22,6 +59,9 @@ export function ProgramMessagesPartnerPageClient() { const { partnerId } = useParams() as { partnerId: string }; const { partner, loading, error } = usePartner({ partnerId }); + // TODO: [Messages] fetch+persist real data from/to API + const [messages, setMessages] = useState(DEMO_MESSAGES); + const { setCurrentPanel } = useMessagesContext(); const [isRightPanelOpen, setIsRightPanelOpen] = useState(true); @@ -34,7 +74,7 @@ export function ProgramMessagesPartnerPageClient() { gridTemplateColumns: "minmax(340px, 1fr) minmax(0, min-content)", }} > -
+
-
- toast.info(message)} /> +
+ + setMessages((prev) => [ + ...prev, + { + id: `msg_${prev.length + 1}`, + text: message, + createdAt: new Date(), + sender: { + type: "user", + id: "user_1", + name: "Tim Wilson", + avatar: + "https://dubassets.com/avatars/clro5ctqd0000jv084g63ua08", + }, + }, + ]) + } + />
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx index ed17c1506f1..36d9b796b54 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx @@ -117,7 +117,7 @@ export default function MessagesLayout({ children }: { children: ReactNode }) {
-
+
{children}
diff --git a/apps/web/ui/messages/messages-panel.tsx b/apps/web/ui/messages/messages-panel.tsx index 4463f89315a..d2370818be9 100644 --- a/apps/web/ui/messages/messages-panel.tsx +++ b/apps/web/ui/messages/messages-panel.tsx @@ -1,12 +1,30 @@ import { ArrowTurnLeft, Button } from "@dub/ui"; -import { useRef, useState } from "react"; +import { OG_AVATAR_URL, cn, formatDate } from "@dub/utils"; +import { Fragment, useRef, useState } from "react"; import ReactTextareaAutosize from "react-textarea-autosize"; import { EmojiPicker } from "./emoji-picker"; export function MessagesPanel({ + messages, + currentUserType, + currentUserId, onSendMessage, placeholder = "Type a message...", }: { + messages?: { + id: string; + text: string; + createdAt: Date; + sender: { + type: "partner" | "user"; + id: string; + name: string; + avatar: string | null; + groupAvatar?: string; + }; + }[]; + currentUserType: "partner" | "user"; + currentUserId: string; onSendMessage: (message: string) => void; placeholder?: string; }) { @@ -23,7 +41,74 @@ export function MessagesPanel({ return (
-
messages
+
+
+ {messages?.map((message, idx) => { + const isCurrentUser = + message.sender.type === currentUserType && + message.sender.id === currentUserId; + + // Message is new if it was sent within the last 5 seconds (used for intro animations) + const isNew = + message.createdAt.getTime() > new Date().getTime() - 5_000; + + return ( + + {(idx === 0 || + messages[idx - 1].createdAt.toDateString() !== + message.createdAt.toDateString()) && ( +
+ {formatDate(message.createdAt)} +
+ )} +
+ {/* Avatar */} + {`${message.sender.name} + +
+ {/* Message box */} +
+ {message.text} +
+
+
+
+ ); + })} +
+
Date: Fri, 29 Aug 2025 16:35:01 -0400 Subject: [PATCH 12/81] WIP messages UI --- .../messages/[partnerId]/page-client.tsx | 10 +-- .../[slug]/(ee)/program/messages/layout.tsx | 4 +- apps/web/ui/messages/messages-panel.tsx | 71 +++++++++++++++---- apps/web/ui/partners/partner-info-stats.tsx | 2 +- 4 files changed, 65 insertions(+), 22 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx index 3c3d13cc246..c7ded1a22c5 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx @@ -75,7 +75,7 @@ export function ProgramMessagesPartnerPageClient() { }} >
-
+
-
+
{partner ? ( <> diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx index 36d9b796b54..0cefb95553a 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx @@ -66,7 +66,7 @@ export default function MessagesLayout({ children }: { children: ReactNode }) { > {/* Left panel - 800px/messages list */}
-
+

@@ -80,7 +80,7 @@ export default function MessagesLayout({ children }: { children: ReactNode }) { onClick={() => toast.info("WIP")} />

-
+
{partnersWithMessages?.length || isLoading ? ( ({ diff --git a/apps/web/ui/messages/messages-panel.tsx b/apps/web/ui/messages/messages-panel.tsx index d2370818be9..e5054b9aa3b 100644 --- a/apps/web/ui/messages/messages-panel.tsx +++ b/apps/web/ui/messages/messages-panel.tsx @@ -1,4 +1,4 @@ -import { ArrowTurnLeft, Button } from "@dub/ui"; +import { ArrowTurnLeft, Button, Tooltip } from "@dub/ui"; import { OG_AVATAR_URL, cn, formatDate } from "@dub/utils"; import { Fragment, useRef, useState } from "react"; import ReactTextareaAutosize from "react-textarea-autosize"; @@ -42,8 +42,20 @@ export function MessagesPanel({ return (
-
+
{messages?.map((message, idx) => { + const isNewDate = + idx === 0 || + messages[idx - 1].createdAt.toDateString() !== + message.createdAt.toDateString(); + + // If it's been more than 5 minutes since the last message + const isNewTime = + isNewDate || + message.createdAt.getTime() - + messages[idx - 1].createdAt.getTime() > + 5 * 1000 * 60; + const isCurrentUser = message.sender.type === currentUserType && message.sender.id === currentUserId; @@ -54,9 +66,7 @@ export function MessagesPanel({ return ( - {(idx === 0 || - messages[idx - 1].createdAt.toDateString() !== - message.createdAt.toDateString()) && ( + {isNewDate && (
{/* Avatar */} - {`${message.sender.name} + +
+ {`${message.sender.name} + {message.sender.groupAvatar && ( + + )} +
+
+ {/* Name / timestamp */} + {(!isCurrentUser || isNewTime) && ( +
+ {!isCurrentUser && ( + + {message.sender.name} + + )} + {isNewTime && ( + + {new Date(message.createdAt).toLocaleTimeString( + "en-US", + { + hour: "numeric", + minute: "numeric", + }, + )} + + )} +
+ )} {/* Message box */}
From c6edd29a217be743429b409731452dda02b37f58 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Fri, 29 Aug 2025 16:49:13 -0400 Subject: [PATCH 13/81] Add loading/error states --- .../messages/[partnerId]/page-client.tsx | 1 + apps/web/ui/messages/messages-panel.tsx | 197 ++++++++++-------- 2 files changed, 106 insertions(+), 92 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx index c7ded1a22c5..83f23d6bff8 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx @@ -112,6 +112,7 @@ export function ProgramMessagesPartnerPageClient() {
diff --git a/apps/web/ui/messages/messages-panel.tsx b/apps/web/ui/messages/messages-panel.tsx index e5054b9aa3b..4d6e8f73b79 100644 --- a/apps/web/ui/messages/messages-panel.tsx +++ b/apps/web/ui/messages/messages-panel.tsx @@ -1,4 +1,4 @@ -import { ArrowTurnLeft, Button, Tooltip } from "@dub/ui"; +import { ArrowTurnLeft, Button, LoadingSpinner, Tooltip } from "@dub/ui"; import { OG_AVATAR_URL, cn, formatDate } from "@dub/utils"; import { Fragment, useRef, useState } from "react"; import ReactTextareaAutosize from "react-textarea-autosize"; @@ -10,6 +10,7 @@ export function MessagesPanel({ currentUserId, onSendMessage, placeholder = "Type a message...", + error, }: { messages?: { id: string; @@ -27,6 +28,7 @@ export function MessagesPanel({ currentUserId: string; onSendMessage: (message: string) => void; placeholder?: string; + error?: any; }) { const textAreaRef = useRef(null); const selectionStartRef = useRef(null); @@ -41,123 +43,134 @@ export function MessagesPanel({ return (
-
-
- {messages?.map((message, idx) => { - const isNewDate = - idx === 0 || - messages[idx - 1].createdAt.toDateString() !== - message.createdAt.toDateString(); + {messages ? ( +
+
+ {messages?.map((message, idx) => { + const isNewDate = + idx === 0 || + messages[idx - 1].createdAt.toDateString() !== + message.createdAt.toDateString(); - // If it's been more than 5 minutes since the last message - const isNewTime = - isNewDate || - message.createdAt.getTime() - - messages[idx - 1].createdAt.getTime() > - 5 * 1000 * 60; + // If it's been more than 5 minutes since the last message + const isNewTime = + isNewDate || + message.createdAt.getTime() - + messages[idx - 1].createdAt.getTime() > + 5 * 1000 * 60; - const isCurrentUser = - message.sender.type === currentUserType && - message.sender.id === currentUserId; + const isCurrentUser = + message.sender.type === currentUserType && + message.sender.id === currentUserId; - // Message is new if it was sent within the last 5 seconds (used for intro animations) - const isNew = - message.createdAt.getTime() > new Date().getTime() - 5_000; + // Message is new if it was sent within the last 5 seconds (used for intro animations) + const isNew = + message.createdAt.getTime() > new Date().getTime() - 5_000; - return ( - - {isNewDate && ( + return ( + + {isNewDate && ( +
+ {formatDate(message.createdAt)} +
+ )}
- {formatDate(message.createdAt)} -
- )} -
- {/* Avatar */} - -
- {`${message.sender.name} - {message.sender.groupAvatar && ( + {/* Avatar */} + +
- )} -
-
- -
- {/* Name / timestamp */} - {(!isCurrentUser || isNewTime) && ( -
- {!isCurrentUser && ( - - {message.sender.name} - - )} - {isNewTime && ( - - {new Date(message.createdAt).toLocaleTimeString( - "en-US", - { - hour: "numeric", - minute: "numeric", - }, - )} - + {message.sender.groupAvatar && ( + )}
- )} - {/* Message box */} + +
- {message.text} + {/* Name / timestamp */} + {(!isCurrentUser || isNewTime) && ( +
+ {!isCurrentUser && ( + + {message.sender.name} + + )} + {isNewTime && ( + + {new Date(message.createdAt).toLocaleTimeString( + "en-US", + { + hour: "numeric", + minute: "numeric", + }, + )} + + )} +
+ )} + {/* Message box */} +
+ {message.text} +
-
- - ); - })} + + ); + })} +
-
+ ) : error ? ( +
+ Failed to load messages +
+ ) : ( +
+ +
+ )}
setTypedMessage(e.currentTarget.value)} onKeyDown={(e) => { From 2ec5eb520130502e9e6641c88f940100d0c08da2 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Mon, 1 Sep 2025 22:31:33 -0700 Subject: [PATCH 14/81] Update partner-info-stats.tsx --- apps/web/ui/partners/partner-info-stats.tsx | 33 ++++++++++----------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/apps/web/ui/partners/partner-info-stats.tsx b/apps/web/ui/partners/partner-info-stats.tsx index 69765d731bf..1083ec7faf2 100644 --- a/apps/web/ui/partners/partner-info-stats.tsx +++ b/apps/web/ui/partners/partner-info-stats.tsx @@ -18,42 +18,41 @@ export function PartnerInfoStats({ {[ [ "Clicks", - !partner.clicks ? "-" : nFormatter(partner.clicks, { full: true }), + Number.isNaN(partner.clicks) + ? "-" + : nFormatter(partner.clicks, { full: true }), ], [ "Leads", - !partner.leads ? "-" : nFormatter(partner.leads, { full: true }), + Number.isNaN(partner.leads) + ? "-" + : nFormatter(partner.leads, { full: true }), ], [ - "Sales", - !partner.sales ? "-" : nFormatter(partner.sales, { full: true }), + "Conversions", + Number.isNaN(partner.conversions) + ? "-" + : nFormatter(partner.conversions, { full: true }), ], [ "Revenue", - !partner.saleAmount + Number.isNaN(partner.saleAmount) ? "-" : currencyFormatter(partner.saleAmount / 100, { - minimumFractionDigits: partner.saleAmount % 1 === 0 ? 0 : 2, - maximumFractionDigits: 2, + trailingZeroDisplay: "stripIfInteger", }), ], [ "Commissions", - !partner.totalCommissions + Number.isNaN(partner.totalCommissions) ? "-" - : currencyFormatter(partner.totalCommissions / 100, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }), + : currencyFormatter(partner.totalCommissions / 100), ], [ "Net revenue", - !partner.netRevenue + Number.isNaN(partner.netRevenue) ? "-" - : currencyFormatter(partner.netRevenue / 100, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }), + : currencyFormatter(partner.netRevenue / 100), ], ].map(([label, value]) => (
From c0c86705d66c718c21323ca30a3785f9a2210fde Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Tue, 2 Sep 2025 10:21:55 -0400 Subject: [PATCH 15/81] Read statuses --- .../messages/[partnerId]/page-client.tsx | 19 +--- apps/web/ui/messages/messages-panel.tsx | 91 +++++++++++++++---- 2 files changed, 76 insertions(+), 34 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx index 83f23d6bff8..285491626ae 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx @@ -3,7 +3,7 @@ import usePartner from "@/lib/swr/use-partner"; import useWorkspace from "@/lib/swr/use-workspace"; import { useMessagesContext } from "@/ui/messages/messages-context"; -import { MessagesPanel } from "@/ui/messages/messages-panel"; +import { Message, MessagesPanel } from "@/ui/messages/messages-panel"; import { ToggleSidePanelButton } from "@/ui/messages/toggle-side-panel-button"; import { PartnerInfoGroup } from "@/ui/partners/partner-info-group"; import { PartnerInfoSection } from "@/ui/partners/partner-info-section"; @@ -15,18 +15,7 @@ import { OG_AVATAR_URL, cn } from "@dub/utils"; import { redirect, useParams } from "next/navigation"; import { useState } from "react"; -const DEMO_MESSAGES: { - id: string; - text: string; - createdAt: Date; - sender: { - type: "partner" | "user"; - id: string; - name: string; - avatar: string | null; - groupAvatar?: string; - }; -}[] = [ +const DEMO_MESSAGES: Message[] = [ { id: "1", text: "Checking to see if I'm applicable for that new product?", @@ -49,7 +38,8 @@ const DEMO_MESSAGES: { groupAvatar: "https://dev.dubassets.com/programs/pg_cm1ze1d510001ekktgfxnj76j/logo_zYzinxG", }, - createdAt: new Date("2025-08-26T12:00:00Z"), + createdAt: new Date("2025-08-26T13:05:00Z"), + readInEmail: new Date(), }, ]; @@ -122,6 +112,7 @@ export function ProgramMessagesPartnerPageClient() { id: `msg_${prev.length + 1}`, text: message, createdAt: new Date(), + delivered: false, sender: { type: "user", id: "user_1", diff --git a/apps/web/ui/messages/messages-panel.tsx b/apps/web/ui/messages/messages-panel.tsx index 4d6e8f73b79..f7dab2e7b9e 100644 --- a/apps/web/ui/messages/messages-panel.tsx +++ b/apps/web/ui/messages/messages-panel.tsx @@ -1,9 +1,31 @@ -import { ArrowTurnLeft, Button, LoadingSpinner, Tooltip } from "@dub/ui"; +import { + ArrowTurnLeft, + Button, + Check2, + LoadingSpinner, + Tooltip, +} from "@dub/ui"; import { OG_AVATAR_URL, cn, formatDate } from "@dub/utils"; import { Fragment, useRef, useState } from "react"; import ReactTextareaAutosize from "react-textarea-autosize"; import { EmojiPicker } from "./emoji-picker"; +export type Message = { + id: string; + text: string; + createdAt: Date; + readInEmail?: Date | null; + readInApp?: Date | null; + delivered?: boolean; + sender: { + type: "partner" | "user"; + id: string; + name: string; + avatar: string | null; + groupAvatar?: string; + }; +}; + export function MessagesPanel({ messages, currentUserType, @@ -12,18 +34,7 @@ export function MessagesPanel({ placeholder = "Type a message...", error, }: { - messages?: { - id: string; - text: string; - createdAt: Date; - sender: { - type: "partner" | "user"; - id: string; - name: string; - avatar: string | null; - groupAvatar?: string; - }; - }[]; + messages?: Message[]; currentUserType: "partner" | "user"; currentUserId: string; onSendMessage: (message: string) => void; @@ -35,12 +46,16 @@ export function MessagesPanel({ const [typedMessage, setTypedMessage] = useState(""); const sendMessage = () => { - if (!typedMessage) return; + if (!typedMessage.trim()) return; - onSendMessage(typedMessage); + onSendMessage(typedMessage.trim()); setTypedMessage(""); }; + const isMessageFromCurrentUser = (message: Message) => + message.sender.type === currentUserType && + message.sender.id === currentUserId; + return (
{messages ? ( @@ -59,14 +74,20 @@ export function MessagesPanel({ messages[idx - 1].createdAt.getTime() > 5 * 1000 * 60; - const isCurrentUser = - message.sender.type === currentUserType && - message.sender.id === currentUserId; + const isCurrentUser = isMessageFromCurrentUser(message); // Message is new if it was sent within the last 5 seconds (used for intro animations) const isNew = message.createdAt.getTime() > new Date().getTime() - 5_000; + const showReadIndicator = + isCurrentUser && + message.delivered !== false && + (idx === messages.length - 1 || + messages + .slice(idx + 1) + .findIndex(isMessageFromCurrentUser) === -1); + return ( {isNewDate && ( @@ -117,7 +138,7 @@ export function MessagesPanel({ )} > {/* Name / timestamp */} - {(!isCurrentUser || isNewTime) && ( + {(!isCurrentUser || isNewTime || showReadIndicator) && (
{!isCurrentUser && ( @@ -135,12 +156,15 @@ export function MessagesPanel({ )} )} + {showReadIndicator && ( + + )}
)} {/* Message box */}
); } + +function ReadIndicator({ message }: { message: Message }) { + return ( + +
+ + {(message.readInEmail || message.readInApp) && ( + + )} +
+
+ ); +} From 8ccffe66c0dea2b9247005da8d12c0324a6cf0d7 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Tue, 2 Sep 2025 16:59:57 -0400 Subject: [PATCH 16/81] Hook up messages backend --- apps/web/app/(ee)/api/messages/route.ts | 81 +++++++++++++++++++ .../messages/[partnerId]/page-client.tsx | 80 +++++++----------- .../[slug]/(ee)/program/messages/layout.tsx | 55 +++---------- apps/web/lib/api/rbac/permissions.ts | 12 +++ apps/web/lib/swr/use-partner-messages.ts | 41 ++++++++++ apps/web/lib/types.ts | 3 + apps/web/lib/zod/schemas/messages.ts | 50 ++++++++++++ apps/web/ui/messages/messages-panel.tsx | 59 ++++++-------- packages/prisma/schema/message.prisma | 27 +++++++ packages/prisma/schema/partner.prisma | 2 + packages/prisma/schema/program.prisma | 1 + packages/prisma/schema/schema.prisma | 1 + 12 files changed, 284 insertions(+), 128 deletions(-) create mode 100644 apps/web/app/(ee)/api/messages/route.ts create mode 100644 apps/web/lib/swr/use-partner-messages.ts create mode 100644 apps/web/lib/zod/schemas/messages.ts create mode 100644 packages/prisma/schema/message.prisma diff --git a/apps/web/app/(ee)/api/messages/route.ts b/apps/web/app/(ee)/api/messages/route.ts new file mode 100644 index 00000000000..c7e1cb43c6a --- /dev/null +++ b/apps/web/app/(ee)/api/messages/route.ts @@ -0,0 +1,81 @@ +import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { withWorkspace } from "@/lib/auth"; +import { + PartnerMessagesSchema, + getPartnerMessagesQuerySchema, +} from "@/lib/zod/schemas/messages"; +import { prisma } from "@dub/prisma"; +import { NextResponse } from "next/server"; + +// GET /api/messages - get messages grouped by partner +export const GET = withWorkspace( + async ({ workspace, searchParams }) => { + const programId = getDefaultProgramIdOrThrow(workspace); + + const { partnerId, messagesLimit: messagesLimitArg } = + getPartnerMessagesQuerySchema.parse(searchParams); + + const messagesLimit = messagesLimitArg ?? (partnerId ? undefined : 10); + + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + programId, + ...(partnerId && { partnerId }), + partner: { + messages: { + some: { + programId, + }, + }, + }, + }, + include: { + partner: { + include: { + messages: { + where: { + programId, + }, + include: { + senderPartner: true, + senderUser: true, + }, + orderBy: { + createdAt: "desc", + }, + take: messagesLimit, + }, + }, + }, + }, + }); + + return NextResponse.json( + PartnerMessagesSchema.parse( + programEnrollments + // Sort by most recent message + .sort( + (a, b) => + b.partner.messages[0].createdAt.getTime() - + a.partner.messages[0].createdAt.getTime(), + ) + // Map to {partner, messages} + .map(({ partner }) => ({ + partner, + messages: partner.messages, + })), + ), + ); + }, + { + requiredPermissions: ["messages.read"], + requiredPlan: [ + "business", + "business extra", + "business max", + "business plus", + "advanced", + "enterprise", + ], + }, +); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx index 285491626ae..d0af5d25a18 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx @@ -1,9 +1,10 @@ "use client"; import usePartner from "@/lib/swr/use-partner"; +import { usePartnerMessages } from "@/lib/swr/use-partner-messages"; import useWorkspace from "@/lib/swr/use-workspace"; import { useMessagesContext } from "@/ui/messages/messages-context"; -import { Message, MessagesPanel } from "@/ui/messages/messages-panel"; +import { MessagesPanel } from "@/ui/messages/messages-panel"; import { ToggleSidePanelButton } from "@/ui/messages/toggle-side-panel-button"; import { PartnerInfoGroup } from "@/ui/partners/partner-info-group"; import { PartnerInfoSection } from "@/ui/partners/partner-info-section"; @@ -14,48 +15,22 @@ import { ChevronLeft, LoadingSpinner } from "@dub/ui/icons"; import { OG_AVATAR_URL, cn } from "@dub/utils"; import { redirect, useParams } from "next/navigation"; import { useState } from "react"; - -const DEMO_MESSAGES: Message[] = [ - { - id: "1", - text: "Checking to see if I'm applicable for that new product?", - sender: { - type: "partner", - id: "pn_1", - name: "Tim Wilson", - avatar: "https://dubassets.com/avatars/clro5ctqd0000jv084g63ua08", - }, - createdAt: new Date("2025-08-26T12:00:00Z"), - }, - { - id: "2", - text: "You are for sure eligible. We'll most likely make those changes within the next day or two. Stay tuned.", - sender: { - type: "user", - id: "user_1", - name: "Tim Wilson", - avatar: "https://dubassets.com/avatars/clro5ctqd0000jv084g63ua08", - groupAvatar: - "https://dev.dubassets.com/programs/pg_cm1ze1d510001ekktgfxnj76j/logo_zYzinxG", - }, - createdAt: new Date("2025-08-26T13:05:00Z"), - readInEmail: new Date(), - }, -]; +import { toast } from "sonner"; export function ProgramMessagesPartnerPageClient() { const { slug: workspaceSlug } = useWorkspace(); const { partnerId } = useParams() as { partnerId: string }; - const { partner, loading, error } = usePartner({ partnerId }); - - // TODO: [Messages] fetch+persist real data from/to API - const [messages, setMessages] = useState(DEMO_MESSAGES); + const { partner, error: errorPartner } = usePartner({ partnerId }); + const { partnerMessages, error: errorMessages } = usePartnerMessages({ + query: { partnerId }, + }); + const messages = partnerMessages?.[0]?.messages; const { setCurrentPanel } = useMessagesContext(); const [isRightPanelOpen, setIsRightPanelOpen] = useState(true); - if (error) redirect(`/${workspaceSlug}/program/messages`); + if (errorPartner) redirect(`/${workspaceSlug}/program/messages`); return (
- {loading ? ( + {!partner ? ( <>
@@ -102,26 +77,27 @@ export function ProgramMessagesPartnerPageClient() {
- setMessages((prev) => [ - ...prev, - { - id: `msg_${prev.length + 1}`, - text: message, - createdAt: new Date(), - delivered: false, - sender: { - type: "user", - id: "user_1", - name: "Tim Wilson", - avatar: - "https://dubassets.com/avatars/clro5ctqd0000jv084g63ua08", - }, - }, - ]) + // setMessages((prev) => [ + // ...prev, + // { + // id: `msg_${prev.length + 1}`, + // text: message, + // createdAt: new Date(), + // delivered: false, + // sender: { + // type: "user", + // id: "user_1", + // name: "Tim Wilson", + // avatar: + // "https://dubassets.com/avatars/clro5ctqd0000jv084g63ua08", + // }, + // }, + // ]) + toast.info("WIP") } />
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx index 0cefb95553a..e8b8e191586 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx @@ -1,55 +1,23 @@ "use client"; +import { usePartnerMessages } from "@/lib/swr/use-partner-messages"; import useWorkspace from "@/lib/swr/use-workspace"; import { NavButton } from "@/ui/layout/page-content/nav-button"; import { MessagesContext, MessagesPanel } from "@/ui/messages/messages-context"; import { MessagesList } from "@/ui/messages/messages-list"; import { Button } from "@dub/ui"; import { Msgs, Pen2 } from "@dub/ui/icons"; -import { subMinutes } from "date-fns"; import { useParams } from "next/navigation"; import { CSSProperties, ReactNode, useState } from "react"; import { toast } from "sonner"; export default function MessagesLayout({ children }: { children: ReactNode }) { - const { slug: workspaceSlug } = useWorkspace(); + const { id: workspaceId, slug: workspaceSlug } = useWorkspace(); const { partnerId } = useParams() as { partnerId?: string }; - // TODO: [Messages] fetch real data - const partnersWithMessages = [ - { - id: "pn_1KcRT7do2foT1PZ9zZhLF0Cq", - name: "Tim Wilson", - image: "https://dubassets.com/avatars/clro5ctqd0000jv084g63ua08", - messages: [ - { - text: "Hello, how are you?", - createdAt: subMinutes(new Date(), 5), - readStatus: "read-app", - }, - { - text: "Great, thanks! What about you?", - createdAt: subMinutes(new Date(), 5), - readStatus: "read-app", - }, - ], - }, - { - id: "pn_1JZ8GFVXAMTXEYF33QKWZAZ0Y", - name: "Tim Partner11", - image: - "https://dev.dubassets.com/partners/pn_1JZ8GFVXAMTXEYF33QKWZAZ0Y/image_nMMv6kL", - messages: [ - { - text: "Thanks for approving my application!", - createdAt: subMinutes(new Date(), 5), - readStatus: "read-app", - }, - ], - }, - ]; - const isLoading = false; - const error = null; + const { partnerMessages, isLoading, error } = usePartnerMessages({ + query: { messagesLimit: 1 }, + }); const [currentPanel, setCurrentPanel] = useState("index"); @@ -81,12 +49,15 @@ export default function MessagesLayout({ children }: { children: ReactNode }) { />
- {partnersWithMessages?.length || isLoading ? ( + {partnerMessages?.length || isLoading ? ( ({ - ...p, - href: `/${workspaceSlug}/program/messages/${p.id}`, - }))} + groupedMessages={partnerMessages?.map( + ({ partner, messages }) => ({ + ...partner, + messages, + href: `/${workspaceSlug}/program/messages/${partner.id}`, + }), + )} activeId={partnerId} /> ) : error ? ( diff --git a/apps/web/lib/api/rbac/permissions.ts b/apps/web/lib/api/rbac/permissions.ts index f1df520472b..5d12133749c 100644 --- a/apps/web/lib/api/rbac/permissions.ts +++ b/apps/web/lib/api/rbac/permissions.ts @@ -23,6 +23,8 @@ export const PERMISSION_ACTIONS = [ "payouts.write", "groups.write", "groups.read", + "messages.read", + "messages.write", ] as const; export type PermissionAction = (typeof PERMISSION_ACTIONS)[number]; @@ -142,6 +144,16 @@ export const ROLE_PERMISSIONS: { description: "access groups", roles: ["owner", "member"], }, + { + action: "messages.write", + description: "create, update, or delete messages", + roles: ["owner", "member"], + }, + { + action: "messages.read", + description: "access messages", + roles: ["owner", "member"], + }, ]; // Get permissions for a role diff --git a/apps/web/lib/swr/use-partner-messages.ts b/apps/web/lib/swr/use-partner-messages.ts new file mode 100644 index 00000000000..e2679e00bae --- /dev/null +++ b/apps/web/lib/swr/use-partner-messages.ts @@ -0,0 +1,41 @@ +import { fetcher } from "@dub/utils"; +import useSWR from "swr"; +import { z } from "zod"; +import { + PartnerMessagesSchema, + getPartnerMessagesQuerySchema, +} from "../zod/schemas/messages"; +import useWorkspace from "./use-workspace"; + +const partialQuerySchema = getPartnerMessagesQuerySchema.partial(); + +export function usePartnerMessages({ + query, + enabled = true, +}: { + query?: z.infer; + enabled?: boolean; +} = {}) { + const { id: workspaceId } = useWorkspace(); + + const { data, isLoading, error } = useSWR< + z.infer + >( + enabled && workspaceId + ? `/api/messages?${new URLSearchParams({ + workspaceId, + ...(query as Record), + }).toString()}` + : null, + fetcher, + { + keepPreviousData: true, + }, + ); + + return { + partnerMessages: data, + isLoading, + error, + }; +} diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index a06811d0d17..57e75427ff1 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -57,6 +57,7 @@ import { ABTestVariantsSchema, createLinkBodySchema, } from "./zod/schemas/links"; +import { MessageSchema } from "./zod/schemas/messages"; import { createOAuthAppSchema, oAuthAppSchema } from "./zod/schemas/oauth"; import { createPartnerSchema, @@ -572,3 +573,5 @@ export type BountySubmissionsQueryFilters = z.infer< // earnings: number; // } // >; + +export type Message = z.infer; diff --git a/apps/web/lib/zod/schemas/messages.ts b/apps/web/lib/zod/schemas/messages.ts new file mode 100644 index 00000000000..daf53290ef4 --- /dev/null +++ b/apps/web/lib/zod/schemas/messages.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; +import { PartnerSchema } from "./partners"; +import { UserSchema } from "./users"; + +export const MessageSchema = z + .object({ + id: z.string(), + programId: z.string(), + partnerId: z.string(), + senderPartnerId: z.string().nullable(), + senderUserId: z.string().nullable(), + + text: z.string(), + + emailId: z.string().nullable(), + readInApp: z.date().nullable(), + readInEmail: z.date().nullable(), + createdAt: z.date(), + updatedAt: z.date(), + + senderPartner: PartnerSchema.pick({ + id: true, + name: true, + image: true, + }).nullable(), + senderUser: UserSchema.pick({ + id: true, + name: true, + image: true, + }).nullable(), + }) + .refine((data) => data.senderPartnerId || data.senderUserId, { + message: "Either senderPartnerId or senderUserId must be present", + }); + +export const PartnerMessagesSchema = z.array( + z.object({ + partner: PartnerSchema.pick({ + id: true, + name: true, + image: true, + }), + messages: z.array(MessageSchema), + }), +); + +export const getPartnerMessagesQuerySchema = z.object({ + partnerId: z.string().optional(), + messagesLimit: z.coerce.number().min(0).optional(), +}); diff --git a/apps/web/ui/messages/messages-panel.tsx b/apps/web/ui/messages/messages-panel.tsx index f7dab2e7b9e..5e1aa76b055 100644 --- a/apps/web/ui/messages/messages-panel.tsx +++ b/apps/web/ui/messages/messages-panel.tsx @@ -1,3 +1,4 @@ +import { Message } from "@/lib/types"; import { ArrowTurnLeft, Button, @@ -10,22 +11,6 @@ import { Fragment, useRef, useState } from "react"; import ReactTextareaAutosize from "react-textarea-autosize"; import { EmojiPicker } from "./emoji-picker"; -export type Message = { - id: string; - text: string; - createdAt: Date; - readInEmail?: Date | null; - readInApp?: Date | null; - delivered?: boolean; - sender: { - type: "partner" | "user"; - id: string; - name: string; - avatar: string | null; - groupAvatar?: string; - }; -}; - export function MessagesPanel({ messages, currentUserType, @@ -34,7 +19,7 @@ export function MessagesPanel({ placeholder = "Type a message...", error, }: { - messages?: Message[]; + messages?: (Message & { delivered?: boolean })[]; currentUserType: "partner" | "user"; currentUserId: string; onSendMessage: (message: string) => void; @@ -53,8 +38,11 @@ export function MessagesPanel({ }; const isMessageFromCurrentUser = (message: Message) => - message.sender.type === currentUserType && - message.sender.id === currentUserId; + Boolean( + currentUserType === "partner" + ? message.senderPartner + : message.senderUserId === currentUserId, + ); return (
@@ -64,21 +52,22 @@ export function MessagesPanel({ {messages?.map((message, idx) => { const isNewDate = idx === 0 || - messages[idx - 1].createdAt.toDateString() !== - message.createdAt.toDateString(); + new Date(messages[idx - 1].createdAt).toDateString() !== + new Date(message.createdAt).toDateString(); // If it's been more than 5 minutes since the last message const isNewTime = isNewDate || - message.createdAt.getTime() - - messages[idx - 1].createdAt.getTime() > + new Date(message.createdAt).getTime() - + new Date(messages[idx - 1].createdAt).getTime() > 5 * 1000 * 60; const isCurrentUser = isMessageFromCurrentUser(message); // Message is new if it was sent within the last 5 seconds (used for intro animations) const isNew = - message.createdAt.getTime() > new Date().getTime() - 5_000; + new Date(message.createdAt).getTime() > + new Date().getTime() - 5_000; const showReadIndicator = isCurrentUser && @@ -88,6 +77,11 @@ export function MessagesPanel({ .slice(idx + 1) .findIndex(isMessageFromCurrentUser) === -1); + const sender = + currentUserType === "partner" + ? message.senderPartner + : message.senderUser; + return ( {isNewDate && ( @@ -110,24 +104,21 @@ export function MessagesPanel({ )} > {/* Avatar */} - +
{`${message.sender.name} - {message.sender.groupAvatar && ( + {/* {sender?.groupAvatar && ( - )} + )} */}
@@ -142,7 +133,7 @@ export function MessagesPanel({
{!isCurrentUser && ( - {message.sender.name} + {sender?.name} )} {isNewTime && ( diff --git a/packages/prisma/schema/message.prisma b/packages/prisma/schema/message.prisma new file mode 100644 index 00000000000..bf853a12939 --- /dev/null +++ b/packages/prisma/schema/message.prisma @@ -0,0 +1,27 @@ + +model Message { + id String @id @default(cuid()) + programId String + partnerId String + + senderPartnerId String? // Populated if the sender is a partner + senderUserId String? // Populated if the sender is a program owner/user + + text String + + emailId String? + readInApp DateTime? + readInEmail DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + program Program @relation(fields: [programId], references: [id], onDelete: Cascade) + partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade) + senderPartner Partner? @relation("SenderPartner", fields: [senderPartnerId], references: [id], onDelete: Cascade) + senderUser User? @relation(fields: [senderUserId], references: [id], onDelete: Cascade) + + @@index(programId) + @@index(partnerId) + @@index(senderPartnerId) + @@index(senderUserId) +} \ No newline at end of file diff --git a/packages/prisma/schema/partner.prisma b/packages/prisma/schema/partner.prisma index 3c797e7b40f..09298c3eb41 100644 --- a/packages/prisma/schema/partner.prisma +++ b/packages/prisma/schema/partner.prisma @@ -56,6 +56,8 @@ model Partner { payouts Payout[] commissions Commission[] bountySubmissions BountySubmission[] + messages Message[] + sentMessages Message[] @relation("SenderPartner") } model PartnerInvite { diff --git a/packages/prisma/schema/program.prisma b/packages/prisma/schema/program.prisma index c58e42b45d6..3252f40b815 100644 --- a/packages/prisma/schema/program.prisma +++ b/packages/prisma/schema/program.prisma @@ -73,6 +73,7 @@ model Program { bounties Bounty[] bountySubmissions BountySubmission[] workflows Workflow[] + messages Message[] @@index(workspaceId) @@index(domain) diff --git a/packages/prisma/schema/schema.prisma b/packages/prisma/schema/schema.prisma index 305770b62d3..dd0dc30bf3f 100644 --- a/packages/prisma/schema/schema.prisma +++ b/packages/prisma/schema/schema.prisma @@ -44,6 +44,7 @@ model User { utmTemplates UtmTemplate[] payouts Payout[] bountySubmissions BountySubmission[] + sentMessages Message[] @@index(source) @@index(defaultWorkspace) From 1db5bbc23d0108ff2f99806b26f22154b45a88c2 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Tue, 2 Sep 2025 17:10:25 -0400 Subject: [PATCH 17/81] More actions --- .../lib/actions/partners/message-partner.ts | 31 +++++++++++++++++++ .../partners/update-partner-message.ts | 31 +++++++++++++++++++ apps/web/lib/api/create-id.ts | 1 + apps/web/lib/zod/schemas/messages.ts | 11 +++++++ 4 files changed, 74 insertions(+) create mode 100644 apps/web/lib/actions/partners/message-partner.ts create mode 100644 apps/web/lib/actions/partners/update-partner-message.ts diff --git a/apps/web/lib/actions/partners/message-partner.ts b/apps/web/lib/actions/partners/message-partner.ts new file mode 100644 index 00000000000..3848421d614 --- /dev/null +++ b/apps/web/lib/actions/partners/message-partner.ts @@ -0,0 +1,31 @@ +"use server"; + +import { createId } from "@/lib/api/create-id"; +import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; +import { prisma } from "@dub/prisma"; +import { messagePartnerSchema } from "../../zod/schemas/messages"; +import { authActionClient } from "../safe-action"; + +// Message a partner +export const messagePartnerAction = authActionClient + .schema(messagePartnerSchema) + .action(async ({ parsedInput, ctx }) => { + const { workspace, user } = ctx; + const { partnerId, text } = parsedInput; + + const programId = getDefaultProgramIdOrThrow(workspace); + await getProgramEnrollmentOrThrow({ programId, partnerId }); + + await prisma.message.create({ + data: { + id: createId({ prefix: "msg_" }), + programId, + partnerId, + senderUserId: user.id, + text, + }, + }); + + // TODO: [Messages] Send email to partner and track read status + }); diff --git a/apps/web/lib/actions/partners/update-partner-message.ts b/apps/web/lib/actions/partners/update-partner-message.ts new file mode 100644 index 00000000000..a770b756ba0 --- /dev/null +++ b/apps/web/lib/actions/partners/update-partner-message.ts @@ -0,0 +1,31 @@ +"use server"; + +import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { prisma } from "@dub/prisma"; +import { updatePartnerMessageSchema } from "../../zod/schemas/messages"; +import { authActionClient } from "../safe-action"; + +// Update a partner message +export const updatePartnerMessageAction = authActionClient + .schema(updatePartnerMessageSchema) + .action(async ({ parsedInput, ctx }) => { + const { workspace } = ctx; + const { messageId, readInApp, readInEmail } = parsedInput; + + const programId = getDefaultProgramIdOrThrow(workspace); + + await prisma.message.update({ + where: { + id: messageId, + programId, + }, + data: { + ...(readInApp !== undefined && { + readInApp: readInApp ? new Date() : null, + }), + ...(readInEmail !== undefined && { + readInEmail: readInEmail ? new Date() : null, + }), + }, + }); + }); diff --git a/apps/web/lib/api/create-id.ts b/apps/web/lib/api/create-id.ts index 9fb82892dc8..0dc728125c7 100644 --- a/apps/web/lib/api/create-id.ts +++ b/apps/web/lib/api/create-id.ts @@ -32,6 +32,7 @@ const prefixes = [ "bnty_", "bnty_sub_", "wf_", + "msg_", ] as const; // ULID uses base32 encoding diff --git a/apps/web/lib/zod/schemas/messages.ts b/apps/web/lib/zod/schemas/messages.ts index daf53290ef4..12a07a7fc4c 100644 --- a/apps/web/lib/zod/schemas/messages.ts +++ b/apps/web/lib/zod/schemas/messages.ts @@ -48,3 +48,14 @@ export const getPartnerMessagesQuerySchema = z.object({ partnerId: z.string().optional(), messagesLimit: z.coerce.number().min(0).optional(), }); + +export const messagePartnerSchema = z.object({ + partnerId: z.string(), + text: z.string(), +}); + +export const updatePartnerMessageSchema = z.object({ + messageId: z.string(), + readInApp: z.boolean().optional(), + readInEmail: z.boolean().optional(), +}); From 1d1e5289e3a128fa71f9765c1710079553d9f664 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Wed, 3 Sep 2025 12:38:16 -0400 Subject: [PATCH 18/81] Message sending --- apps/web/app/(ee)/api/messages/route.ts | 20 ++-- .../messages/[partnerId]/page-client.tsx | 99 +++++++++++++++++-- .../lib/actions/partners/message-partner.ts | 19 +++- apps/web/lib/swr/use-partner-messages.ts | 5 +- apps/web/lib/zod/schemas/messages.ts | 12 +++ apps/web/ui/messages/messages-panel.tsx | 54 ++++++---- 6 files changed, 168 insertions(+), 41 deletions(-) diff --git a/apps/web/app/(ee)/api/messages/route.ts b/apps/web/app/(ee)/api/messages/route.ts index c7e1cb43c6a..afcf32b3c2c 100644 --- a/apps/web/app/(ee)/api/messages/route.ts +++ b/apps/web/app/(ee)/api/messages/route.ts @@ -12,8 +12,12 @@ export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); - const { partnerId, messagesLimit: messagesLimitArg } = - getPartnerMessagesQuerySchema.parse(searchParams); + const { + partnerId, + sortBy, + sortOrder, + messagesLimit: messagesLimitArg, + } = getPartnerMessagesQuerySchema.parse(searchParams); const messagesLimit = messagesLimitArg ?? (partnerId ? undefined : 10); @@ -41,7 +45,7 @@ export const GET = withWorkspace( senderUser: true, }, orderBy: { - createdAt: "desc", + [sortBy]: sortOrder, }, take: messagesLimit, }, @@ -54,10 +58,12 @@ export const GET = withWorkspace( PartnerMessagesSchema.parse( programEnrollments // Sort by most recent message - .sort( - (a, b) => - b.partner.messages[0].createdAt.getTime() - - a.partner.messages[0].createdAt.getTime(), + .sort((a, b) => + sortOrder === "desc" + ? b.partner.messages[0][sortBy].getTime() - + a.partner.messages[0][sortBy].getTime() + : a.partner.messages[0][sortBy].getTime() - + b.partner.messages[0][sortBy].getTime(), ) // Map to {partner, messages} .map(({ partner }) => ({ diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx index d0af5d25a18..664bd32cc11 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx @@ -1,7 +1,10 @@ "use client"; +import { messagePartnerAction } from "@/lib/actions/partners/message-partner"; import usePartner from "@/lib/swr/use-partner"; import { usePartnerMessages } from "@/lib/swr/use-partner-messages"; +import useProgram from "@/lib/swr/use-program"; +import useUser from "@/lib/swr/use-user"; import useWorkspace from "@/lib/swr/use-workspace"; import { useMessagesContext } from "@/ui/messages/messages-context"; import { MessagesPanel } from "@/ui/messages/messages-panel"; @@ -13,20 +16,30 @@ import { X } from "@/ui/shared/icons"; import { Button } from "@dub/ui"; import { ChevronLeft, LoadingSpinner } from "@dub/ui/icons"; import { OG_AVATAR_URL, cn } from "@dub/utils"; +import { useAction } from "next-safe-action/hooks"; import { redirect, useParams } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; +import { v4 as uuid } from "uuid"; export function ProgramMessagesPartnerPageClient() { - const { slug: workspaceSlug } = useWorkspace(); + const { id: workspaceId, slug: workspaceSlug } = useWorkspace(); const { partnerId } = useParams() as { partnerId: string }; + const { user } = useUser(); + const { program } = useProgram(); const { partner, error: errorPartner } = usePartner({ partnerId }); - const { partnerMessages, error: errorMessages } = usePartnerMessages({ - query: { partnerId }, + const { + partnerMessages, + error: errorMessages, + mutate: mutatePartnerMessages, + } = usePartnerMessages({ + query: { partnerId, sortOrder: "asc" }, }); const messages = partnerMessages?.[0]?.messages; + const { executeAsync: sendMessage } = useAction(messagePartnerAction); + const { setCurrentPanel } = useMessagesContext(); const [isRightPanelOpen, setIsRightPanelOpen] = useState(true); @@ -76,11 +89,82 @@ export function ProgramMessagesPartnerPageClient() {
+ currentUserId={user?.id || ""} + onSendMessage={async (message) => { + const createdAt = new Date(); + + try { + await mutatePartnerMessages( + async (data) => { + const result = await sendMessage({ + workspaceId: workspaceId!, + partnerId, + text: message, + createdAt, + }); + + if (!result?.data?.message) + throw new Error( + result?.serverError || "Failed to send message", + ); + + return data + ? [ + { + ...data[0], + messages: [ + ...data[0].messages, + result.data.message, + ], + }, + ] + : []; + }, + { + optimisticData: (data) => + data + ? [ + { + ...data[0], + messages: [ + ...data[0].messages, + { + delivered: false, + id: `tmp_${uuid()}`, + programId: program!.id, + partnerId: partnerId, + text: message, + + emailId: null, + readInApp: null, + readInEmail: null, + createdAt, + updatedAt: createdAt, + + senderPartnerId: null, + senderPartner: null, + senderUserId: user!.id, + senderUser: { + id: user!.id, + name: user!.name, + image: user!.image || null, + }, + }, + ], + }, + ] + : [], + rollbackOnError: true, + }, + ); + } catch (e) { + console.log("Failed to send message", e); + toast.error("Failed to send message"); + } + // setMessages((prev) => [ // ...prev, // { @@ -97,8 +181,7 @@ export function ProgramMessagesPartnerPageClient() { // }, // }, // ]) - toast.info("WIP") - } + }} />
diff --git a/apps/web/lib/actions/partners/message-partner.ts b/apps/web/lib/actions/partners/message-partner.ts index 3848421d614..3f5f05c8e8e 100644 --- a/apps/web/lib/actions/partners/message-partner.ts +++ b/apps/web/lib/actions/partners/message-partner.ts @@ -4,28 +4,39 @@ import { createId } from "@/lib/api/create-id"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { prisma } from "@dub/prisma"; -import { messagePartnerSchema } from "../../zod/schemas/messages"; +import { z } from "zod"; +import { + MessageSchema, + messagePartnerSchema, +} from "../../zod/schemas/messages"; import { authActionClient } from "../safe-action"; // Message a partner export const messagePartnerAction = authActionClient - .schema(messagePartnerSchema) + .schema(messagePartnerSchema.extend({ workspaceId: z.string() })) .action(async ({ parsedInput, ctx }) => { const { workspace, user } = ctx; - const { partnerId, text } = parsedInput; + const { partnerId, text, createdAt } = parsedInput; const programId = getDefaultProgramIdOrThrow(workspace); await getProgramEnrollmentOrThrow({ programId, partnerId }); - await prisma.message.create({ + const message = await prisma.message.create({ data: { id: createId({ prefix: "msg_" }), programId, partnerId, senderUserId: user.id, text, + createdAt, + }, + include: { + senderUser: true, + senderPartner: true, }, }); // TODO: [Messages] Send email to partner and track read status + + return { message: MessageSchema.parse(message) }; }); diff --git a/apps/web/lib/swr/use-partner-messages.ts b/apps/web/lib/swr/use-partner-messages.ts index e2679e00bae..e15e7c591fc 100644 --- a/apps/web/lib/swr/use-partner-messages.ts +++ b/apps/web/lib/swr/use-partner-messages.ts @@ -18,8 +18,8 @@ export function usePartnerMessages({ } = {}) { const { id: workspaceId } = useWorkspace(); - const { data, isLoading, error } = useSWR< - z.infer + const { data, isLoading, error, mutate } = useSWR< + z.infer & { delivered?: false } >( enabled && workspaceId ? `/api/messages?${new URLSearchParams({ @@ -37,5 +37,6 @@ export function usePartnerMessages({ partnerMessages: data, isLoading, error, + mutate, }; } diff --git a/apps/web/lib/zod/schemas/messages.ts b/apps/web/lib/zod/schemas/messages.ts index 12a07a7fc4c..a2283546aaa 100644 --- a/apps/web/lib/zod/schemas/messages.ts +++ b/apps/web/lib/zod/schemas/messages.ts @@ -47,11 +47,23 @@ export const PartnerMessagesSchema = z.array( export const getPartnerMessagesQuerySchema = z.object({ partnerId: z.string().optional(), messagesLimit: z.coerce.number().min(0).optional(), + sortBy: z.enum(["createdAt"]).default("createdAt"), + sortOrder: z.enum(["asc", "desc"]).default("desc"), }); export const messagePartnerSchema = z.object({ partnerId: z.string(), text: z.string(), + createdAt: z.coerce + .date() + .refine( + (date) => + date.getTime() <= Date.now() && + date.getTime() >= Date.now() - 1000 * 60, + { + message: "Message timestamp must be within the last 60 seconds", + }, + ), }); export const updatePartnerMessageSchema = z.object({ diff --git a/apps/web/ui/messages/messages-panel.tsx b/apps/web/ui/messages/messages-panel.tsx index 5e1aa76b055..06056f3b217 100644 --- a/apps/web/ui/messages/messages-panel.tsx +++ b/apps/web/ui/messages/messages-panel.tsx @@ -69,21 +69,19 @@ export function MessagesPanel({ new Date(message.createdAt).getTime() > new Date().getTime() - 5_000; - const showReadIndicator = + const showStatusIndicator = isCurrentUser && - message.delivered !== false && (idx === messages.length - 1 || messages .slice(idx + 1) .findIndex(isMessageFromCurrentUser) === -1); - const sender = - currentUserType === "partner" - ? message.senderPartner - : message.senderUser; + const sender = message.senderUser || message.senderPartner; return ( - + {isNewDate && (
{`${sender?.name} {/* Name / timestamp */} - {(!isCurrentUser || isNewTime || showReadIndicator) && ( + {(!isCurrentUser || isNewTime || showStatusIndicator) && (
{!isCurrentUser && ( @@ -147,8 +147,8 @@ export function MessagesPanel({ )} )} - {showReadIndicator && ( - + {showStatusIndicator && ( + )}
)} @@ -233,15 +233,21 @@ export function MessagesPanel({ ); } -function ReadIndicator({ message }: { message: Message }) { +function StatusIndicator({ + message, +}: { + message: Message & { delivered?: boolean }; +}) { return (
- - {(message.readInEmail || message.readInApp) && ( - + {message.delivered === false ? ( + <> + + + ) : ( + <> + + {(message.readInEmail || message.readInApp) && ( + + )} + )}
From 83ba92415e1edd75af69d5812e4a60691997adde Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Wed, 3 Sep 2025 15:02:35 -0400 Subject: [PATCH 19/81] Add links --- apps/web/ui/messages/messages-panel.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/ui/messages/messages-panel.tsx b/apps/web/ui/messages/messages-panel.tsx index 06056f3b217..5ef747f067f 100644 --- a/apps/web/ui/messages/messages-panel.tsx +++ b/apps/web/ui/messages/messages-panel.tsx @@ -7,6 +7,7 @@ import { Tooltip, } from "@dub/ui"; import { OG_AVATAR_URL, cn, formatDate } from "@dub/utils"; +import Linkify from "linkify-react"; import { Fragment, useRef, useState } from "react"; import ReactTextareaAutosize from "react-textarea-autosize"; import { EmojiPicker } from "./emoji-picker"; @@ -161,7 +162,16 @@ export function MessagesPanel({ : "text-content-default rounded-bl bg-neutral-100", )} > - {message.text} + + {message.text} +
From 32d6e4f625397aacfd42a785d6b2561f788a1beb Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Wed, 3 Sep 2025 16:18:32 -0400 Subject: [PATCH 20/81] Update route.ts --- apps/web/app/(ee)/api/messages/route.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/web/app/(ee)/api/messages/route.ts b/apps/web/app/(ee)/api/messages/route.ts index afcf32b3c2c..3f25a3f8f1c 100644 --- a/apps/web/app/(ee)/api/messages/route.ts +++ b/apps/web/app/(ee)/api/messages/route.ts @@ -24,14 +24,17 @@ export const GET = withWorkspace( const programEnrollments = await prisma.programEnrollment.findMany({ where: { programId, - ...(partnerId && { partnerId }), - partner: { - messages: { - some: { - programId, - }, - }, - }, + ...(partnerId + ? { partnerId } + : { + partner: { + messages: { + some: { + programId, + }, + }, + }, + }), }, include: { partner: { From d329e2e38ea8fb5ad51a199853c39cc1f0c8e7fa Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Wed, 3 Sep 2025 17:05:04 -0400 Subject: [PATCH 21/81] New conversations --- .../messages/[partnerId]/page-client.tsx | 22 ++------ .../[slug]/(ee)/program/messages/layout.tsx | 30 +++++++--- .../(ee)/program/messages/page-client.tsx | 56 ++++++++++++++----- apps/web/ui/messages/messages-panel.tsx | 4 ++ 4 files changed, 73 insertions(+), 39 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx index 664bd32cc11..9195c27c83e 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx @@ -1,6 +1,7 @@ "use client"; import { messagePartnerAction } from "@/lib/actions/partners/message-partner"; +import { mutatePrefix } from "@/lib/swr/mutate"; import usePartner from "@/lib/swr/use-partner"; import { usePartnerMessages } from "@/lib/swr/use-partner-messages"; import useProgram from "@/lib/swr/use-program"; @@ -41,7 +42,7 @@ export function ProgramMessagesPartnerPageClient() { const { executeAsync: sendMessage } = useAction(messagePartnerAction); const { setCurrentPanel } = useMessagesContext(); - const [isRightPanelOpen, setIsRightPanelOpen] = useState(true); + const [isRightPanelOpen, setIsRightPanelOpen] = useState(false); if (errorPartner) redirect(`/${workspaceSlug}/program/messages`); @@ -160,27 +161,12 @@ export function ProgramMessagesPartnerPageClient() { rollbackOnError: true, }, ); + + mutatePrefix("/api/messages"); } catch (e) { console.log("Failed to send message", e); toast.error("Failed to send message"); } - - // setMessages((prev) => [ - // ...prev, - // { - // id: `msg_${prev.length + 1}`, - // text: message, - // createdAt: new Date(), - // delivered: false, - // sender: { - // type: "user", - // id: "user_1", - // name: "Tim Wilson", - // avatar: - // "https://dubassets.com/avatars/clro5ctqd0000jv084g63ua08", - // }, - // }, - // ]) }} />
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx index e8b8e191586..1c16cc4bc97 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx @@ -5,22 +5,28 @@ import useWorkspace from "@/lib/swr/use-workspace"; import { NavButton } from "@/ui/layout/page-content/nav-button"; import { MessagesContext, MessagesPanel } from "@/ui/messages/messages-context"; import { MessagesList } from "@/ui/messages/messages-list"; -import { Button } from "@dub/ui"; +import { Button, useRouterStuff } from "@dub/ui"; import { Msgs, Pen2 } from "@dub/ui/icons"; -import { useParams } from "next/navigation"; -import { CSSProperties, ReactNode, useState } from "react"; -import { toast } from "sonner"; +import { useParams, useRouter } from "next/navigation"; +import { CSSProperties, ReactNode, useEffect, useState } from "react"; export default function MessagesLayout({ children }: { children: ReactNode }) { - const { id: workspaceId, slug: workspaceSlug } = useWorkspace(); + const { slug: workspaceSlug } = useWorkspace(); const { partnerId } = useParams() as { partnerId?: string }; + const router = useRouter(); + const { queryParams, searchParams } = useRouterStuff(); + const { partnerMessages, isLoading, error } = usePartnerMessages({ query: { messagesLimit: 1 }, }); const [currentPanel, setCurrentPanel] = useState("index"); + useEffect(() => { + searchParams.get("new") && setCurrentPanel("main"); + }, [searchParams.get("new")]); + return (
@@ -45,7 +51,11 @@ export default function MessagesLayout({ children }: { children: ReactNode }) { variant="secondary" icon={} className="size-8 rounded-lg p-0" - onClick={() => toast.info("WIP")} + onClick={() => { + if (partnerId) + router.push(`/${workspaceSlug}/program/messages?new=true`); + else queryParams({ set: { new: "true" } }); + }} />
@@ -81,7 +91,13 @@ export default function MessagesLayout({ children }: { children: ReactNode }) { variant="primary" className="mt-6 h-8 w-fit rounded-lg" text="Compose message" - onClick={() => toast.info("WIP")} + onClick={() => { + if (partnerId) + router.push( + `/${workspaceSlug}/program/messages?new=true`, + ); + else queryParams({ set: { new: "true" } }); + }} />
)} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx index 5427f10c1ad..dfdcea0fcfb 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx @@ -1,32 +1,60 @@ "use client"; +import useWorkspace from "@/lib/swr/use-workspace"; import { useMessagesContext } from "@/ui/messages/messages-context"; +import { PartnerSelector } from "@/ui/partners/partner-selector"; +import { useRouterStuff } from "@dub/ui"; import { ChevronLeft, Msgs } from "@dub/ui/icons"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; export function ProgramMessagesPageClient() { + const { slug: workspaceSlug } = useWorkspace(); const { setCurrentPanel } = useMessagesContext(); + const router = useRouter(); + const { searchParams, queryParams } = useRouterStuff(); + + // Short-lived state to display the selected partner while the next page loads + const [selectedPartnerId, setSelectedPartnerId] = useState( + null, + ); + return (
-
-
- - {/*

- [Partner Name] -

*/} -
+
+ + {searchParams.get("new") && ( +
+ To +
+ { + setSelectedPartnerId(id); + router.push(`/${workspaceSlug}/program/messages/${id}`); + }} + /> +
+
+ )}

- Select or compose a message + {searchParams.get("new") + ? "Select a partner to message" + : "Select or compose a message"}

diff --git a/apps/web/ui/messages/messages-panel.tsx b/apps/web/ui/messages/messages-panel.tsx index 5ef747f067f..de60840c052 100644 --- a/apps/web/ui/messages/messages-panel.tsx +++ b/apps/web/ui/messages/messages-panel.tsx @@ -5,6 +5,7 @@ import { Check2, LoadingSpinner, Tooltip, + useMediaQuery, } from "@dub/ui"; import { OG_AVATAR_URL, cn, formatDate } from "@dub/utils"; import Linkify from "linkify-react"; @@ -27,6 +28,8 @@ export function MessagesPanel({ placeholder?: string; error?: any; }) { + const { isMobile } = useMediaQuery(); + const textAreaRef = useRef(null); const selectionStartRef = useRef(null); const [typedMessage, setTypedMessage] = useState(""); @@ -193,6 +196,7 @@ export function MessagesPanel({
Date: Wed, 3 Sep 2025 17:07:56 -0400 Subject: [PATCH 22/81] Update page-client.tsx --- .../(dashboard)/[slug]/(ee)/program/messages/page-client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx index dfdcea0fcfb..dc45bd354fe 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx @@ -36,7 +36,7 @@ export function ProgramMessagesPageClient() { {searchParams.get("new") && (
To -
+
{ From dc5cccb52450cbe45ff734afb4a9ba4346975d3d Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Wed, 3 Sep 2025 17:33:57 -0400 Subject: [PATCH 23/81] Misc. fixes --- .../[slug]/(ee)/program/messages/[partnerId]/page-client.tsx | 2 +- apps/web/ui/messages/messages-list.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx index 9195c27c83e..3dabe01dc3b 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx @@ -72,7 +72,7 @@ export function ProgramMessagesPartnerPageClient() { ) : ( <> {`${partner?.name} diff --git a/apps/web/ui/messages/messages-list.tsx b/apps/web/ui/messages/messages-list.tsx index a58f2e65256..047ccacaf21 100644 --- a/apps/web/ui/messages/messages-list.tsx +++ b/apps/web/ui/messages/messages-list.tsx @@ -36,7 +36,7 @@ export function MessagesList({ )} > {`${group.name} From 7bcf41b78f12686fad85ef86315f594c89bbb2ae Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Thu, 4 Sep 2025 10:33:37 -0400 Subject: [PATCH 24/81] Message updates --- apps/web/lib/zod/schemas/messages.ts | 4 +++- apps/web/ui/messages/messages-panel.tsx | 2 ++ packages/prisma/schema/message.prisma | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/zod/schemas/messages.ts b/apps/web/lib/zod/schemas/messages.ts index a2283546aaa..d9c46ce5791 100644 --- a/apps/web/lib/zod/schemas/messages.ts +++ b/apps/web/lib/zod/schemas/messages.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { PartnerSchema } from "./partners"; import { UserSchema } from "./users"; +export const MAX_MESSAGE_LENGTH = 2000; + export const MessageSchema = z .object({ id: z.string(), @@ -10,7 +12,7 @@ export const MessageSchema = z senderPartnerId: z.string().nullable(), senderUserId: z.string().nullable(), - text: z.string(), + text: z.string().min(1).max(MAX_MESSAGE_LENGTH), emailId: z.string().nullable(), readInApp: z.date().nullable(), diff --git a/apps/web/ui/messages/messages-panel.tsx b/apps/web/ui/messages/messages-panel.tsx index de60840c052..829cb026544 100644 --- a/apps/web/ui/messages/messages-panel.tsx +++ b/apps/web/ui/messages/messages-panel.tsx @@ -1,4 +1,5 @@ import { Message } from "@/lib/types"; +import { MAX_MESSAGE_LENGTH } from "@/lib/zod/schemas/messages"; import { ArrowTurnLeft, Button, @@ -201,6 +202,7 @@ export function MessagesPanel({ placeholder={placeholder} disabled={!messages} value={typedMessage} + maxLength={MAX_MESSAGE_LENGTH} onChange={(e) => setTypedMessage(e.currentTarget.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { diff --git a/packages/prisma/schema/message.prisma b/packages/prisma/schema/message.prisma index bf853a12939..a43de7d1dde 100644 --- a/packages/prisma/schema/message.prisma +++ b/packages/prisma/schema/message.prisma @@ -7,7 +7,7 @@ model Message { senderPartnerId String? // Populated if the sender is a partner senderUserId String? // Populated if the sender is a program owner/user - text String + text String @db.Text emailId String? readInApp DateTime? From 0f11f4a10db73e3ec409ffe95e2e184091924972 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Thu, 4 Sep 2025 11:02:49 -0400 Subject: [PATCH 25/81] Mark messages as read --- .../messages/[partnerId]/page-client.tsx | 22 ++++++++++++++ .../partners/mark-partner-messages-read.ts | 29 +++++++++++++++++++ apps/web/lib/swr/use-partner-messages.ts | 5 +++- 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 apps/web/lib/actions/partners/mark-partner-messages-read.ts diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx index 3dabe01dc3b..34bf169ff26 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx @@ -1,5 +1,6 @@ "use client"; +import { markPartnersMessagesReadAction } from "@/lib/actions/partners/mark-partner-messages-read"; import { messagePartnerAction } from "@/lib/actions/partners/message-partner"; import { mutatePrefix } from "@/lib/swr/mutate"; import usePartner from "@/lib/swr/use-partner"; @@ -30,12 +31,33 @@ export function ProgramMessagesPartnerPageClient() { const { user } = useUser(); const { program } = useProgram(); const { partner, error: errorPartner } = usePartner({ partnerId }); + + const { + executeAsync: markPartnerMessagesRead, + isPending: isMarkingPartnerMessagesRead, + } = useAction(markPartnersMessagesReadAction); + const { partnerMessages, error: errorMessages, mutate: mutatePartnerMessages, } = usePartnerMessages({ query: { partnerId, sortOrder: "asc" }, + swrOpts: { + onSuccess: (data) => { + // Mark unread messages from the partner as read + if ( + !isMarkingPartnerMessagesRead && + data?.[0]?.messages?.some( + (message) => message.senderPartnerId && !message.readInApp, + ) + ) + markPartnerMessagesRead({ + workspaceId: workspaceId!, + partnerId, + }); + }, + }, }); const messages = partnerMessages?.[0]?.messages; diff --git a/apps/web/lib/actions/partners/mark-partner-messages-read.ts b/apps/web/lib/actions/partners/mark-partner-messages-read.ts new file mode 100644 index 00000000000..a8f47fdab94 --- /dev/null +++ b/apps/web/lib/actions/partners/mark-partner-messages-read.ts @@ -0,0 +1,29 @@ +"use server"; + +import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { prisma } from "@dub/prisma"; +import { z } from "zod"; +import { authActionClient } from "../safe-action"; + +// Mark partner messages as read +export const markPartnersMessagesReadAction = authActionClient + .schema(z.object({ workspaceId: z.string(), partnerId: z.string() })) + .action(async ({ parsedInput, ctx }) => { + const { workspace } = ctx; + const { partnerId } = parsedInput; + + const programId = getDefaultProgramIdOrThrow(workspace); + + await prisma.message.updateMany({ + where: { + partnerId, + programId, + senderPartnerId: { + not: null, + }, + }, + data: { + readInApp: new Date(), + }, + }); + }); diff --git a/apps/web/lib/swr/use-partner-messages.ts b/apps/web/lib/swr/use-partner-messages.ts index e15e7c591fc..fb6aa06c5df 100644 --- a/apps/web/lib/swr/use-partner-messages.ts +++ b/apps/web/lib/swr/use-partner-messages.ts @@ -1,5 +1,5 @@ import { fetcher } from "@dub/utils"; -import useSWR from "swr"; +import useSWR, { SWRConfiguration } from "swr"; import { z } from "zod"; import { PartnerMessagesSchema, @@ -12,9 +12,11 @@ const partialQuerySchema = getPartnerMessagesQuerySchema.partial(); export function usePartnerMessages({ query, enabled = true, + swrOpts, }: { query?: z.infer; enabled?: boolean; + swrOpts?: SWRConfiguration; } = {}) { const { id: workspaceId } = useWorkspace(); @@ -30,6 +32,7 @@ export function usePartnerMessages({ fetcher, { keepPreviousData: true, + ...swrOpts, }, ); From 3aaafed2cb61d62204d0fafb4a856b131cd8d2ed Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Thu, 4 Sep 2025 14:38:24 -0400 Subject: [PATCH 26/81] Upsell + program logos --- .../messages/[partnerId]/page-client.tsx | 1 + .../[slug]/(ee)/program/messages/layout.tsx | 12 ++ .../(ee)/program/messages/messages-upsell.tsx | 113 ++++++++++++++++++ apps/web/lib/plan-capabilities.ts | 1 + apps/web/ui/messages/messages-panel.tsx | 10 +- 5 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/messages-upsell.tsx diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx index 34bf169ff26..2abba50b23d 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx @@ -116,6 +116,7 @@ export function ProgramMessagesPartnerPageClient() { error={errorMessages} currentUserType="user" currentUserId={user?.id || ""} + programImage={program?.logo} onSendMessage={async (message) => { const createdAt = new Date(); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx index 1c16cc4bc97..ee4dd6fadf6 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx @@ -1,5 +1,6 @@ "use client"; +import { getPlanCapabilities } from "@/lib/plan-capabilities"; import { usePartnerMessages } from "@/lib/swr/use-partner-messages"; import useWorkspace from "@/lib/swr/use-workspace"; import { NavButton } from "@/ui/layout/page-content/nav-button"; @@ -9,8 +10,19 @@ import { Button, useRouterStuff } from "@dub/ui"; import { Msgs, Pen2 } from "@dub/ui/icons"; import { useParams, useRouter } from "next/navigation"; import { CSSProperties, ReactNode, useEffect, useState } from "react"; +import { MessagesUpsell } from "./messages-upsell"; export default function MessagesLayout({ children }: { children: ReactNode }) { + const { plan } = useWorkspace(); + + const { canMessagePartners } = getPlanCapabilities(plan); + + if (!canMessagePartners) return ; + + return {children}; +} + +export function CapableLayout({ children }: { children: ReactNode }) { const { slug: workspaceSlug } = useWorkspace(); const { partnerId } = useParams() as { partnerId?: string }; diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/messages-upsell.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/messages-upsell.tsx new file mode 100644 index 00000000000..bbacab2f554 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/messages-upsell.tsx @@ -0,0 +1,113 @@ +"use client"; + +import useWorkspace from "@/lib/swr/use-workspace"; +import { PageContent } from "@/ui/layout/page-content"; +import { buttonVariants } from "@dub/ui"; +import { cn } from "@dub/utils"; +import Link from "next/link"; + +export function MessagesUpsell() { + const { slug } = useWorkspace(); + + return ( + +
+
+ + + +
+
+ + Messaging Center + +

+ Messaging makes working with partners easier. Available on Advanced + plans and higher +

+
+
+ + Upgrade to Advanced + +
+
+
+ ); +} + +const DemoMessage = ({ + isCurrentUser, + avatarIndex, + programImage, + text, +}: { + isCurrentUser: boolean; + avatarIndex: number; + programImage?: string; + text: string; +}) => { + return ( +
+ {/* Avatar */} +
+
+ {programImage && ( + avatar + )} +
+ +
+ {/* Message box */} +
+ {text} +
+
+
+ ); +}; diff --git a/apps/web/lib/plan-capabilities.ts b/apps/web/lib/plan-capabilities.ts index 8e495ac8204..b033c4e1b6e 100644 --- a/apps/web/lib/plan-capabilities.ts +++ b/apps/web/lib/plan-capabilities.ts @@ -14,5 +14,6 @@ export const getPlanCapabilities = ( canExportAuditLogs: !!plan && ["enterprise"].includes(plan), canUseAdvancedRewardLogic: !!plan && ["enterprise", "advanced"].includes(plan), + canMessagePartners: !!plan && ["enterprise", "advanced"].includes(plan), }; }; diff --git a/apps/web/ui/messages/messages-panel.tsx b/apps/web/ui/messages/messages-panel.tsx index 829cb026544..1f22fc344e5 100644 --- a/apps/web/ui/messages/messages-panel.tsx +++ b/apps/web/ui/messages/messages-panel.tsx @@ -18,6 +18,7 @@ export function MessagesPanel({ messages, currentUserType, currentUserId, + programImage, onSendMessage, placeholder = "Type a message...", error, @@ -25,6 +26,7 @@ export function MessagesPanel({ messages?: (Message & { delivered?: boolean })[]; currentUserType: "partner" | "user"; currentUserId: string; + programImage?: string | null; onSendMessage: (message: string) => void; placeholder?: string; error?: any; @@ -117,13 +119,13 @@ export function MessagesPanel({ className="size-8 rounded-full" draggable={false} /> - {/* {sender?.groupAvatar && ( + {programImage && !message.senderPartnerId && ( - )} */} + )}
From 418fdf13b8d7b31337b0b40bd7223a32ff823440 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Thu, 4 Sep 2025 15:34:24 -0400 Subject: [PATCH 27/81] WIP partners side --- apps/web/app/(ee)/api/messages/route.ts | 4 +- .../api/partner-profile/messages/route.ts | 74 ++++++ .../messages/[programSlug]/page-client.tsx | 237 ++++++++++++++++++ .../messages/[programSlug]/page.tsx | 5 + .../(dashboard)/messages/layout.tsx | 110 ++++++++ .../(dashboard)/messages/page-client.tsx | 63 +++++ .../(dashboard)/messages/page.tsx | 5 + apps/web/lib/swr/use-program-messages.ts | 41 +++ apps/web/lib/zod/schemas/messages.ts | 20 ++ .../layout/sidebar/partners-sidebar-nav.tsx | 10 +- 10 files changed, 566 insertions(+), 3 deletions(-) create mode 100644 apps/web/app/(ee)/api/partner-profile/messages/route.ts create mode 100644 apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx create mode 100644 apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page.tsx create mode 100644 apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/layout.tsx create mode 100644 apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/page-client.tsx create mode 100644 apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/page.tsx create mode 100644 apps/web/lib/swr/use-program-messages.ts diff --git a/apps/web/app/(ee)/api/messages/route.ts b/apps/web/app/(ee)/api/messages/route.ts index 3f25a3f8f1c..ef79ee21be3 100644 --- a/apps/web/app/(ee)/api/messages/route.ts +++ b/apps/web/app/(ee)/api/messages/route.ts @@ -69,9 +69,9 @@ export const GET = withWorkspace( b.partner.messages[0][sortBy].getTime(), ) // Map to {partner, messages} - .map(({ partner }) => ({ + .map(({ partner: { messages, ...partner } }) => ({ partner, - messages: partner.messages, + messages, })), ), ); diff --git a/apps/web/app/(ee)/api/partner-profile/messages/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/route.ts new file mode 100644 index 00000000000..0c2ee9b196f --- /dev/null +++ b/apps/web/app/(ee)/api/partner-profile/messages/route.ts @@ -0,0 +1,74 @@ +import { withPartnerProfile } from "@/lib/auth/partner"; +import { + ProgramMessagesSchema, + getProgramMessagesQuerySchema, +} from "@/lib/zod/schemas/messages"; +import { prisma } from "@dub/prisma"; +import { NextResponse } from "next/server"; + +// GET /api/partner-profile/messages - get messages grouped by program +export const GET = withPartnerProfile(async ({ partner, searchParams }) => { + const { + programSlug, + sortBy, + sortOrder, + messagesLimit: messagesLimitArg, + } = getProgramMessagesQuerySchema.parse(searchParams); + + const messagesLimit = messagesLimitArg ?? (programSlug ? undefined : 10); + + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + partnerId: partner.id, + program: { + ...(programSlug + ? { slug: programSlug } + : { + messages: { + some: { + partnerId: partner.id, + }, + }, + }), + }, + }, + include: { + program: { + include: { + messages: { + where: { + partnerId: partner.id, + }, + include: { + senderPartner: true, + senderUser: true, + }, + orderBy: { + [sortBy]: sortOrder, + }, + take: messagesLimit, + }, + }, + }, + }, + }); + + return NextResponse.json( + ProgramMessagesSchema.parse( + programEnrollments + // Sort by most recent message + .sort((a, b) => + sortOrder === "desc" + ? b.program.messages[0][sortBy].getTime() - + a.program.messages[0][sortBy].getTime() + : a.program.messages[0][sortBy].getTime() - + b.program.messages[0][sortBy].getTime(), + ) + // Map to {program, messages} + .map(({ program: { messages, ...program } }) => ({ + program, + messages, + })), + ), + ); +}); diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx new file mode 100644 index 00000000000..f8093eb0f10 --- /dev/null +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { messagePartnerAction } from "@/lib/actions/partners/message-partner"; +import useProgramEnrollment from "@/lib/swr/use-program-enrollment"; +import { useProgramMessages } from "@/lib/swr/use-program-messages"; +import useUser from "@/lib/swr/use-user"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { useMessagesContext } from "@/ui/messages/messages-context"; +import { MessagesPanel } from "@/ui/messages/messages-panel"; +import { ToggleSidePanelButton } from "@/ui/messages/toggle-side-panel-button"; +import { X } from "@/ui/shared/icons"; +import { Button } from "@dub/ui"; +import { ChevronLeft, LoadingSpinner } from "@dub/ui/icons"; +import { cn } from "@dub/utils"; +import { useAction } from "next-safe-action/hooks"; +import { redirect, useParams } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; + +export function PartnerMessagesProgramPageClient() { + const { id: workspaceId, slug: workspaceSlug } = useWorkspace(); + + const { programSlug } = useParams() as { programSlug: string }; + const { user } = useUser(); + const { programEnrollment, error: errorProgramEnrollment } = + useProgramEnrollment(); + const program = programEnrollment?.program; + + // const { + // executeAsync: markPartnerMessagesRead, + // isPending: isMarkingPartnerMessagesRead, + // } = useAction(markPartnersMessagesReadAction); + + const { + programMessages, + error: errorMessages, + mutate: mutateProgramMessages, + } = useProgramMessages({ + query: { programSlug, sortOrder: "asc" }, + swrOpts: { + // onSuccess: (data) => { + // // Mark unread messages from the partner as read + // if ( + // !isMarkingPartnerMessagesRead && + // data?.[0]?.messages?.some( + // (message) => message.senderPartnerId && !message.readInApp, + // ) + // ) + // markPartnerMessagesRead({ + // workspaceId: workspaceId!, + // partnerId, + // }); + // }, + }, + }); + const messages = programMessages?.[0]?.messages; + + const { executeAsync: sendMessage } = useAction(messagePartnerAction); + + const { setCurrentPanel } = useMessagesContext(); + const [isRightPanelOpen, setIsRightPanelOpen] = useState(false); + + if (errorProgramEnrollment) redirect(`/messages`); + + return ( +
+
+
+
+ +
+ {!program ? ( + <> +
+
+ + ) : ( + <> + {`${program?.name} +

+ {program?.name ?? "Program"} +

+ + )} +
+
+ setIsRightPanelOpen((o) => !o)} + /> +
+
+ { + toast.info("WIP"); + // const createdAt = new Date(); + + // try { + // await mutateProgramMessages( + // async (data) => { + // const result = await sendMessage({ + // workspaceId: workspaceId!, + // partnerId, + // text: message, + // createdAt, + // }); + + // if (!result?.data?.message) + // throw new Error( + // result?.serverError || "Failed to send message", + // ); + + // return data + // ? [ + // { + // ...data[0], + // messages: [ + // ...data[0].messages, + // result.data.message, + // ], + // }, + // ] + // : []; + // }, + // { + // optimisticData: (data) => + // data + // ? [ + // { + // ...data[0], + // messages: [ + // ...data[0].messages, + // { + // delivered: false, + // id: `tmp_${uuid()}`, + // programId: program!.id, + // partnerId: partnerId, + // text: message, + + // emailId: null, + // readInApp: null, + // readInEmail: null, + // createdAt, + // updatedAt: createdAt, + + // senderPartnerId: null, + // senderPartner: null, + // senderUserId: user!.id, + // senderUser: { + // id: user!.id, + // name: user!.name, + // image: user!.image || null, + // }, + // }, + // ], + // }, + // ] + // : [], + // rollbackOnError: true, + // }, + // ); + + // mutatePrefix("/api/messages"); + // } catch (e) { + // console.log("Failed to send message", e); + // toast.error("Failed to send message"); + // } + }} + /> +
+
+ + {/* Right panel - Profile */} +
+
+
+

+ Program +

+
+ +
+
+
+ {program ? ( + <>{program.name} + ) : ( +
+ +
+ )} +
+
+
+
+ ); +} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page.tsx new file mode 100644 index 00000000000..d7c4be82262 --- /dev/null +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page.tsx @@ -0,0 +1,5 @@ +import { PartnerMessagesProgramPageClient } from "./page-client"; + +export default function PartnerMessagesProgramPage() { + return ; +} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/layout.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/layout.tsx new file mode 100644 index 00000000000..a4bb6fbd6bf --- /dev/null +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/layout.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useProgramMessages } from "@/lib/swr/use-program-messages"; +import { NavButton } from "@/ui/layout/page-content/nav-button"; +import { MessagesContext, MessagesPanel } from "@/ui/messages/messages-context"; +import { MessagesList } from "@/ui/messages/messages-list"; +import { Button, useRouterStuff } from "@dub/ui"; +import { Msgs, Pen2 } from "@dub/ui/icons"; +import { useParams, useRouter } from "next/navigation"; +import { CSSProperties, ReactNode, useEffect, useState } from "react"; + +export default function MessagesLayout({ children }: { children: ReactNode }) { + const { partnerId } = useParams() as { partnerId?: string }; + + const router = useRouter(); + const { queryParams, searchParams } = useRouterStuff(); + + const { programMessages, isLoading, error } = useProgramMessages({ + query: { messagesLimit: 1 }, + }); + + const [currentPanel, setCurrentPanel] = useState("index"); + + useEffect(() => { + searchParams.get("new") && setCurrentPanel("main"); + }, [searchParams.get("new")]); + + return ( + +
+
+ {/* Left panel - 800px/messages list */} +
+
+
+ +

+ Messages +

+
+
+
+ {programMessages?.length || isLoading ? ( + ({ + id: program.id, + name: program.name, + image: program.logo, + messages, + href: `/messages/${program.slug}`, + }), + )} + activeId={partnerId} + /> + ) : error ? ( +
+ Failed to load messages +
+ ) : ( +
+ +
+ + You don't have any messages + +

+ When you receive a new message, it will appear here. You + can also start a conversation at any time. +

+
+ +
+ )} +
+
+ +
+ {children} +
+
+
+
+ ); +} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/page-client.tsx new file mode 100644 index 00000000000..515b219f6c6 --- /dev/null +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/page-client.tsx @@ -0,0 +1,63 @@ +"use client"; + +import useWorkspace from "@/lib/swr/use-workspace"; +import { useMessagesContext } from "@/ui/messages/messages-context"; +import { useRouterStuff } from "@dub/ui"; +import { ChevronLeft, Msgs } from "@dub/ui/icons"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +export function PartnerMessagesPageClient() { + const { slug: workspaceSlug } = useWorkspace(); + const { setCurrentPanel } = useMessagesContext(); + + const router = useRouter(); + const { searchParams, queryParams } = useRouterStuff(); + + // Short-lived state to display the selected program while the next page loads + const [selectedProgramId, setSelectedProgramId] = useState( + null, + ); + + return ( +
+
+ + {searchParams.get("new") && ( +
+ To +
+ {/* TODO: [Messages] Add program selector */} + [Program Selector] + {/* { + setSelectedPartnerId(id); + router.push(`/${workspaceSlug}/program/messages/${id}`); + }} + /> */} +
+
+ )} +
+ +
+ +

+ {searchParams.get("new") + ? "Select a program to message" + : "Select or compose a message"} +

+
+
+ ); +} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/page.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/page.tsx new file mode 100644 index 00000000000..bb8675d2f80 --- /dev/null +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/page.tsx @@ -0,0 +1,5 @@ +import { PartnerMessagesPageClient } from "./page-client"; + +export default function PartnerMessages() { + return ; +} diff --git a/apps/web/lib/swr/use-program-messages.ts b/apps/web/lib/swr/use-program-messages.ts new file mode 100644 index 00000000000..33fa5742f4a --- /dev/null +++ b/apps/web/lib/swr/use-program-messages.ts @@ -0,0 +1,41 @@ +import { fetcher } from "@dub/utils"; +import useSWR, { SWRConfiguration } from "swr"; +import { z } from "zod"; +import { + ProgramMessagesSchema, + getProgramMessagesQuerySchema, +} from "../zod/schemas/messages"; + +const partialQuerySchema = getProgramMessagesQuerySchema.partial(); + +export function useProgramMessages({ + query, + enabled = true, + swrOpts, +}: { + query?: z.infer; + enabled?: boolean; + swrOpts?: SWRConfiguration; +} = {}) { + const { data, isLoading, error, mutate } = useSWR< + z.infer & { delivered?: false } + >( + enabled + ? `/api/partner-profile/messages?${new URLSearchParams({ + ...(query as Record), + }).toString()}` + : null, + fetcher, + { + keepPreviousData: true, + ...swrOpts, + }, + ); + + return { + programMessages: data, + isLoading, + error, + mutate, + }; +} diff --git a/apps/web/lib/zod/schemas/messages.ts b/apps/web/lib/zod/schemas/messages.ts index d9c46ce5791..7557b3802e4 100644 --- a/apps/web/lib/zod/schemas/messages.ts +++ b/apps/web/lib/zod/schemas/messages.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { PartnerSchema } from "./partners"; +import { ProgramSchema } from "./programs"; import { UserSchema } from "./users"; export const MAX_MESSAGE_LENGTH = 2000; @@ -73,3 +74,22 @@ export const updatePartnerMessageSchema = z.object({ readInApp: z.boolean().optional(), readInEmail: z.boolean().optional(), }); + +export const ProgramMessagesSchema = z.array( + z.object({ + program: ProgramSchema.pick({ + id: true, + slug: true, + name: true, + logo: true, + }), + messages: z.array(MessageSchema), + }), +); + +export const getProgramMessagesQuerySchema = z.object({ + programSlug: z.string().optional(), + messagesLimit: z.coerce.number().min(0).optional(), + sortBy: z.enum(["createdAt"]).default("createdAt"), + sortOrder: z.enum(["asc", "desc"]).default("desc"), +}); diff --git a/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx b/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx index 595b0233839..656cbd1d24f 100644 --- a/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx +++ b/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx @@ -15,6 +15,7 @@ import { Globe, GridIcon, MoneyBills2, + Msgs, ShieldCheck, SquareUserSparkle2, Trophy, @@ -64,6 +65,13 @@ const NAV_GROUPS: SidebarNavGroups = ({ pathname }) => [ href: "/profile", active: pathname.startsWith("/profile"), }, + { + name: "Messages", + description: "Chat with programs you're enrolled in", + icon: Msgs, + href: "/messages", + active: pathname.startsWith("/messages"), + }, ]; const NAV_AREAS: SidebarNavAreas = { @@ -282,7 +290,7 @@ export function PartnersSidebarNav({ ? "partnerSettings" : pathname.startsWith("/profile") ? "profile" - : pathname.startsWith("/payouts") + : pathname.startsWith("/payouts") || pathname.startsWith("/messages") ? null : isEnrolledProgramPage ? "program" From d5210df5ef2ecfd02197a5a35b9de003eb8e3b00 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Thu, 4 Sep 2025 15:35:44 -0400 Subject: [PATCH 28/81] Update layout.tsx --- .../partners.dub.co/(dashboard)/messages/layout.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/layout.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/layout.tsx index a4bb6fbd6bf..d4e296b2870 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/layout.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/layout.tsx @@ -10,7 +10,7 @@ import { useParams, useRouter } from "next/navigation"; import { CSSProperties, ReactNode, useEffect, useState } from "react"; export default function MessagesLayout({ children }: { children: ReactNode }) { - const { partnerId } = useParams() as { partnerId?: string }; + const { programSlug } = useParams() as { programSlug?: string }; const router = useRouter(); const { queryParams, searchParams } = useRouterStuff(); @@ -50,7 +50,7 @@ export default function MessagesLayout({ children }: { children: ReactNode }) { icon={} className="size-8 rounded-lg p-0" onClick={() => { - if (partnerId) router.push(`/messages?new=true`); + if (programSlug) router.push(`/messages?new=true`); else queryParams({ set: { new: "true" } }); }} /> @@ -60,14 +60,14 @@ export default function MessagesLayout({ children }: { children: ReactNode }) { ({ - id: program.id, + id: program.slug, name: program.name, image: program.logo, messages, href: `/messages/${program.slug}`, }), )} - activeId={partnerId} + activeId={programSlug} /> ) : error ? (
@@ -91,7 +91,7 @@ export default function MessagesLayout({ children }: { children: ReactNode }) { className="mt-6 h-8 w-fit rounded-lg" text="Compose message" onClick={() => { - if (partnerId) router.push(`/messages?new=true`); + if (programSlug) router.push(`/messages?new=true`); else queryParams({ set: { new: "true" } }); }} /> From e53b83d2b65bb6dcee7d35998af9dd6d586091ba Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Thu, 4 Sep 2025 16:06:42 -0400 Subject: [PATCH 29/81] Update messages-panel.tsx --- apps/web/ui/messages/messages-panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/ui/messages/messages-panel.tsx b/apps/web/ui/messages/messages-panel.tsx index 1f22fc344e5..b69053ff504 100644 --- a/apps/web/ui/messages/messages-panel.tsx +++ b/apps/web/ui/messages/messages-panel.tsx @@ -131,7 +131,7 @@ export function MessagesPanel({
From cf66b01c3504425115ebc1b514291f28e8dbf882 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Thu, 4 Sep 2025 16:50:03 -0400 Subject: [PATCH 30/81] Partner message sending --- .../messages/[programSlug]/page-client.tsx | 161 +++++++++--------- .../lib/actions/partners/message-program.ts | 42 +++++ apps/web/lib/zod/schemas/messages.ts | 15 ++ 3 files changed, 137 insertions(+), 81 deletions(-) create mode 100644 apps/web/lib/actions/partners/message-program.ts diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx index f8093eb0f10..2a18eea54b4 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx @@ -1,10 +1,11 @@ "use client"; -import { messagePartnerAction } from "@/lib/actions/partners/message-partner"; +import { messageProgramAction } from "@/lib/actions/partners/message-program"; +import { mutatePrefix } from "@/lib/swr/mutate"; +import usePartnerProfile from "@/lib/swr/use-partner-profile"; import useProgramEnrollment from "@/lib/swr/use-program-enrollment"; import { useProgramMessages } from "@/lib/swr/use-program-messages"; import useUser from "@/lib/swr/use-user"; -import useWorkspace from "@/lib/swr/use-workspace"; import { useMessagesContext } from "@/ui/messages/messages-context"; import { MessagesPanel } from "@/ui/messages/messages-panel"; import { ToggleSidePanelButton } from "@/ui/messages/toggle-side-panel-button"; @@ -16,11 +17,11 @@ import { useAction } from "next-safe-action/hooks"; import { redirect, useParams } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; +import { v4 as uuid } from "uuid"; export function PartnerMessagesProgramPageClient() { - const { id: workspaceId, slug: workspaceSlug } = useWorkspace(); - const { programSlug } = useParams() as { programSlug: string }; + const { partner } = usePartnerProfile(); const { user } = useUser(); const { programEnrollment, error: errorProgramEnrollment } = useProgramEnrollment(); @@ -55,7 +56,7 @@ export function PartnerMessagesProgramPageClient() { }); const messages = programMessages?.[0]?.messages; - const { executeAsync: sendMessage } = useAction(messagePartnerAction); + const { executeAsync: sendMessage } = useAction(messageProgramAction); const { setCurrentPanel } = useMessagesContext(); const [isRightPanelOpen, setIsRightPanelOpen] = useState(false); @@ -106,85 +107,83 @@ export function PartnerMessagesProgramPageClient() {
{ - toast.info("WIP"); - // const createdAt = new Date(); - - // try { - // await mutateProgramMessages( - // async (data) => { - // const result = await sendMessage({ - // workspaceId: workspaceId!, - // partnerId, - // text: message, - // createdAt, - // }); - - // if (!result?.data?.message) - // throw new Error( - // result?.serverError || "Failed to send message", - // ); - - // return data - // ? [ - // { - // ...data[0], - // messages: [ - // ...data[0].messages, - // result.data.message, - // ], - // }, - // ] - // : []; - // }, - // { - // optimisticData: (data) => - // data - // ? [ - // { - // ...data[0], - // messages: [ - // ...data[0].messages, - // { - // delivered: false, - // id: `tmp_${uuid()}`, - // programId: program!.id, - // partnerId: partnerId, - // text: message, - - // emailId: null, - // readInApp: null, - // readInEmail: null, - // createdAt, - // updatedAt: createdAt, - - // senderPartnerId: null, - // senderPartner: null, - // senderUserId: user!.id, - // senderUser: { - // id: user!.id, - // name: user!.name, - // image: user!.image || null, - // }, - // }, - // ], - // }, - // ] - // : [], - // rollbackOnError: true, - // }, - // ); - - // mutatePrefix("/api/messages"); - // } catch (e) { - // console.log("Failed to send message", e); - // toast.error("Failed to send message"); - // } + const createdAt = new Date(); + + try { + await mutateProgramMessages( + async (data) => { + const result = await sendMessage({ + programSlug, + text: message, + createdAt, + }); + + if (!result?.data?.message) + throw new Error( + result?.serverError || "Failed to send message", + ); + + return data + ? [ + { + ...data[0], + messages: [ + ...data[0].messages, + result.data.message, + ], + }, + ] + : []; + }, + { + optimisticData: (data) => + data + ? [ + { + ...data[0], + messages: [ + ...data[0].messages, + { + delivered: false, + id: `tmp_${uuid()}`, + programId: program!.id, + partnerId: partner!.id, + text: message, + + emailId: null, + readInApp: null, + readInEmail: null, + createdAt, + updatedAt: createdAt, + + senderUserId: null, + senderUser: null, + senderPartnerId: partner!.id, + senderPartner: { + id: partner!.id, + name: partner!.name, + image: partner!.image || null, + }, + }, + ], + }, + ] + : [], + rollbackOnError: true, + }, + ); + + mutatePrefix("/api/partner-profile/messages"); + } catch (e) { + console.log("Failed to send message", e); + toast.error("Failed to send message"); + } }} />
diff --git a/apps/web/lib/actions/partners/message-program.ts b/apps/web/lib/actions/partners/message-program.ts new file mode 100644 index 00000000000..cbd94e4dffc --- /dev/null +++ b/apps/web/lib/actions/partners/message-program.ts @@ -0,0 +1,42 @@ +"use server"; + +import { createId } from "@/lib/api/create-id"; +import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; +import { prisma } from "@dub/prisma"; +import { + MessageSchema, + messageProgramSchema, +} from "../../zod/schemas/messages"; +import { authPartnerActionClient } from "../safe-action"; + +// Message a program +export const messageProgramAction = authPartnerActionClient + .schema(messageProgramSchema) + .action(async ({ parsedInput, ctx }) => { + const { partner } = ctx; + const { programSlug, text, createdAt } = parsedInput; + + const enrollment = await getProgramEnrollmentOrThrow({ + programId: programSlug, + partnerId: partner.id, + }); + + const message = await prisma.message.create({ + data: { + id: createId({ prefix: "msg_" }), + programId: enrollment.programId, + partnerId: partner.id, + senderPartnerId: partner.id, + text, + createdAt, + }, + include: { + senderUser: true, + senderPartner: true, + }, + }); + + // TODO: [Messages] Send email to program owner(s) and track read status + + return { message: MessageSchema.parse(message) }; + }); diff --git a/apps/web/lib/zod/schemas/messages.ts b/apps/web/lib/zod/schemas/messages.ts index 7557b3802e4..3798407bf40 100644 --- a/apps/web/lib/zod/schemas/messages.ts +++ b/apps/web/lib/zod/schemas/messages.ts @@ -93,3 +93,18 @@ export const getProgramMessagesQuerySchema = z.object({ sortBy: z.enum(["createdAt"]).default("createdAt"), sortOrder: z.enum(["asc", "desc"]).default("desc"), }); + +export const messageProgramSchema = z.object({ + programSlug: z.string(), + text: z.string(), + createdAt: z.coerce + .date() + .refine( + (date) => + date.getTime() <= Date.now() && + date.getTime() >= Date.now() - 1000 * 60, + { + message: "Message timestamp must be within the last 60 seconds", + }, + ), +}); From 306ab1c821203b26dd162d955776a72fbc90fd7a Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Thu, 4 Sep 2025 17:04:56 -0400 Subject: [PATCH 31/81] Mark as read from partner side --- .../messages/[programSlug]/page-client.tsx | 36 +++++++++---------- .../messages/[partnerId]/page-client.tsx | 4 +-- .../partners/mark-partner-messages-read.ts | 2 +- .../partners/mark-program-messages-read.ts | 32 +++++++++++++++++ 4 files changed, 52 insertions(+), 22 deletions(-) create mode 100644 apps/web/lib/actions/partners/mark-program-messages-read.ts diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx index 2a18eea54b4..83c32698e7a 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx @@ -1,11 +1,11 @@ "use client"; +import { markProgramMessagesReadAction } from "@/lib/actions/partners/mark-program-messages-read"; import { messageProgramAction } from "@/lib/actions/partners/message-program"; import { mutatePrefix } from "@/lib/swr/mutate"; import usePartnerProfile from "@/lib/swr/use-partner-profile"; import useProgramEnrollment from "@/lib/swr/use-program-enrollment"; import { useProgramMessages } from "@/lib/swr/use-program-messages"; -import useUser from "@/lib/swr/use-user"; import { useMessagesContext } from "@/ui/messages/messages-context"; import { MessagesPanel } from "@/ui/messages/messages-panel"; import { ToggleSidePanelButton } from "@/ui/messages/toggle-side-panel-button"; @@ -22,15 +22,14 @@ import { v4 as uuid } from "uuid"; export function PartnerMessagesProgramPageClient() { const { programSlug } = useParams() as { programSlug: string }; const { partner } = usePartnerProfile(); - const { user } = useUser(); const { programEnrollment, error: errorProgramEnrollment } = useProgramEnrollment(); const program = programEnrollment?.program; - // const { - // executeAsync: markPartnerMessagesRead, - // isPending: isMarkingPartnerMessagesRead, - // } = useAction(markPartnersMessagesReadAction); + const { + executeAsync: markProgramMessagesRead, + isPending: isMarkingProgramMessagesRead, + } = useAction(markProgramMessagesReadAction); const { programMessages, @@ -39,19 +38,18 @@ export function PartnerMessagesProgramPageClient() { } = useProgramMessages({ query: { programSlug, sortOrder: "asc" }, swrOpts: { - // onSuccess: (data) => { - // // Mark unread messages from the partner as read - // if ( - // !isMarkingPartnerMessagesRead && - // data?.[0]?.messages?.some( - // (message) => message.senderPartnerId && !message.readInApp, - // ) - // ) - // markPartnerMessagesRead({ - // workspaceId: workspaceId!, - // partnerId, - // }); - // }, + onSuccess: (data) => { + // Mark unread messages from the program as read + if ( + !isMarkingProgramMessagesRead && + data?.[0]?.messages?.some( + (message) => message.senderUserId && !message.readInApp, + ) + ) + markProgramMessagesRead({ + programSlug, + }); + }, }, }); const messages = programMessages?.[0]?.messages; diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx index 2abba50b23d..92fda26ad6e 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx @@ -1,6 +1,6 @@ "use client"; -import { markPartnersMessagesReadAction } from "@/lib/actions/partners/mark-partner-messages-read"; +import { markPartnerMessagesReadAction } from "@/lib/actions/partners/mark-partner-messages-read"; import { messagePartnerAction } from "@/lib/actions/partners/message-partner"; import { mutatePrefix } from "@/lib/swr/mutate"; import usePartner from "@/lib/swr/use-partner"; @@ -35,7 +35,7 @@ export function ProgramMessagesPartnerPageClient() { const { executeAsync: markPartnerMessagesRead, isPending: isMarkingPartnerMessagesRead, - } = useAction(markPartnersMessagesReadAction); + } = useAction(markPartnerMessagesReadAction); const { partnerMessages, diff --git a/apps/web/lib/actions/partners/mark-partner-messages-read.ts b/apps/web/lib/actions/partners/mark-partner-messages-read.ts index a8f47fdab94..6bfd2963181 100644 --- a/apps/web/lib/actions/partners/mark-partner-messages-read.ts +++ b/apps/web/lib/actions/partners/mark-partner-messages-read.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { authActionClient } from "../safe-action"; // Mark partner messages as read -export const markPartnersMessagesReadAction = authActionClient +export const markPartnerMessagesReadAction = authActionClient .schema(z.object({ workspaceId: z.string(), partnerId: z.string() })) .action(async ({ parsedInput, ctx }) => { const { workspace } = ctx; diff --git a/apps/web/lib/actions/partners/mark-program-messages-read.ts b/apps/web/lib/actions/partners/mark-program-messages-read.ts new file mode 100644 index 00000000000..a6f24f930ba --- /dev/null +++ b/apps/web/lib/actions/partners/mark-program-messages-read.ts @@ -0,0 +1,32 @@ +"use server"; + +import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; +import { prisma } from "@dub/prisma"; +import { z } from "zod"; +import { authPartnerActionClient } from "../safe-action"; + +// Mark program messages as read +export const markProgramMessagesReadAction = authPartnerActionClient + .schema(z.object({ programSlug: z.string() })) + .action(async ({ parsedInput, ctx }) => { + const { partner } = ctx; + const { programSlug } = parsedInput; + + const enrollment = await getProgramEnrollmentOrThrow({ + programId: programSlug, + partnerId: partner.id, + }); + + await prisma.message.updateMany({ + where: { + partnerId: partner.id, + programId: enrollment.programId, + senderUserId: { + not: null, + }, + }, + data: { + readInApp: new Date(), + }, + }); + }); From 3cfad10ec998ca7e2309ce512af7e5177db864a8 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Fri, 5 Sep 2025 10:39:04 -0400 Subject: [PATCH 32/81] Persist partner user IDs --- .../messages/[programSlug]/page-client.tsx | 14 ++++++++++---- apps/web/lib/actions/partners/message-program.ts | 3 ++- packages/prisma/schema/message.prisma | 6 +++--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx index 83c32698e7a..776e9aa6bde 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx @@ -6,6 +6,7 @@ import { mutatePrefix } from "@/lib/swr/mutate"; import usePartnerProfile from "@/lib/swr/use-partner-profile"; import useProgramEnrollment from "@/lib/swr/use-program-enrollment"; import { useProgramMessages } from "@/lib/swr/use-program-messages"; +import useUser from "@/lib/swr/use-user"; import { useMessagesContext } from "@/ui/messages/messages-context"; import { MessagesPanel } from "@/ui/messages/messages-panel"; import { ToggleSidePanelButton } from "@/ui/messages/toggle-side-panel-button"; @@ -21,6 +22,7 @@ import { v4 as uuid } from "uuid"; export function PartnerMessagesProgramPageClient() { const { programSlug } = useParams() as { programSlug: string }; + const { user } = useUser(); const { partner } = usePartnerProfile(); const { programEnrollment, error: errorProgramEnrollment } = useProgramEnrollment(); @@ -43,7 +45,7 @@ export function PartnerMessagesProgramPageClient() { if ( !isMarkingProgramMessagesRead && data?.[0]?.messages?.some( - (message) => message.senderUserId && !message.readInApp, + (message) => !message.senderPartnerId && !message.readInApp, ) ) markProgramMessagesRead({ @@ -105,7 +107,7 @@ export function PartnerMessagesProgramPageClient() {
{ - const { partner } = ctx; + const { partner, user } = ctx; const { programSlug, text, createdAt } = parsedInput; const enrollment = await getProgramEnrollmentOrThrow({ @@ -27,6 +27,7 @@ export const messageProgramAction = authPartnerActionClient programId: enrollment.programId, partnerId: partner.id, senderPartnerId: partner.id, + senderUserId: user.id, text, createdAt, }, diff --git a/packages/prisma/schema/message.prisma b/packages/prisma/schema/message.prisma index a43de7d1dde..99a74be76c8 100644 --- a/packages/prisma/schema/message.prisma +++ b/packages/prisma/schema/message.prisma @@ -4,8 +4,8 @@ model Message { programId String partnerId String - senderPartnerId String? // Populated if the sender is a partner - senderUserId String? // Populated if the sender is a program owner/user + senderUserId String + senderPartnerId String? // Populated only if the sender is a partner text String @db.Text @@ -17,8 +17,8 @@ model Message { program Program @relation(fields: [programId], references: [id], onDelete: Cascade) partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade) + senderUser User @relation(fields: [senderUserId], references: [id], onDelete: Cascade) senderPartner Partner? @relation("SenderPartner", fields: [senderPartnerId], references: [id], onDelete: Cascade) - senderUser User? @relation(fields: [senderUserId], references: [id], onDelete: Cascade) @@index(programId) @@index(partnerId) From 065895e2d600d307d108be6c4917f74156ca4e76 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Fri, 5 Sep 2025 11:00:17 -0400 Subject: [PATCH 33/81] Update mark-program-messages-read.ts --- apps/web/lib/actions/partners/mark-program-messages-read.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/lib/actions/partners/mark-program-messages-read.ts b/apps/web/lib/actions/partners/mark-program-messages-read.ts index a6f24f930ba..448cc933a26 100644 --- a/apps/web/lib/actions/partners/mark-program-messages-read.ts +++ b/apps/web/lib/actions/partners/mark-program-messages-read.ts @@ -21,9 +21,7 @@ export const markProgramMessagesReadAction = authPartnerActionClient where: { partnerId: partner.id, programId: enrollment.programId, - senderUserId: { - not: null, - }, + senderPartnerId: null, }, data: { readInApp: new Date(), From 04ad2cd2f063c442305ac9015c3a142a8098bb47 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Fri, 5 Sep 2025 11:04:40 -0400 Subject: [PATCH 34/81] Tweaks from review --- apps/web/app/(ee)/api/messages/route.ts | 17 +++++------------ .../(ee)/api/partner-profile/messages/route.ts | 8 ++++---- apps/web/lib/zod/schemas/messages.ts | 2 +- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/apps/web/app/(ee)/api/messages/route.ts b/apps/web/app/(ee)/api/messages/route.ts index ef79ee21be3..7836d86a426 100644 --- a/apps/web/app/(ee)/api/messages/route.ts +++ b/apps/web/app/(ee)/api/messages/route.ts @@ -63,10 +63,10 @@ export const GET = withWorkspace( // Sort by most recent message .sort((a, b) => sortOrder === "desc" - ? b.partner.messages[0][sortBy].getTime() - - a.partner.messages[0][sortBy].getTime() - : a.partner.messages[0][sortBy].getTime() - - b.partner.messages[0][sortBy].getTime(), + ? (b.partner.messages?.[0][sortBy].getTime() ?? 0) - + (a.partner.messages?.[0][sortBy].getTime() ?? 0) + : (a.partner.messages?.[0][sortBy].getTime() ?? 0) - + (b.partner.messages?.[0][sortBy].getTime() ?? 0), ) // Map to {partner, messages} .map(({ partner: { messages, ...partner } }) => ({ @@ -78,13 +78,6 @@ export const GET = withWorkspace( }, { requiredPermissions: ["messages.read"], - requiredPlan: [ - "business", - "business extra", - "business max", - "business plus", - "advanced", - "enterprise", - ], + requiredPlan: ["advanced", "enterprise"], }, ); diff --git a/apps/web/app/(ee)/api/partner-profile/messages/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/route.ts index 0c2ee9b196f..5505e1b9178 100644 --- a/apps/web/app/(ee)/api/partner-profile/messages/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/messages/route.ts @@ -59,10 +59,10 @@ export const GET = withPartnerProfile(async ({ partner, searchParams }) => { // Sort by most recent message .sort((a, b) => sortOrder === "desc" - ? b.program.messages[0][sortBy].getTime() - - a.program.messages[0][sortBy].getTime() - : a.program.messages[0][sortBy].getTime() - - b.program.messages[0][sortBy].getTime(), + ? (b.program.messages?.[0][sortBy].getTime() ?? 0) - + (a.program.messages?.[0][sortBy].getTime() ?? 0) + : (a.program.messages?.[0][sortBy].getTime() ?? 0) - + (b.program.messages?.[0][sortBy].getTime() ?? 0), ) // Map to {program, messages} .map(({ program: { messages, ...program } }) => ({ diff --git a/apps/web/lib/zod/schemas/messages.ts b/apps/web/lib/zod/schemas/messages.ts index 3798407bf40..bc88c1b3264 100644 --- a/apps/web/lib/zod/schemas/messages.ts +++ b/apps/web/lib/zod/schemas/messages.ts @@ -11,7 +11,7 @@ export const MessageSchema = z programId: z.string(), partnerId: z.string(), senderPartnerId: z.string().nullable(), - senderUserId: z.string().nullable(), + senderUserId: z.string(), text: z.string().min(1).max(MAX_MESSAGE_LENGTH), From e6c11bf6b575b1d1a1958662dc2123c8e7654b10 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Fri, 5 Sep 2025 11:10:42 -0400 Subject: [PATCH 35/81] Update layout.tsx --- .../(dashboard)/[slug]/(ee)/program/messages/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx index ee4dd6fadf6..0176b16c16c 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx @@ -22,7 +22,7 @@ export default function MessagesLayout({ children }: { children: ReactNode }) { return {children}; } -export function CapableLayout({ children }: { children: ReactNode }) { +function CapableLayout({ children }: { children: ReactNode }) { const { slug: workspaceSlug } = useWorkspace(); const { partnerId } = useParams() as { partnerId?: string }; From 5113717571f825962386df6b39af3cfeccd34598 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Fri, 5 Sep 2025 11:43:23 -0400 Subject: [PATCH 36/81] Add program selector --- .../(dashboard)/messages/page-client.tsx | 19 ++--- apps/web/ui/partners/program-selector.tsx | 83 +++++++++++++++++++ 2 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 apps/web/ui/partners/program-selector.tsx diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/page-client.tsx index 515b219f6c6..aecd35d3dc3 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/page-client.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/page-client.tsx @@ -1,21 +1,20 @@ "use client"; -import useWorkspace from "@/lib/swr/use-workspace"; import { useMessagesContext } from "@/ui/messages/messages-context"; +import { ProgramSelector } from "@/ui/partners/program-selector"; import { useRouterStuff } from "@dub/ui"; import { ChevronLeft, Msgs } from "@dub/ui/icons"; import { useRouter } from "next/navigation"; import { useState } from "react"; export function PartnerMessagesPageClient() { - const { slug: workspaceSlug } = useWorkspace(); const { setCurrentPanel } = useMessagesContext(); const router = useRouter(); const { searchParams, queryParams } = useRouterStuff(); // Short-lived state to display the selected program while the next page loads - const [selectedProgramId, setSelectedProgramId] = useState( + const [selectedProgramSlug, setSelectedProgramSlug] = useState( null, ); @@ -36,15 +35,13 @@ export function PartnerMessagesPageClient() {
To
- {/* TODO: [Messages] Add program selector */} - [Program Selector] - {/* { - setSelectedPartnerId(id); - router.push(`/${workspaceSlug}/program/messages/${id}`); + { + setSelectedProgramSlug(slug); + router.push(`/messages/${slug}`); }} - /> */} + />
)} diff --git a/apps/web/ui/partners/program-selector.tsx b/apps/web/ui/partners/program-selector.tsx new file mode 100644 index 00000000000..f56f09ad8d8 --- /dev/null +++ b/apps/web/ui/partners/program-selector.tsx @@ -0,0 +1,83 @@ +import useProgramEnrollments from "@/lib/swr/use-program-enrollments"; +import { Combobox } from "@dub/ui"; +import { cn, OG_AVATAR_URL } from "@dub/utils"; +import { useMemo, useState } from "react"; + +interface ProgramSelectorProps { + selectedProgramSlug: string | null; + setSelectedProgramSlug: (programSlug: string) => void; + disabled?: boolean; +} + +export function ProgramSelector({ + selectedProgramSlug, + setSelectedProgramSlug, + disabled, +}: ProgramSelectorProps) { + const [openPopover, setOpenPopover] = useState(false); + + const { programEnrollments, isLoading } = useProgramEnrollments({ + status: "approved", + }); + + const programOptions = useMemo(() => { + return programEnrollments?.map(({ program }) => ({ + value: program.slug, + label: program.name, + icon: ( + + ), + })); + }, [programEnrollments]); + + const selectedOption = useMemo(() => { + if (!selectedProgramSlug) return null; + + const program = programEnrollments?.find( + ({ program }) => program.slug === selectedProgramSlug, + )?.program; + + if (!program) return null; + + return { + value: program.slug, + label: program.name, + icon: ( + + ), + }; + }, [programEnrollments, selectedProgramSlug]); + + return ( + { + setSelectedProgramSlug(option.value); + }} + selected={selectedOption} + icon={selectedOption?.icon} + caret={true} + placeholder="Select program" + searchPlaceholder="Search program..." + matchTriggerWidth + open={openPopover} + onOpenChange={setOpenPopover} + buttonProps={{ + disabled, + className: cn( + "w-full justify-start border-neutral-300 px-3", + "data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500", + "focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none", + ), + }} + > + {selectedOption?.label} + + ); +} From 04893941f5c9efa6583a8bf358a4bfcf643c79c4 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Fri, 5 Sep 2025 12:50:23 -0400 Subject: [PATCH 37/81] Update messages.ts --- apps/web/lib/zod/schemas/messages.ts | 50 +++++++++++++--------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/apps/web/lib/zod/schemas/messages.ts b/apps/web/lib/zod/schemas/messages.ts index bc88c1b3264..5c638f3fc4b 100644 --- a/apps/web/lib/zod/schemas/messages.ts +++ b/apps/web/lib/zod/schemas/messages.ts @@ -5,36 +5,32 @@ import { UserSchema } from "./users"; export const MAX_MESSAGE_LENGTH = 2000; -export const MessageSchema = z - .object({ - id: z.string(), - programId: z.string(), - partnerId: z.string(), - senderPartnerId: z.string().nullable(), - senderUserId: z.string(), +export const MessageSchema = z.object({ + id: z.string(), + programId: z.string(), + partnerId: z.string(), + senderPartnerId: z.string().nullable(), + senderUserId: z.string(), - text: z.string().min(1).max(MAX_MESSAGE_LENGTH), + text: z.string().min(1).max(MAX_MESSAGE_LENGTH), - emailId: z.string().nullable(), - readInApp: z.date().nullable(), - readInEmail: z.date().nullable(), - createdAt: z.date(), - updatedAt: z.date(), + emailId: z.string().nullable(), + readInApp: z.date().nullable(), + readInEmail: z.date().nullable(), + createdAt: z.date(), + updatedAt: z.date(), - senderPartner: PartnerSchema.pick({ - id: true, - name: true, - image: true, - }).nullable(), - senderUser: UserSchema.pick({ - id: true, - name: true, - image: true, - }).nullable(), - }) - .refine((data) => data.senderPartnerId || data.senderUserId, { - message: "Either senderPartnerId or senderUserId must be present", - }); + senderPartner: PartnerSchema.pick({ + id: true, + name: true, + image: true, + }).nullable(), + senderUser: UserSchema.pick({ + id: true, + name: true, + image: true, + }).nullable(), +}); export const PartnerMessagesSchema = z.array( z.object({ From 687e7f421fe07ddd2490a390a44315f07d03e00d Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Fri, 5 Sep 2025 15:22:15 -0400 Subject: [PATCH 38/81] Program info panel --- .../messages/[programSlug]/page-client.tsx | 220 +++++++++++++++++- .../layout/sidebar/program-help-support.tsx | 49 +--- apps/web/ui/partners/program-help-links.tsx | 62 +++++ 3 files changed, 273 insertions(+), 58 deletions(-) create mode 100644 apps/web/ui/partners/program-help-links.tsx diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx index 776e9aa6bde..b422f990741 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx @@ -2,21 +2,37 @@ import { markProgramMessagesReadAction } from "@/lib/actions/partners/mark-program-messages-read"; import { messageProgramAction } from "@/lib/actions/partners/message-program"; +import { constructPartnerLink } from "@/lib/partners/construct-partner-link"; import { mutatePrefix } from "@/lib/swr/mutate"; +import usePartnerAnalytics from "@/lib/swr/use-partner-analytics"; +import { usePartnerEarningsTimeseries } from "@/lib/swr/use-partner-earnings-timeseries"; +import usePartnerPayoutsCount from "@/lib/swr/use-partner-payouts-count"; import usePartnerProfile from "@/lib/swr/use-partner-profile"; import useProgramEnrollment from "@/lib/swr/use-program-enrollment"; import { useProgramMessages } from "@/lib/swr/use-program-messages"; import useUser from "@/lib/swr/use-user"; +import { PayoutsCount, ProgramEnrollmentProps } from "@/lib/types"; import { useMessagesContext } from "@/ui/messages/messages-context"; import { MessagesPanel } from "@/ui/messages/messages-panel"; import { ToggleSidePanelButton } from "@/ui/messages/toggle-side-panel-button"; +import { ProgramHelpLinks } from "@/ui/partners/program-help-links"; +import { ProgramRewardList } from "@/ui/partners/program-reward-list"; import { X } from "@/ui/shared/icons"; -import { Button } from "@dub/ui"; -import { ChevronLeft, LoadingSpinner } from "@dub/ui/icons"; -import { cn } from "@dub/utils"; +import { PayoutStatus } from "@dub/prisma/client"; +import { Button, Grid, useCopyToClipboard } from "@dub/ui"; +import { Check, ChevronLeft, Copy, LoadingSpinner } from "@dub/ui/icons"; +import { + OG_AVATAR_URL, + capitalize, + cn, + currencyFormatter, + formatDate, + getPrettyUrl, +} from "@dub/utils"; import { useAction } from "next-safe-action/hooks"; +import Link from "next/link"; import { redirect, useParams } from "next/navigation"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { toast } from "sonner"; import { v4 as uuid } from "uuid"; @@ -197,11 +213,11 @@ export function PartnerMessagesProgramPageClient() {
-
+

Program @@ -218,15 +234,15 @@ export function PartnerMessagesProgramPageClient() {

-
- {program ? ( - <>{program.name} +
+ {programEnrollment && program ? ( + ) : (
@@ -238,3 +254,185 @@ export function PartnerMessagesProgramPageClient() {
); } + +function ProgramInfoPanel({ + programEnrollment, +}: { + programEnrollment: ProgramEnrollmentProps; +}) { + const program = programEnrollment.program; + const partnerLink = constructPartnerLink({ + program, + linkKey: programEnrollment.links?.[0]?.key, + }); + + const { data: statsTotals } = usePartnerAnalytics({ + event: "composite", + interval: "all", + }); + + const { data: earningsTimeseries } = usePartnerEarningsTimeseries({ + interval: "all", + }); + + const totalEarnings = useMemo( + () => earningsTimeseries?.reduce((acc, { earnings }) => acc + earnings, 0), + [earningsTimeseries], + ); + + const { payoutsCount } = usePartnerPayoutsCount({ + groupBy: "status", + }); + + const totalPayouts = useMemo( + () => + payoutsCount + ?.filter( + (payout) => + payout.status === PayoutStatus.processed || + payout.status === PayoutStatus.sent || + payout.status === PayoutStatus.completed, + ) + ?.reduce((acc, p) => acc + p.amount, 0) ?? 0, + [payoutsCount], + ); + + const [copied, copyToClipboard] = useCopyToClipboard(); + + return ( + <> + {/* Program info */} +
+
+ +
+
+ {`${program.name} +
+ + {program.name} + + + Partner since {formatDate(programEnrollment.createdAt)} + +
+
+
+ + {/* Referral link */} +
+
+

+ Referral link +

+ + View all + +
+ + + +
+ } + text={copied ? "Copied link" : "Copy link"} + className="mt-3 h-8 rounded-lg" + onClick={() => copyToClipboard(partnerLink)} + /> +
+ + {/* Rewards */} +
+

Rewards

+ +
+ + {/* Stats */} +
+

Stats

+
+
+ {["clicks", "leads", "sales"].map((event) => ( +
+ + {capitalize(event)} + + {statsTotals ? ( + + {statsTotals?.[event]} + + ) : ( +
+ )} +
+ ))} +
+
+ {[ + { label: "Earnings", value: totalEarnings }, + { label: "Payouts", value: totalPayouts }, + ].map(({ label, value }) => ( +
+ + {label} + + {value !== undefined ? ( + + {currencyFormatter(value / 100)} + + ) : ( +
+ )} +
+ ))} +
+
+
+ + {/* Help & support */} +
+

+ Help and support +

+
+ +
+
+ + ); +} diff --git a/apps/web/ui/layout/sidebar/program-help-support.tsx b/apps/web/ui/layout/sidebar/program-help-support.tsx index 1d06c0a855e..b3979cb7f3a 100644 --- a/apps/web/ui/layout/sidebar/program-help-support.tsx +++ b/apps/web/ui/layout/sidebar/program-help-support.tsx @@ -1,7 +1,7 @@ "use client"; import useProgramEnrollment from "@/lib/swr/use-program-enrollment"; -import { BookOpen, EnvelopeArrowRight, Page2 } from "@dub/ui"; +import { ProgramHelpLinks } from "@/ui/partners/program-help-links"; import { memo } from "react"; export const ProgramHelpSupport = memo(() => { @@ -11,58 +11,13 @@ export const ProgramHelpSupport = memo(() => { const { program } = programEnrollment; - const supportItems = [ - ...(program.supportEmail - ? [ - { - icon: EnvelopeArrowRight, - label: "Email support", - href: `mailto:${program.supportEmail}`, - }, - ] - : []), - ...(program.helpUrl - ? [ - { - icon: BookOpen, - label: "Help center", - href: program.helpUrl, - }, - ] - : []), - ...(program.termsUrl - ? [ - { - icon: Page2, - label: "Terms of service", - href: program.termsUrl, - }, - ] - : []), - ]; - - if (supportItems.length === 0) return null; - return (
{program.name.length <= 12 ? `${program.name} ` : ""} Program Support
-
- {supportItems.map(({ icon: Icon, label, href }) => ( - - - {label} - - ))} -
+
); }); diff --git a/apps/web/ui/partners/program-help-links.tsx b/apps/web/ui/partners/program-help-links.tsx new file mode 100644 index 00000000000..5c8b397b2ed --- /dev/null +++ b/apps/web/ui/partners/program-help-links.tsx @@ -0,0 +1,62 @@ +"use client"; + +import useProgramEnrollment from "@/lib/swr/use-program-enrollment"; +import { BookOpen, EnvelopeArrowRight, Page2 } from "@dub/ui"; +import { memo } from "react"; + +export const ProgramHelpLinks = memo(() => { + const { programEnrollment } = useProgramEnrollment(); + + if (!programEnrollment?.program) return null; + + const { program } = programEnrollment; + + const supportItems = [ + ...(program.supportEmail + ? [ + { + icon: EnvelopeArrowRight, + label: "Email support", + href: `mailto:${program.supportEmail}`, + }, + ] + : []), + ...(program.helpUrl + ? [ + { + icon: BookOpen, + label: "Help center", + href: program.helpUrl, + }, + ] + : []), + ...(program.termsUrl + ? [ + { + icon: Page2, + label: "Terms of service", + href: program.termsUrl, + }, + ] + : []), + ]; + + if (supportItems.length === 0) return null; + + return ( +
+ {supportItems.map(({ icon: Icon, label, href }) => ( + + + {label} + + ))} +
+ ); +}); From e04059aeaecfb0b80cab6e9a7b1f72f3a0613d47 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Fri, 5 Sep 2025 15:29:25 -0400 Subject: [PATCH 39/81] Update page-client.tsx --- .../(dashboard)/messages/[programSlug]/page-client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx index b422f990741..c2a1450abdc 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx @@ -302,7 +302,7 @@ function ProgramInfoPanel({ return ( <> {/* Program info */} -
+
From 991da0b0c284abad6b2cf1e9433a45660f686a1c Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Fri, 5 Sep 2025 16:27:40 -0400 Subject: [PATCH 40/81] Add email templates --- .../templates/new-message-from-partner.tsx | 129 +++++++++++++++ .../templates/new-message-from-program.tsx | 154 ++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 packages/email/src/templates/new-message-from-partner.tsx create mode 100644 packages/email/src/templates/new-message-from-program.tsx diff --git a/packages/email/src/templates/new-message-from-partner.tsx b/packages/email/src/templates/new-message-from-partner.tsx new file mode 100644 index 00000000000..e901c532713 --- /dev/null +++ b/packages/email/src/templates/new-message-from-partner.tsx @@ -0,0 +1,129 @@ +import { DUB_WORDMARK, OG_AVATAR_URL } from "@dub/utils"; +import { + Body, + Column, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Row, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import { Footer } from "../components/footer"; + +const MAX_DISPLAYED_MESSAGES = 3; + +export default function NewMessageFromPartner({ + workspaceSlug = "acme", + partner = { + id: "pn_xxx", + name: "Marvin Ta", + image: null, + }, + messages = [ + { + text: "Am I eligible for that one bounty?", + createdAt: new Date(Date.now() - 1000 * 60 * 5), + }, + // { + // text: "Pls respond", + // createdAt: new Date(), + // }, + // { + // text: "Pls respond", + // createdAt: new Date(), + // }, + // { + // text: "Pls respond", + // createdAt: new Date(), + // }, + // { + // text: "Pls respond", + // createdAt: new Date(), + // }, + ], + email = "panic@thedis.co", +}: { + workspaceSlug: string; + partner: { + id: string; + name: string; + image: string | null; + }; + messages: { + text: string; + createdAt: Date; + }[]; + email: string; +}) { + return ( + + + Bounty completed + + + +
+ Dub +
+ +
+ + {messages.length > 1 + ? `${messages.length} new messages` + : "New message"}{" "} + from {partner.name} + + + View profile in Dub + +
+ +
+ {messages.slice(0, MAX_DISPLAYED_MESSAGES).map((message, idx) => ( + 0 ? "pt-3" : ""}> + + {partner.name} + + + + {message.text} + + + + ))} + {messages.length > MAX_DISPLAYED_MESSAGES && ( + + {messages.length - MAX_DISPLAYED_MESSAGES} more messages from{" "} + {partner.name} + + )} + + View in Dub + +
+ +