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
+

+ ) : 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
+

+ ) : 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 || "Product"}
+
+ {activeAttachment.storeName ? (
+
+ {activeAttachment.storeName}
+
+ ) : null}
+ {activeAttachment.description ? (
+
+ {activeAttachment.description}
+
+ ) : (
+
+ No description provided.
+
+ )}
+
+
+ ) : (
+
+
+ {activeAttachment.primaryImageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+
+
+ {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() {
/>