From 4142e9607560595a6e6c9eb54dea1d707e817d6d Mon Sep 17 00:00:00 2001 From: DIodide Date: Wed, 25 Feb 2026 13:38:17 -0500 Subject: [PATCH 1/2] messages --- src/app/api/webhooks/stripe/route.ts | 81 +- src/app/dashboard/coaches/client-layout.tsx | 2 +- src/app/dashboard/coaches/messages/page.tsx | 989 +++++---------- src/app/dashboard/player/messages/page.tsx | 1098 +++++------------ src/components/messaging/ChatHeader.tsx | 69 ++ src/components/messaging/ConversationItem.tsx | 99 ++ src/components/messaging/ConversationList.tsx | 208 ++++ src/components/messaging/EmptyChatState.tsx | 42 + src/components/messaging/MessageBubble.tsx | 42 + src/components/messaging/MessageInput.tsx | 55 + src/components/messaging/MessageThread.tsx | 58 + src/components/messaging/MessagingPaywall.tsx | 41 + .../messaging/NewConversationDialog.tsx | 219 ++++ src/components/messaging/index.ts | 19 + src/components/messaging/types.ts | 35 + src/components/messaging/utils.ts | 31 + src/hooks/use-messaging.ts | 195 +++ src/lib/server/entitlements.ts | 1 + src/server/api/routers/messages.ts | 317 +++-- 19 files changed, 2041 insertions(+), 1560 deletions(-) create mode 100644 src/components/messaging/ChatHeader.tsx create mode 100644 src/components/messaging/ConversationItem.tsx create mode 100644 src/components/messaging/ConversationList.tsx create mode 100644 src/components/messaging/EmptyChatState.tsx create mode 100644 src/components/messaging/MessageBubble.tsx create mode 100644 src/components/messaging/MessageInput.tsx create mode 100644 src/components/messaging/MessageThread.tsx create mode 100644 src/components/messaging/MessagingPaywall.tsx create mode 100644 src/components/messaging/NewConversationDialog.tsx create mode 100644 src/components/messaging/index.ts create mode 100644 src/components/messaging/types.ts create mode 100644 src/components/messaging/utils.ts create mode 100644 src/hooks/use-messaging.ts diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts index f8e1998..56e26b3 100644 --- a/src/app/api/webhooks/stripe/route.ts +++ b/src/app/api/webhooks/stripe/route.ts @@ -1,5 +1,10 @@ import { env } from "@/env"; -import { revokeSubscriptionEntitlements } from "@/lib/server/entitlements"; +import { + FEATURE_KEYS, + grantEntitlement, + revokeSubscriptionEntitlements, + type FeatureKey, +} from "@/lib/server/entitlements"; import { stripe } from "@/lib/server/stripe"; import { syncPurchaseFromCheckoutSession, @@ -11,6 +16,18 @@ import { headers } from "next/headers"; import type { NextRequest } from "next/server"; import type Stripe from "stripe"; +const EVAL_PLUS_PRICE_IDS = [ + process.env.NEXT_PUBLIC_STRIPE_GOLD_PRICE_ID, + process.env.NEXT_PUBLIC_STRIPE_PLATINUM_PRICE_ID, +].filter(Boolean) as string[]; + +const EVAL_PLUS_FEATURES = [ + FEATURE_KEYS.DIRECT_MESSAGING, + FEATURE_KEYS.UNLIMITED_MESSAGES, + FEATURE_KEYS.PREMIUM_SEARCH, + FEATURE_KEYS.ADVANCED_ANALYTICS, +] as const; + export async function POST(req: NextRequest) { const body = await req.text(); const headersList = await headers(); @@ -61,16 +78,28 @@ export async function POST(req: NextRequest) { if (customer) { const priceId = subscription.items.data[0]?.price.id; - // Map price IDs to features - customize based on your pricing plans - // This is a placeholder - implement your actual feature mapping - if (priceId) { - // Example: Grant premium features for premium subscription - // await grantEntitlement( - // customer.clerk_user_id, - // FEATURE_KEYS.PREMIUM_SEARCH, - // "SUBSCRIPTION", - // { subscriptionId: subscription.id } - // ); + const isActive = + subscription.status === "active" || + subscription.status === "trialing"; + + if (priceId && isActive && EVAL_PLUS_PRICE_IDS.includes(priceId)) { + const dbSub = await db.subscription.findUnique({ + where: { stripe_subscription_id: subscription.id }, + select: { id: true }, + }); + + for (const featureKey of EVAL_PLUS_FEATURES) { + await grantEntitlement( + customer.clerk_user_id, + featureKey, + "SUBSCRIPTION", + { subscriptionId: dbSub?.id }, + ); + } + + console.log( + `[STRIPE WEBHOOK] Granted EVAL+ features to ${customer.clerk_user_id}`, + ); } } break; @@ -105,20 +134,22 @@ export async function POST(req: NextRequest) { where: { stripe_payment_intent_id: paymentIntent.id }, }); - if (purchase) { - // Map product types to features - customize based on your products - // Example: if product_type is "FEATURE_UNLOCK", grant the feature - if ( - purchase.product_type === "FEATURE_UNLOCK" && - purchase.product_id - ) { - // await grantEntitlement( - // customer.clerk_user_id, - // purchase.product_id as FeatureKey, - // "PURCHASE", - // { purchaseId: purchase.id } - // ); - } + const validFeatureKeys = new Set(Object.values(FEATURE_KEYS)); + if ( + purchase && + purchase.product_type === "FEATURE_UNLOCK" && + purchase.product_id && + validFeatureKeys.has(purchase.product_id) + ) { + await grantEntitlement( + customer.clerk_user_id, + purchase.product_id as FeatureKey, + "PURCHASE", + { purchaseId: purchase.id }, + ); + console.log( + `[STRIPE WEBHOOK] Granted ${purchase.product_id} to ${customer.clerk_user_id} via purchase`, + ); } } break; diff --git a/src/app/dashboard/coaches/client-layout.tsx b/src/app/dashboard/coaches/client-layout.tsx index b1788dc..2ab0f64 100644 --- a/src/app/dashboard/coaches/client-layout.tsx +++ b/src/app/dashboard/coaches/client-layout.tsx @@ -372,7 +372,7 @@ export function CoachesDashboardClientLayout({ {/* Content area */} -
{children}
+
{children}
); diff --git a/src/app/dashboard/coaches/messages/page.tsx b/src/app/dashboard/coaches/messages/page.tsx index 2510cba..d3dd546 100644 --- a/src/app/dashboard/coaches/messages/page.tsx +++ b/src/app/dashboard/coaches/messages/page.tsx @@ -1,20 +1,12 @@ "use client"; -import { useState } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useState, useEffect, useRef } from "react"; +import { format } from "date-fns"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { Badge } from "@/components/ui/badge"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; import { Select, SelectContent, @@ -23,746 +15,387 @@ import { SelectValue, } from "@/components/ui/select"; import { - MessageSquareIcon, + MessageCircleIcon, SearchIcon, PlusIcon, - SendIcon, FilterIcon, - CheckIcon, - MoreVerticalIcon, - StarIcon, LoaderIcon, + MailIcon, + SendIcon, + StarIcon, + MoreVerticalIcon, + MessageSquareIcon, + CheckIcon, } from "lucide-react"; -import { format } from "date-fns"; import { cn } from "@/lib/utils"; +import { NewConversationDialog, MessagingPaywall } from "@/components/messaging"; +import type { FilterStatus, MessageItem, CoachConversationDetail, CoachConversation } from "@/components/messaging/types"; +import { formatLastSeen, getInitials, getGameIcon } from "@/components/messaging/utils"; +import { + useConversations, + useSendMessage, + useMarkAsRead, + useToggleStar, + useMessagingAccess, +} from "@/hooks/use-messaging"; import { api } from "@/trpc/react"; import { toast } from "sonner"; -// Types based on tRPC API responses -interface Player { - id: string; - name: string; - email: string; - avatar?: string | null; - school?: string | null; - classYear?: string | null; - location?: string | null; - gpa?: number | null; - mainGame?: string; - gameProfiles: Array<{ - game: string; - rank?: string | null; - role?: string | null; - username: string; - }>; -} - -interface Message { - id: string; - senderId: string; - senderType: "COACH" | "PLAYER"; - content: string; - timestamp: Date; - isRead: boolean; -} - -interface Conversation { - id: string; - player: Player; - lastMessage: { - id: string; - content: string; - senderType: "COACH" | "PLAYER"; - timestamp: Date; - isRead: boolean; - } | null; - unreadCount: number; - isStarred: boolean; - isArchived: boolean; - updatedAt: Date; -} - export default function CoachMessagesPage() { - const [selectedConversationId, setSelectedConversationId] = useState< - string | null - >(null); - const [newMessageContent, setNewMessageContent] = useState(""); - const [searchQuery, setSearchQuery] = useState(""); - const [filterStatus, setFilterStatus] = useState< - "all" | "unread" | "starred" | "archived" - >("all"); - const [newConversationOpen, setNewConversationOpen] = useState(false); - const [selectedPlayers, setSelectedPlayers] = useState([]); - const [messageTemplate, setMessageTemplate] = useState(""); - - // tRPC queries and mutations - const { data: conversationsData, isLoading: conversationsLoading } = - api.messages.getConversations.useQuery({ - search: searchQuery, - filter: filterStatus, - limit: 50, - }); - - const { data: selectedConversation, isLoading: conversationLoading } = - api.messages.getConversation.useQuery( - { conversationId: selectedConversationId! }, - { enabled: !!selectedConversationId }, - ); - - const { data: availablePlayers, isLoading: playersLoading } = - api.messages.getAvailablePlayers.useQuery({ - limit: 50, - }); + const [selectedId, setSelectedId] = useState(null); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + const [newConvOpen, setNewConvOpen] = useState(false); + const [draft, setDraft] = useState(""); + const scrollRef = useRef(null); + + const accessQuery = useMessagingAccess(); + const hasAccess = accessQuery.data?.hasAccess ?? false; + + const listQuery = useConversations("coach", { search, filter }); + const detailQuery = api.messages.getConversation.useQuery( + { conversationId: selectedId! }, + { enabled: !!selectedId, refetchInterval: 2_000 }, + ); - const sendMessageMutation = api.messages.sendMessage.useMutation({ - onSuccess: () => { - setNewMessageContent(""); - toast.success("Message sent successfully!"); - }, - onError: (error) => { - toast.error("Failed to send message: " + error.message); - }, - }); + const sendMutation = useSendMessage("coach", selectedId); + const markReadMutation = useMarkAsRead("coach"); + const starMutation = useToggleStar("coach"); - const sendBulkMessageMutation = api.messages.sendBulkMessage.useMutation({ + const sendNewMutation = api.messages.sendMessage.useMutation({ onSuccess: (data) => { - toast.success(`Successfully sent ${data.messagesSent} messages`); - setSelectedPlayers([]); - setMessageTemplate(""); - setNewConversationOpen(false); - }, - onError: (error) => { - toast.error("Failed to send messages: " + error.message); - }, - }); - - const markAsReadMutation = api.messages.markAsRead.useMutation({ - onSuccess: () => { - toast.success("Messages marked as read"); + setNewConvOpen(false); + setSelectedId(data.conversationId); + toast.success("Message sent!"); }, + onError: (err) => { toast.error(err.message); }, }); - const toggleStarMutation = api.messages.toggleStar.useMutation({ - onSuccess: () => { - toast.success("Conversation starred"); - }, - }); - - // Use actual data from tRPC queries - const conversations: Conversation[] = conversationsData?.conversations ?? []; - const players: Player[] = availablePlayers ?? []; - - const handleSendMessage = () => { - if (!newMessageContent.trim() || !selectedConversationId) return; - - sendMessageMutation.mutate({ - conversationId: selectedConversationId, - content: newMessageContent, - }); - }; - - const handleStartNewConversation = () => { - if (selectedPlayers.length === 0 || !messageTemplate.trim()) return; - - sendBulkMessageMutation.mutate({ - playerIds: selectedPlayers.map((p) => p.id), - content: messageTemplate, - }); - }; - - const handleConversationSelect = (conversation: Conversation) => { - setSelectedConversationId(conversation.id); + const conversations = (listQuery.data?.conversations ?? []) as CoachConversation[]; + const detail = detailQuery.data as CoachConversationDetail | undefined; + const messages = (detail?.messages ?? []) as MessageItem[]; - // Mark unread messages as read - if (conversation.unreadCount > 0) { - markAsReadMutation.mutate({ - conversationId: conversation.id, - }); - } - }; + useEffect(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [messages.length]); - const handleToggleStar = (conversationId: string, e: React.MouseEvent) => { - e.stopPropagation(); - toggleStarMutation.mutate({ conversationId }); + const handleSelect = (id: string, unread: number) => { + setSelectedId(id); + if (unread > 0) markReadMutation.mutate({ conversationId: id }); }; - const getGameIcon = (game: string) => { - const icons: Record = { - VALORANT: "๐ŸŽฏ", - "Overwatch 2": "โšก", - "Rocket League": "๐Ÿš€", - "League of Legends": "โš”๏ธ", - "Super Smash Bros. Ultimate": "๐ŸฅŠ", - }; - return icons[game] ?? "๐ŸŽฎ"; - }; - - const formatLastSeen = (date: Date) => { - const now = new Date(); - const diffInMinutes = Math.floor( - (now.getTime() - date.getTime()) / (1000 * 60), + const handleSend = () => { + if (!selectedId || !draft.trim()) return; + sendMutation.mutate( + { conversationId: selectedId, content: draft }, + { onError: (err) => { toast.error(err.message); } }, ); - - if (diffInMinutes < 1) return "Just now"; - if (diffInMinutes < 60) return `${diffInMinutes}m ago`; - if (diffInMinutes < 1440) return `${Math.floor(diffInMinutes / 60)}h ago`; - return format(date, "MMM d"); + setDraft(""); }; return ( -
- {/* Page Header */} -
-

- Messages -

-

- Communicate with prospective players -

+
+ {/* Header */} +
+
+ +
+
+

Messages

+

+ Communicate with prospective players +

+
- {/* Messages Layout */} -
- {/* Conversations List */} - - + {/* Main chat layout */} +
+ {/* Sidebar โ€“ conversation list */} +
+ {/* Sidebar header */} +
- +

+ Conversations - - +

+ {conversations.length}
+
+ + setSearch(e.target.value)} + placeholder="Search..." + className="h-8 border-gray-700 bg-gray-800/60 pl-9 text-sm text-white placeholder-gray-500 focus:border-cyan-500" + /> +
+
+ + +
+
- {/* Search and Filter */} -
-
- - setSearchQuery(e.target.value)} - placeholder="Search conversations..." - className="border-gray-700 bg-gray-800 pl-10 text-white placeholder-gray-400" - /> + {/* Conversation items */} +
+ {listQuery.isLoading ? ( +
+
- -
- - - - - - - - - - Start New Conversation - - - Select players to message and compose your message - - - -
- {/* Player Selection */} -
-

- Select Players -

- {playersLoading ? ( -
- -
- ) : ( -
- {players.map((player) => ( -
{ - setSelectedPlayers((prev) => - prev.includes(player) - ? prev.filter((p) => p.id !== player.id) - : [...prev, player], - ); - }} - > -
- - - - {player.name - .split(" ") - .map((n) => n[0]) - .join("")} - - -
-

- {player.name} -

-
- {player.mainGame && ( - <> - - {getGameIcon(player.mainGame)} - - {player.mainGame} - - )} - {player.school && ( - <> - โ€ข - {player.school} - - )} -
-
-
- {selectedPlayers.includes(player) && ( - - )} -
- ))} -
- )} - {selectedPlayers.length > 0 && ( -
-

- Selected players ({selectedPlayers.length}): -

-
- {selectedPlayers.map((player) => ( - - {player.name} - - ))} -
-
- )} -
- - {/* Message Template */} -
-

- Compose Message -

-