feat(chat): rich typed attachments with modal
Made-with: Cursor
This commit is contained in:
parent
2e28ae8ed3
commit
5fb777496b
@ -4,6 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
"prebuild": "npx prisma generate",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
@ -12,6 +12,12 @@ enum UserRole {
|
|||||||
ADMIN
|
ADMIN
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ChatMessageKind {
|
||||||
|
TEXT
|
||||||
|
PRODUCT
|
||||||
|
ORDER
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String?
|
name String?
|
||||||
@ -564,7 +570,9 @@ model ChatMessage {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
senderId String
|
senderId String
|
||||||
receiverId String
|
receiverId String
|
||||||
|
kind ChatMessageKind @default(TEXT)
|
||||||
content String @map("encryptedContent") @db.Text
|
content String @map("encryptedContent") @db.Text
|
||||||
|
attachment Json?
|
||||||
|
|
||||||
sender User @relation("SentMessages", fields: [senderId], references: [id])
|
sender User @relation("SentMessages", fields: [senderId], references: [id])
|
||||||
receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id])
|
receiver User @relation("ReceivedMessages", fields: [receiverId], references: [id])
|
||||||
|
|||||||
@ -37,10 +37,41 @@ export async function GET() {
|
|||||||
type OrderItem = {
|
type OrderItem = {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
orderNumber?: string
|
||||||
total_price: string
|
total_price: string
|
||||||
currency?: string
|
currency?: string
|
||||||
storeId: string
|
storeId: string
|
||||||
storeName: 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 (
|
const fetchOrdersForStore = async (
|
||||||
@ -57,10 +88,13 @@ export async function GET() {
|
|||||||
return cached.slice(0, PER_STORE_LIMIT).map((order: CachedOrderRow) => ({
|
return cached.slice(0, PER_STORE_LIMIT).map((order: CachedOrderRow) => ({
|
||||||
id: parseInt(order.shopifyOrderId),
|
id: parseInt(order.shopifyOrderId),
|
||||||
name: order.orderNumber,
|
name: order.orderNumber,
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
total_price: order.totalPrice,
|
total_price: order.totalPrice,
|
||||||
currency: order.currency || undefined,
|
currency: order.currency || undefined,
|
||||||
storeId: store.id,
|
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) {
|
if (data.orders?.length) {
|
||||||
await updateOrderCache(store.id, data.orders).catch(() => {})
|
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,
|
id: o.id,
|
||||||
name: o.name || `#${o.id}`,
|
name: o.name || `#${o.id}`,
|
||||||
|
orderNumber: typeof o.name === "string" ? o.name : `#${o.id}`,
|
||||||
total_price: o.total_price || "0",
|
total_price: o.total_price || "0",
|
||||||
currency: o.currency,
|
currency: o.currency,
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
storeName
|
storeName,
|
||||||
|
customerName: extractCustomerName(o.customer),
|
||||||
|
primaryImageUrl: extractPrimaryImageUrl(o.line_items)
|
||||||
}))
|
}))
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
|
|||||||
@ -34,7 +34,20 @@ export async function GET() {
|
|||||||
|
|
||||||
type StoreRow = (typeof stores)[number]
|
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 (
|
const fetchProductsForStore = async (
|
||||||
store: { id: string; businessName: string | null; shopifyDomain: string | null; shopifyAccessToken: string | null }
|
store: { id: string; businessName: string | null; shopifyDomain: string | null; shopifyAccessToken: string | null }
|
||||||
@ -51,7 +64,9 @@ export async function GET() {
|
|||||||
id: parseInt(product.shopifyProductId),
|
id: parseInt(product.shopifyProductId),
|
||||||
title: product.title || "Product",
|
title: product.title || "Product",
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
storeName
|
storeName,
|
||||||
|
imageUrl: extractImageUrl(product.images),
|
||||||
|
description: null
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,11 +90,13 @@ export async function GET() {
|
|||||||
if (data.products?.length) {
|
if (data.products?.length) {
|
||||||
await updateProductCache(store.id, data.products).catch(() => {})
|
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,
|
id: p.id,
|
||||||
title: p.title || "Product",
|
title: p.title || "Product",
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
storeName
|
storeName,
|
||||||
|
imageUrl: p.images?.[0]?.src || p.images?.[0]?.url || null,
|
||||||
|
description: p.body_html || p.description || null
|
||||||
}))
|
}))
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
|
|||||||
@ -10,18 +10,33 @@ export async function POST(req: Request) {
|
|||||||
return new NextResponse("Unauthorized", { status: 401 })
|
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 })
|
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({
|
const message = await prisma.chatMessage.create({
|
||||||
data: {
|
data: {
|
||||||
senderId: session.user.id,
|
senderId: session.user.id,
|
||||||
receiverId,
|
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
|
// Send push notification to receiver
|
||||||
|
|||||||
155
src/components/chat/ChatAttachmentModal.tsx
Normal file
155
src/components/chat/ChatAttachmentModal.tsx
Normal file
@ -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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[99999] bg-black/70 backdrop-blur-md overflow-y-auto overflow-x-hidden"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4 py-10">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full max-w-xl rounded-2xl border shadow-2xl overflow-hidden",
|
||||||
|
isLight ? "bg-white border-slate-200" : "bg-slate-900/95 border-white/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between p-4 md:p-5 border-b flex-shrink-0",
|
||||||
|
isLight ? "border-slate-200 bg-white" : "border-white/10 bg-slate-900/95"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-10 h-10 rounded-xl flex items-center justify-center",
|
||||||
|
isLight
|
||||||
|
? "bg-indigo-100 text-indigo-600"
|
||||||
|
: "bg-indigo-500/20 text-indigo-300 border border-indigo-500/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{kind === "PRODUCT" ? <Package className="w-5 h-5" /> : <ShoppingCart className="w-5 h-5" />}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className={cn("text-sm md:text-base font-black truncate", isLight ? "text-slate-900" : "text-white")}>
|
||||||
|
{kind === "PRODUCT" ? "Product" : "Order"}
|
||||||
|
</h3>
|
||||||
|
<p className={cn("text-[11px] md:text-xs truncate", isLight ? "text-slate-500" : "text-slate-300/80")}>{title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className={cn(
|
||||||
|
"p-2 rounded-lg transition-colors flex-shrink-0",
|
||||||
|
isLight ? "hover:bg-slate-100 text-slate-600" : "hover:bg-white/10 text-slate-300"
|
||||||
|
)}
|
||||||
|
aria-label="Close attachment modal"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn("p-4 md:p-5 space-y-4", isLight ? "bg-white" : "bg-slate-900/95")}>
|
||||||
|
{kind === "PRODUCT" ? (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-24 h-24 rounded-2xl overflow-hidden bg-black/10 border border-white/10 flex-shrink-0">
|
||||||
|
{attachment.imageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={attachment.imageUrl}
|
||||||
|
alt={attachment.title || "Product image"}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className={cn("font-black text-base md:text-lg truncate", isLight ? "text-slate-900" : "text-white")}>
|
||||||
|
{attachment.title || "Product"}
|
||||||
|
</div>
|
||||||
|
{attachment.storeName ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-[11px] font-black uppercase tracking-widest mt-1",
|
||||||
|
isLight ? "text-indigo-600" : "text-indigo-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{attachment.storeName}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{attachment.description ? (
|
||||||
|
<p className={cn("text-sm mt-2 leading-relaxed", isLight ? "text-slate-600" : "text-slate-300/90")}>
|
||||||
|
{attachment.description}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className={cn("text-sm mt-2 opacity-70", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
|
No description provided.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-24 h-24 rounded-2xl overflow-hidden bg-black/10 border border-white/10 flex-shrink-0">
|
||||||
|
{attachment.primaryImageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={attachment.primaryImageUrl}
|
||||||
|
alt="Order image"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className={cn("font-black text-base md:text-lg truncate", isLight ? "text-slate-900" : "text-white")}>
|
||||||
|
Order {attachment.orderNumber || ""}
|
||||||
|
</div>
|
||||||
|
{attachment.customerName ? (
|
||||||
|
<div className={cn("text-sm mt-1 leading-relaxed", isLight ? "text-slate-700" : "text-slate-200/90")}>
|
||||||
|
Customer: {attachment.customerName}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{attachment.total_price ? (
|
||||||
|
<div className={cn("text-sm mt-2 font-bold", isLight ? "text-slate-700" : "text-slate-200/90")}>
|
||||||
|
{attachment.currency ? `${attachment.currency} ` : ""}
|
||||||
|
{attachment.total_price}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react"
|
import { useState, useEffect, useRef } from "react"
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { useSession } from "next-auth/react"
|
import { useSession } from "next-auth/react"
|
||||||
import { useTheme } from "@/components/ThemeProvider"
|
import { useTheme } from "@/components/ThemeProvider"
|
||||||
@ -33,6 +34,8 @@ interface Message {
|
|||||||
senderId: string
|
senderId: string
|
||||||
receiverId: string
|
receiverId: string
|
||||||
content: string
|
content: string
|
||||||
|
kind?: "TEXT" | "PRODUCT" | "ORDER" | string | null
|
||||||
|
attachment?: any
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,8 +60,25 @@ function AlertCircle(props: React.SVGProps<SVGSVGElement>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AttachOrder = { id: number; name: string; total_price: string; currency?: string; storeId?: string; storeName?: string }
|
type AttachOrder = {
|
||||||
type AttachProduct = { id: number | string; title: string; storeId?: string; storeName?: string }
|
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() {
|
export function ChatView() {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
@ -71,6 +91,12 @@ export function ChatView() {
|
|||||||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
|
||||||
const [messages, setMessages] = useState<Message[]>([])
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
const [newMessage, setNewMessage] = useState("")
|
const [newMessage, setNewMessage] = useState("")
|
||||||
|
const [draftKind, setDraftKind] = useState<"PRODUCT" | "ORDER" | null>(null)
|
||||||
|
const [draftAttachment, setDraftAttachment] = useState<any>(null)
|
||||||
|
|
||||||
|
const [attachmentModalOpen, setAttachmentModalOpen] = useState(false)
|
||||||
|
const [activeAttachmentKind, setActiveAttachmentKind] = useState<"PRODUCT" | "ORDER" | null>(null)
|
||||||
|
const [activeAttachment, setActiveAttachment] = useState<any>(null)
|
||||||
const [loadingContacts, setLoadingContacts] = useState(true)
|
const [loadingContacts, setLoadingContacts] = useState(true)
|
||||||
const [loadingMessages, setLoadingMessages] = useState(false)
|
const [loadingMessages, setLoadingMessages] = useState(false)
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
@ -115,14 +141,16 @@ export function ChatView() {
|
|||||||
const res = await fetch(`/api/chat/messages?contactId=${contact.id}`)
|
const res = await fetch(`/api/chat/messages?contactId=${contact.id}`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
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,
|
id: m.id,
|
||||||
senderId: m.senderId,
|
senderId: m.senderId,
|
||||||
receiverId: m.receiverId,
|
receiverId: m.receiverId,
|
||||||
content: m.content,
|
content: m.content ?? "",
|
||||||
|
kind: m.kind ?? "TEXT",
|
||||||
|
attachment: m.attachment ?? null,
|
||||||
createdAt: m.createdAt
|
createdAt: m.createdAt
|
||||||
}))
|
}))
|
||||||
setMessages(plainMessages)
|
setMessages(typedMessages)
|
||||||
} else if (!silent) {
|
} else if (!silent) {
|
||||||
setMessagesError("Failed to load messages. Please try again.")
|
setMessagesError("Failed to load messages. Please try again.")
|
||||||
}
|
}
|
||||||
@ -182,9 +210,12 @@ export function ChatView() {
|
|||||||
const res = await fetch("/api/supplier/products")
|
const res = await fetch("/api/supplier/products")
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
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,
|
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)
|
setAttachProducts(list)
|
||||||
}
|
}
|
||||||
@ -210,33 +241,57 @@ export function ChatView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const insertOrder = (order: AttachOrder) => {
|
const insertOrder = (order: AttachOrder) => {
|
||||||
const storePart = order.storeName ? ` · ${order.storeName}` : ""
|
setDraftKind("ORDER")
|
||||||
const line = `[Order ${order.name}${storePart} - ${order.currency || ""} ${order.total_price}]\n`
|
setDraftAttachment({
|
||||||
setNewMessage(prev => (prev ? prev + line : line.trim()))
|
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)
|
setAttachOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertProduct = (product: AttachProduct) => {
|
const insertProduct = (product: AttachProduct) => {
|
||||||
const storePart = product.storeName ? ` · ${product.storeName}` : ""
|
setDraftKind("PRODUCT")
|
||||||
const line = `[Product: ${product.title}${storePart}${typeof product.id === "number" ? ` (#${product.id})` : ""}]\n`
|
setDraftAttachment({
|
||||||
setNewMessage(prev => (prev ? prev + line : line.trim()))
|
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)
|
setAttachOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSendMessage = async (e: React.FormEvent) => {
|
const handleSendMessage = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!newMessage.trim() || !selectedContact) return
|
if (!selectedContact) return
|
||||||
|
const hasText = !!newMessage.trim()
|
||||||
|
const hasAttachment = !!draftKind && !!draftAttachment
|
||||||
|
if (!hasText && !hasAttachment) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSending(true)
|
setSending(true)
|
||||||
setSendError(null)
|
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", {
|
const res = await fetch("/api/chat/messages", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload)
|
||||||
receiverId: selectedContact.id,
|
|
||||||
content: newMessage
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@ -246,10 +301,14 @@ export function ChatView() {
|
|||||||
senderId: msg.senderId,
|
senderId: msg.senderId,
|
||||||
receiverId: msg.receiverId,
|
receiverId: msg.receiverId,
|
||||||
content: newMessage,
|
content: newMessage,
|
||||||
|
kind: msg.kind ?? "TEXT",
|
||||||
|
attachment: msg.attachment ?? null,
|
||||||
createdAt: msg.createdAt
|
createdAt: msg.createdAt
|
||||||
}
|
}
|
||||||
setMessages(prev => [...prev, optimiticMsg])
|
setMessages(prev => [...prev, optimiticMsg])
|
||||||
setNewMessage("")
|
setNewMessage("")
|
||||||
|
setDraftKind(null)
|
||||||
|
setDraftAttachment(null)
|
||||||
} else {
|
} else {
|
||||||
setSendError("Failed to send message. Please try again.")
|
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 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"
|
: "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 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveAttachmentKind("PRODUCT")
|
||||||
|
setActiveAttachment(msg.attachment)
|
||||||
|
setAttachmentModalOpen(true)
|
||||||
|
}}
|
||||||
|
className={cn("w-full text-left bg-transparent border-0 p-0 m-0")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-14 h-14 rounded-xl overflow-hidden bg-black/10 border border-white/10 flex-shrink-0 flex items-center justify-center">
|
||||||
|
{msg.attachment.imageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={msg.attachment.imageUrl} alt={msg.attachment.title || "Product"} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center px-1">
|
||||||
|
<Package className="w-5 h-5 text-indigo-400" />
|
||||||
|
<div className="text-[10px] font-black mt-1 leading-none text-indigo-200">
|
||||||
|
{msg.attachment.title ? msg.attachment.title : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-black truncate">
|
||||||
|
{msg.attachment.title || msg.content}
|
||||||
|
</div>
|
||||||
|
{msg.attachment.description ? (
|
||||||
|
<div className={cn("text-xs opacity-90 mt-1 line-clamp-2")}>
|
||||||
|
{msg.attachment.description}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{msg.attachment.storeName ? (
|
||||||
|
<div className={cn("text-[10px] font-black uppercase tracking-widest mt-1 opacity-80")}>
|
||||||
|
{msg.attachment.storeName}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
) : msg.kind === "ORDER" && msg.attachment ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveAttachmentKind("ORDER")
|
||||||
|
setActiveAttachment(msg.attachment)
|
||||||
|
setAttachmentModalOpen(true)
|
||||||
|
}}
|
||||||
|
className={cn("w-full text-left")}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-14 h-14 rounded-xl overflow-hidden bg-black/10 border border-white/10 flex-shrink-0 flex items-center justify-center">
|
||||||
|
{msg.attachment.primaryImageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={msg.attachment.primaryImageUrl} alt="Order item" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center px-1">
|
||||||
|
<ShoppingCart className="w-5 h-5 text-indigo-400" />
|
||||||
|
<div className="text-[10px] font-black mt-1 leading-none text-indigo-200">
|
||||||
|
{msg.attachment.orderNumber || "#"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-black truncate">
|
||||||
|
Order {msg.attachment.orderNumber || msg.content}
|
||||||
|
</div>
|
||||||
|
{msg.attachment.customerName ? (
|
||||||
|
<div className={cn("text-xs opacity-90 mt-1 line-clamp-1")}>
|
||||||
|
{msg.attachment.customerName}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{msg.attachment.total_price ? (
|
||||||
|
<div className={cn("text-[10px] font-black uppercase tracking-widest mt-1 opacity-80")}>
|
||||||
|
{msg.attachment.currency ? `${msg.attachment.currency} ` : ""}{msg.attachment.total_price}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
msg.content
|
||||||
|
)}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"absolute bottom-2 -right-6 flex items-center gap-1 transition-opacity opacity-0 group-hover:opacity-100",
|
"absolute bottom-2 -right-6 flex items-center gap-1 transition-opacity opacity-0 group-hover:opacity-100",
|
||||||
isMe ? "-left-8 right-auto flex-row-reverse" : ""
|
isMe ? "-left-8 right-auto flex-row-reverse" : ""
|
||||||
@ -488,6 +631,142 @@ export function ChatView() {
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
|
{attachmentModalOpen && activeAttachmentKind && activeAttachment && typeof document !== "undefined"
|
||||||
|
? createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[99999] bg-transparent"
|
||||||
|
onClick={() => setAttachmentModalOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4 py-10">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full max-w-xl rounded-2xl border shadow-2xl overflow-hidden",
|
||||||
|
isLight ? "bg-white border-slate-200" : "bg-slate-900/95 border-white/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between p-4 md:p-5 border-b flex-shrink-0",
|
||||||
|
isLight ? "border-slate-200 bg-white" : "border-white/10 bg-slate-900/95"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-10 h-10 rounded-xl flex items-center justify-center",
|
||||||
|
isLight ? "bg-indigo-100 text-indigo-600" : "bg-indigo-500/20 text-indigo-300 border border-indigo-500/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{activeAttachmentKind === "PRODUCT" ? (
|
||||||
|
<Package className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<ShoppingCart className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className={cn("text-sm md:text-base font-black truncate", isLight ? "text-slate-900" : "text-white")}>
|
||||||
|
{activeAttachmentKind === "PRODUCT" ? "Product" : "Order"}
|
||||||
|
</h3>
|
||||||
|
<p className={cn("text-[11px] md:text-xs truncate", isLight ? "text-slate-500" : "text-slate-300/80")}>
|
||||||
|
{activeAttachmentKind === "PRODUCT"
|
||||||
|
? activeAttachment.title || "Attached product"
|
||||||
|
: `Order ${activeAttachment.orderNumber || ""}`.trim()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAttachmentModalOpen(false)}
|
||||||
|
className={cn(
|
||||||
|
"p-2 rounded-lg transition-colors flex-shrink-0",
|
||||||
|
isLight ? "hover:bg-slate-100 text-slate-600" : "hover:bg-white/10 text-slate-300"
|
||||||
|
)}
|
||||||
|
aria-label="Close attachment modal"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn("p-4 md:p-5 space-y-4", isLight ? "bg-white" : "bg-slate-900/95")}>
|
||||||
|
{activeAttachmentKind === "PRODUCT" ? (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-24 h-24 rounded-2xl overflow-hidden bg-black/10 border border-white/10 flex-shrink-0">
|
||||||
|
{activeAttachment.imageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={activeAttachment.imageUrl}
|
||||||
|
alt={activeAttachment.title || "Product image"}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<Package className="w-7 h-7 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className={cn("font-black text-base md:text-lg truncate", isLight ? "text-slate-900" : "text-white")}>
|
||||||
|
{activeAttachment.title || "Product"}
|
||||||
|
</div>
|
||||||
|
{activeAttachment.storeName ? (
|
||||||
|
<div className={cn("text-[11px] font-black uppercase tracking-widest mt-1", isLight ? "text-indigo-600" : "text-indigo-300")}>
|
||||||
|
{activeAttachment.storeName}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{activeAttachment.description ? (
|
||||||
|
<p className={cn("text-sm mt-2 leading-relaxed", isLight ? "text-slate-600" : "text-slate-300/90")}>
|
||||||
|
{activeAttachment.description}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className={cn("text-sm mt-2 opacity-70", isLight ? "text-slate-500" : "text-slate-400")}>
|
||||||
|
No description provided.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-24 h-24 rounded-2xl overflow-hidden bg-black/10 border border-white/10 flex-shrink-0 flex items-center justify-center">
|
||||||
|
{activeAttachment.primaryImageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={activeAttachment.primaryImageUrl}
|
||||||
|
alt="Order image"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center px-2 text-center">
|
||||||
|
<ShoppingCart className="w-6 h-6 text-indigo-400" />
|
||||||
|
<div className="text-[11px] font-black mt-2 leading-none text-indigo-200">
|
||||||
|
{activeAttachment.orderNumber || ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className={cn("font-black text-base md:text-lg truncate", isLight ? "text-slate-900" : "text-white")}>
|
||||||
|
Order {activeAttachment.orderNumber || ""}
|
||||||
|
</div>
|
||||||
|
{activeAttachment.customerName ? (
|
||||||
|
<div className={cn("text-sm mt-1 leading-relaxed", isLight ? "text-slate-700" : "text-slate-200/90")}>
|
||||||
|
Customer: {activeAttachment.customerName}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{activeAttachment.total_price ? (
|
||||||
|
<div className={cn("text-sm mt-2 font-bold", isLight ? "text-slate-700" : "text-slate-200/90")}>
|
||||||
|
{activeAttachment.currency ? `${activeAttachment.currency} ` : ""}
|
||||||
|
{activeAttachment.total_price}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 flex-shrink-0">
|
<div className="p-6 flex-shrink-0">
|
||||||
@ -609,10 +888,10 @@ export function ChatView() {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!newMessage.trim() || sending}
|
disabled={(!newMessage.trim() && !(draftKind && draftAttachment)) || sending}
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-4 rounded-xl transition-all shadow-lg active:scale-90 disabled:opacity-50",
|
"p-4 rounded-xl transition-all shadow-lg active:scale-90 disabled:opacity-50",
|
||||||
newMessage.trim()
|
(newMessage.trim() || (draftKind && draftAttachment))
|
||||||
? "bg-indigo-600 text-white shadow-indigo-600/20"
|
? "bg-indigo-600 text-white shadow-indigo-600/20"
|
||||||
: isLight ? "bg-slate-100 text-slate-500" : "bg-white/5 text-slate-700"
|
: isLight ? "bg-slate-100 text-slate-500" : "bg-white/5 text-slate-700"
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user