diff --git a/package.json b/package.json index ce70b6c..ebde78c 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "next dev", + "prebuild": "npx prisma generate", "build": "next build", "start": "next start", "lint": "eslint", diff --git a/prisma/migrations/20260402124250_chat_message_typed_attachments/migration.sql b/prisma/migrations/20260402124250_chat_message_typed_attachments/migration.sql new file mode 100644 index 0000000..d4bea83 --- /dev/null +++ b/prisma/migrations/20260402124250_chat_message_typed_attachments/migration.sql @@ -0,0 +1,14 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'ChatMessageKind') THEN + CREATE TYPE "ChatMessageKind" AS ENUM ('TEXT', 'PRODUCT', 'ORDER'); + END IF; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +ALTER TABLE "ChatMessage" + ADD COLUMN IF NOT EXISTS "kind" "ChatMessageKind" NOT NULL DEFAULT 'TEXT'; + +ALTER TABLE "ChatMessage" + ADD COLUMN IF NOT EXISTS "attachment" JSONB; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c214647..353ce93 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -12,6 +12,12 @@ enum UserRole { ADMIN } +enum ChatMessageKind { + TEXT + PRODUCT + ORDER +} + model User { id String @id @default(cuid()) name String? @@ -564,7 +570,9 @@ model ChatMessage { id String @id @default(cuid()) senderId String receiverId String + kind ChatMessageKind @default(TEXT) content String @map("encryptedContent") @db.Text + attachment Json? sender User @relation("SentMessages", fields: [senderId], references: [id]) receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id]) diff --git a/src/app/api/chat/attach-orders/route.ts b/src/app/api/chat/attach-orders/route.ts index 77b0c58..834d6c4 100644 --- a/src/app/api/chat/attach-orders/route.ts +++ b/src/app/api/chat/attach-orders/route.ts @@ -37,10 +37,41 @@ export async function GET() { type OrderItem = { id: number name: string + orderNumber?: string total_price: string currency?: string storeId: string storeName: string + customerName?: string | null + primaryImageUrl?: string | null + } + + const extractCustomerName = (customer: unknown): string | null => { + const c = customer as any + if (!c || typeof c !== "object") return null + + const first = typeof c.first_name === "string" ? c.first_name.trim() : "" + const last = typeof c.last_name === "string" ? c.last_name.trim() : "" + const email = typeof c.email === "string" ? c.email.trim() : "" + + const combined = [first, last].filter(Boolean).join(" ").trim() + if (combined) return combined + if (email) return email + return null + } + + const extractPrimaryImageUrl = (lineItems: unknown): string | null => { + if (!Array.isArray(lineItems) || lineItems.length === 0) return null + const first = lineItems[0] as any + + return ( + first?.image?.src || + first?.image?.url || + first?.variant?.image?.src || + first?.variant?.image?.url || + first?.variant?.image_url || + null + ) } const fetchOrdersForStore = async ( @@ -57,10 +88,13 @@ export async function GET() { return cached.slice(0, PER_STORE_LIMIT).map((order: CachedOrderRow) => ({ id: parseInt(order.shopifyOrderId), name: order.orderNumber, + orderNumber: order.orderNumber, total_price: order.totalPrice, currency: order.currency || undefined, storeId: store.id, - storeName + storeName, + customerName: extractCustomerName(order.customer), + primaryImageUrl: extractPrimaryImageUrl(order.lineItems) })) } @@ -84,13 +118,16 @@ export async function GET() { if (data.orders?.length) { await updateOrderCache(store.id, data.orders).catch(() => {}) } - return (data.orders || []).map((o: { id: number; name?: string; total_price?: string; currency?: string }) => ({ + return (data.orders || []).map((o: any) => ({ id: o.id, name: o.name || `#${o.id}`, + orderNumber: typeof o.name === "string" ? o.name : `#${o.id}`, total_price: o.total_price || "0", currency: o.currency, storeId: store.id, - storeName + storeName, + customerName: extractCustomerName(o.customer), + primaryImageUrl: extractPrimaryImageUrl(o.line_items) })) } catch { return [] diff --git a/src/app/api/chat/attach-products/route.ts b/src/app/api/chat/attach-products/route.ts index cea7f4d..a82ef32 100644 --- a/src/app/api/chat/attach-products/route.ts +++ b/src/app/api/chat/attach-products/route.ts @@ -34,7 +34,20 @@ export async function GET() { type StoreRow = (typeof stores)[number] - type ProductItem = { id: number; title: string; storeId: string; storeName: string } + type ProductItem = { + id: number + title: string + storeId: string + storeName: string + imageUrl?: string | null + description?: string | null + } + + const extractImageUrl = (images: unknown): string | null => { + if (!Array.isArray(images) || images.length === 0) return null + const first = images[0] as any + return first?.src || first?.url || null + } const fetchProductsForStore = async ( store: { id: string; businessName: string | null; shopifyDomain: string | null; shopifyAccessToken: string | null } @@ -51,7 +64,9 @@ export async function GET() { id: parseInt(product.shopifyProductId), title: product.title || "Product", storeId: store.id, - storeName + storeName, + imageUrl: extractImageUrl(product.images), + description: null })) } @@ -75,11 +90,13 @@ export async function GET() { if (data.products?.length) { await updateProductCache(store.id, data.products).catch(() => {}) } - return (data.products || []).map((p: { id: number; title?: string }) => ({ + return (data.products || []).map((p: any) => ({ id: p.id, title: p.title || "Product", storeId: store.id, - storeName + storeName, + imageUrl: p.images?.[0]?.src || p.images?.[0]?.url || null, + description: p.body_html || p.description || null })) } catch { return [] diff --git a/src/app/api/chat/messages/route.ts b/src/app/api/chat/messages/route.ts index 1a5d4ea..c7fdc8b 100644 --- a/src/app/api/chat/messages/route.ts +++ b/src/app/api/chat/messages/route.ts @@ -10,18 +10,33 @@ export async function POST(req: Request) { return new NextResponse("Unauthorized", { status: 401 }) } - const { receiverId, content } = await req.json() + const { receiverId, content, kind, attachment } = await req.json() - if (!receiverId || !content || typeof content !== "string") { + if (!receiverId || typeof receiverId !== "string") { return new NextResponse("Missing fields", { status: 400 }) } + const CHAT_KINDS = ["TEXT", "PRODUCT", "ORDER"] as const + const normalizedKind = typeof kind === "string" && (CHAT_KINDS as readonly string[]).includes(kind) ? kind : "TEXT" + + if (normalizedKind === "TEXT") { + if (content != null && typeof content !== "string") { + return new NextResponse("Invalid content", { status: 400 }) + } + } else { + if (!attachment || typeof attachment !== "object") { + return new NextResponse("Missing attachment for typed message", { status: 400 }) + } + } + const message = await prisma.chatMessage.create({ data: { senderId: session.user.id, receiverId, - content, - } + kind: normalizedKind as any, + content: (typeof content === "string" ? content : "") as any, + attachment: (normalizedKind === "TEXT" ? null : (attachment as any)) as any, + } as any }) // Send push notification to receiver diff --git a/src/components/chat/ChatAttachmentModal.tsx b/src/components/chat/ChatAttachmentModal.tsx new file mode 100644 index 0000000..f2040eb --- /dev/null +++ b/src/components/chat/ChatAttachmentModal.tsx @@ -0,0 +1,155 @@ +"use client" + +import { cn } from "@/lib/utils" +import { Package, ShoppingCart, X } from "lucide-react" + +export type ChatAttachmentKind = "PRODUCT" | "ORDER" + +interface ChatAttachmentModalProps { + isOpen: boolean + onClose: () => void + kind: ChatAttachmentKind | null + attachment: any + isLight: boolean +} + +export function ChatAttachmentModal({ + isOpen, + onClose, + kind, + attachment, + isLight, +}: ChatAttachmentModalProps) { + if (!isOpen || !kind || !attachment) return null + + const title = + kind === "PRODUCT" + ? attachment.title || "Attached product" + : `Order ${attachment.orderNumber || ""}`.trim() + + return ( +
{ + if (e.target === e.currentTarget) onClose() + }} + > +
+
+
+
+
+ {kind === "PRODUCT" ? : } +
+
+

+ {kind === "PRODUCT" ? "Product" : "Order"} +

+

{title}

+
+
+ +
+ +
+ {kind === "PRODUCT" ? ( +
+
+ {attachment.imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {attachment.title + ) : null} +
+
+
+ {attachment.title || "Product"} +
+ {attachment.storeName ? ( +
+ {attachment.storeName} +
+ ) : null} + {attachment.description ? ( +

+ {attachment.description} +

+ ) : ( +

+ No description provided. +

+ )} +
+
+ ) : ( +
+
+ {attachment.primaryImageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + Order image + ) : null} +
+
+
+ Order {attachment.orderNumber || ""} +
+ {attachment.customerName ? ( +
+ Customer: {attachment.customerName} +
+ ) : null} + {attachment.total_price ? ( +
+ {attachment.currency ? `${attachment.currency} ` : ""} + {attachment.total_price} +
+ ) : null} +
+
+ )} +
+
+
+
+ ) +} + diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index b31e144..ece783d 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -1,6 +1,7 @@ "use client" import { useState, useEffect, useRef } from "react" +import { createPortal } from "react-dom" import { usePathname } from "next/navigation" import { useSession } from "next-auth/react" import { useTheme } from "@/components/ThemeProvider" @@ -33,6 +34,8 @@ interface Message { senderId: string receiverId: string content: string + kind?: "TEXT" | "PRODUCT" | "ORDER" | string | null + attachment?: any createdAt: string } @@ -57,8 +60,25 @@ function AlertCircle(props: React.SVGProps) { ) } -type AttachOrder = { id: number; name: string; total_price: string; currency?: string; storeId?: string; storeName?: string } -type AttachProduct = { id: number | string; title: string; storeId?: string; storeName?: string } +type AttachOrder = { + id: number + name: string + orderNumber?: string + total_price: string + currency?: string + storeId?: string + storeName?: string + customerName?: string | null + primaryImageUrl?: string | null +} +type AttachProduct = { + id: number | string + title: string + storeId?: string + storeName?: string + imageUrl?: string | null + description?: string | null +} export function ChatView() { const pathname = usePathname() @@ -71,6 +91,12 @@ export function ChatView() { const [selectedContact, setSelectedContact] = useState(null) const [messages, setMessages] = useState([]) const [newMessage, setNewMessage] = useState("") + const [draftKind, setDraftKind] = useState<"PRODUCT" | "ORDER" | null>(null) + const [draftAttachment, setDraftAttachment] = useState(null) + + const [attachmentModalOpen, setAttachmentModalOpen] = useState(false) + const [activeAttachmentKind, setActiveAttachmentKind] = useState<"PRODUCT" | "ORDER" | null>(null) + const [activeAttachment, setActiveAttachment] = useState(null) const [loadingContacts, setLoadingContacts] = useState(true) const [loadingMessages, setLoadingMessages] = useState(false) const [sending, setSending] = useState(false) @@ -115,14 +141,16 @@ export function ChatView() { const res = await fetch(`/api/chat/messages?contactId=${contact.id}`) if (res.ok) { const data = await res.json() - const plainMessages = data.messages.map((m: { id: string; senderId: string; receiverId: string; content: string; createdAt: string }) => ({ + const typedMessages = data.messages.map((m: any) => ({ id: m.id, senderId: m.senderId, receiverId: m.receiverId, - content: m.content, + content: m.content ?? "", + kind: m.kind ?? "TEXT", + attachment: m.attachment ?? null, createdAt: m.createdAt })) - setMessages(plainMessages) + setMessages(typedMessages) } else if (!silent) { setMessagesError("Failed to load messages. Please try again.") } @@ -182,9 +210,12 @@ export function ChatView() { const res = await fetch("/api/supplier/products") if (res.ok) { const data = await res.json() - const list = (data.products || []).map((p: { id: string; title?: string; shopifyProductTitle?: string }) => ({ + const list = (data.products || []).map((p: any) => ({ id: p.id, - title: p.title || p.shopifyProductTitle || "Product" + title: p.title || p.shopifyProductTitle || "Product", + imageUrl: p.imageUrl ?? p.image_url ?? null, + description: p.description ?? null, + storeName: p.vendor?.businessName ?? null })) setAttachProducts(list) } @@ -210,33 +241,57 @@ export function ChatView() { } const insertOrder = (order: AttachOrder) => { - const storePart = order.storeName ? ` · ${order.storeName}` : "" - const line = `[Order ${order.name}${storePart} - ${order.currency || ""} ${order.total_price}]\n` - setNewMessage(prev => (prev ? prev + line : line.trim())) + setDraftKind("ORDER") + setDraftAttachment({ + orderNumber: order.orderNumber ?? order.name, + customerName: order.customerName ?? null, + primaryImageUrl: order.primaryImageUrl ?? null, + total_price: order.total_price, + currency: order.currency ?? null, + storeName: order.storeName ?? null, + shopifyOrderId: order.id + }) + const summary = `Order ${order.orderNumber ?? order.name}${order.customerName ? ` - ${order.customerName}` : ""}${order.storeName ? ` · ${order.storeName}` : ""}` + setNewMessage(prev => (prev.trim() ? `${prev.trim()}\n${summary}` : summary)) setAttachOpen(false) } const insertProduct = (product: AttachProduct) => { - const storePart = product.storeName ? ` · ${product.storeName}` : "" - const line = `[Product: ${product.title}${storePart}${typeof product.id === "number" ? ` (#${product.id})` : ""}]\n` - setNewMessage(prev => (prev ? prev + line : line.trim())) + setDraftKind("PRODUCT") + setDraftAttachment({ + title: product.title, + description: product.description ?? null, + imageUrl: product.imageUrl ?? null, + storeName: product.storeName ?? null, + productId: product.id + }) + const summary = `Product: ${product.title}${product.storeName ? ` · ${product.storeName}` : ""}` + setNewMessage(prev => (prev.trim() ? `${prev.trim()}\n${summary}` : summary)) setAttachOpen(false) } const handleSendMessage = async (e: React.FormEvent) => { e.preventDefault() - if (!newMessage.trim() || !selectedContact) return + if (!selectedContact) return + const hasText = !!newMessage.trim() + const hasAttachment = !!draftKind && !!draftAttachment + if (!hasText && !hasAttachment) return try { setSending(true) setSendError(null) + const payload: any = { + receiverId: selectedContact.id, + content: newMessage + } + if (hasAttachment) { + payload.kind = draftKind + payload.attachment = draftAttachment + } const res = await fetch("/api/chat/messages", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - receiverId: selectedContact.id, - content: newMessage - }) + body: JSON.stringify(payload) }) if (res.ok) { @@ -246,10 +301,14 @@ export function ChatView() { senderId: msg.senderId, receiverId: msg.receiverId, content: newMessage, + kind: msg.kind ?? "TEXT", + attachment: msg.attachment ?? null, createdAt: msg.createdAt } setMessages(prev => [...prev, optimiticMsg]) setNewMessage("") + setDraftKind(null) + setDraftAttachment(null) } else { setSendError("Failed to send message. Please try again.") } @@ -470,7 +529,91 @@ export function ChatView() { ? "bg-white border border-slate-200 text-slate-800 rounded-tl-none shadow-md" : "bg-white/5 border border-white/10 text-slate-200 rounded-tl-none backdrop-blur-sm shadow-xl" )}> - {msg.content} + {msg.kind === "PRODUCT" && msg.attachment ? ( + + ) : msg.kind === "ORDER" && msg.attachment ? ( + + ) : ( + msg.content + )}
+ {attachmentModalOpen && activeAttachmentKind && activeAttachment && typeof document !== "undefined" + ? createPortal( +
setAttachmentModalOpen(false)} + > +
+
+
+
+
+ {activeAttachmentKind === "PRODUCT" ? ( + + ) : ( + + )} +
+
+

+ {activeAttachmentKind === "PRODUCT" ? "Product" : "Order"} +

+

+ {activeAttachmentKind === "PRODUCT" + ? activeAttachment.title || "Attached product" + : `Order ${activeAttachment.orderNumber || ""}`.trim()} +

+
+
+ +
+ +
+ {activeAttachmentKind === "PRODUCT" ? ( +
+
+ {activeAttachment.imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {activeAttachment.title + ) : ( +
+ +
+ )} +
+
+
+ {activeAttachment.title || "Product"} +
+ {activeAttachment.storeName ? ( +
+ {activeAttachment.storeName} +
+ ) : null} + {activeAttachment.description ? ( +

+ {activeAttachment.description} +

+ ) : ( +

+ No description provided. +

+ )} +
+
+ ) : ( +
+
+ {activeAttachment.primaryImageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + Order image + ) : ( +
+ +
+ {activeAttachment.orderNumber || ""} +
+
+ )} +
+
+
+ Order {activeAttachment.orderNumber || ""} +
+ {activeAttachment.customerName ? ( +
+ Customer: {activeAttachment.customerName} +
+ ) : null} + {activeAttachment.total_price ? ( +
+ {activeAttachment.currency ? `${activeAttachment.currency} ` : ""} + {activeAttachment.total_price} +
+ ) : null} +
+
+ )} +
+
+
+
, + document.body + ) : null}
@@ -609,10 +888,10 @@ export function ChatView() { />