feat: implement ServicesOverviewContent component for consistent service listings

refactor: update AccountServicesOverview and PublicServicesOverview to use ServicesOverviewContent
fix: enhance case comment transformation with email-based customer identification
refactor: improve SupportCaseDetailView with message grouping and localStorage tracking
This commit is contained in:
barsa 2026-01-19 16:29:26 +09:00
parent 789e2d95a5
commit 2a40d84691
7 changed files with 501 additions and 362 deletions

View File

@ -353,7 +353,7 @@ export class SalesforceCaseService {
// Transform to unified CaseMessage format
const emailMessages = transformEmailMessagesToCaseMessages(emails, customerEmail);
const commentMessages = transformCaseCommentsToCaseMessages(comments);
const commentMessages = transformCaseCommentsToCaseMessages(comments, customerEmail);
// Merge and sort chronologically
const messages = mergeAndSortCaseMessages(emailMessages, commentMessages);

View File

@ -1,146 +0,0 @@
import Link from "next/link";
import { Building2, Wrench, Tv, ArrowRight, Wifi, Smartphone, Lock } from "lucide-react";
import { cn } from "@/shared/utils";
interface ServicesGridProps {
basePath?: string;
}
interface ServiceCardProps {
href: string;
icon: React.ReactNode;
title: string;
description: string;
price?: string;
highlight?: string;
featured?: boolean;
className?: string;
}
function ServiceCard({
href,
icon,
title,
description,
price,
highlight,
featured = false,
className,
}: ServiceCardProps) {
return (
<Link href={href} className={cn("group block h-full", className)}>
<div
className={cn(
"h-full flex flex-col rounded-xl border p-6",
"transition-all duration-[var(--cp-duration-normal)]",
"hover:-translate-y-0.5 hover:shadow-lg",
featured
? "bg-gradient-to-br from-primary/5 to-card border-primary/20 hover:border-primary/40"
: "bg-card border-border hover:border-primary/30"
)}
>
{/* Icon */}
<div
className={cn(
"flex h-11 w-11 items-center justify-center rounded-lg mb-4 text-primary",
featured ? "bg-primary/15" : "bg-primary/8"
)}
>
{icon}
</div>
{/* Content */}
<h2 className="text-lg font-semibold text-foreground mb-2 font-display">{title}</h2>
<p className="text-sm text-muted-foreground mb-4 flex-grow leading-relaxed">
{description}
</p>
{/* Price or Highlight */}
<div className="flex items-center justify-between mt-auto pt-4 border-t border-border/50">
<div>
{price && (
<span className="text-sm font-medium text-foreground">
From <span className="text-primary">{price}</span>
</span>
)}
{highlight && (
<span className="inline-flex rounded-full bg-success/10 px-2.5 py-0.5 text-xs font-medium text-success">
{highlight}
</span>
)}
{!price && !highlight && (
<span className="text-sm text-muted-foreground">Learn more</span>
)}
</div>
<div
className={cn(
"flex items-center gap-1 text-sm font-medium text-primary",
"transition-transform group-hover:translate-x-0.5"
)}
>
View Plans
<ArrowRight className="h-3.5 w-3.5" />
</div>
</div>
</div>
</Link>
);
}
export function ServicesGrid({ basePath = "/services" }: ServicesGridProps) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Internet - Featured */}
<ServiceCard
href={`${basePath}/internet`}
icon={<Wifi className="h-5 w-5" />}
title="Internet"
description="NTT fiber with speeds up to 10Gbps. Professional installation and full support included."
price="¥4,000/mo"
featured
/>
{/* SIM */}
<ServiceCard
href={`${basePath}/sim`}
icon={<Smartphone className="h-5 w-5" />}
title="SIM & eSIM"
description="Data, voice & SMS on NTT Docomo's network. Physical SIM or instant eSIM."
highlight="No contract"
/>
{/* VPN */}
<ServiceCard
href={`${basePath}/vpn`}
icon={<Lock className="h-5 w-5" />}
title="VPN"
description="Access US/UK content with pre-configured routers. Simple setup."
price="¥2,500/mo"
/>
{/* Business */}
<ServiceCard
href="/services/business"
icon={<Building2 className="h-5 w-5" />}
title="Business"
description="DIA, Office LAN, and enterprise connectivity solutions."
/>
{/* Onsite */}
<ServiceCard
href="/services/onsite"
icon={<Wrench className="h-5 w-5" />}
title="Onsite"
description="Setup and troubleshooting at your location by certified technicians."
/>
{/* TV */}
<ServiceCard
href="/services/tv"
icon={<Tv className="h-5 w-5" />}
title="TV Services"
description="Satellite, cable, and optical fiber TV with international packages."
/>
</div>
);
}

View File

@ -0,0 +1,159 @@
import Link from "next/link";
import {
Wifi,
Smartphone,
ShieldCheck,
ArrowRight,
Phone,
CheckCircle2,
Globe,
Headphones,
Building2,
Wrench,
Tv,
} from "lucide-react";
import { ServiceCard } from "@/components/molecules/ServiceCard";
import { ServicesHero } from "@/features/services/components/base/ServicesHero";
interface ServicesOverviewContentProps {
/** Base path for service links ("/services" or "/account/services") */
basePath: "/services" | "/account/services";
/** Whether to show the CTA section (default: true) */
showCta?: boolean;
}
/**
* ServicesOverviewContent - Shared content component for services overview pages.
*
* Used by both PublicServicesOverview and AccountServicesOverview to ensure
* consistent design across public and authenticated service listing pages.
*/
export function ServicesOverviewContent({
basePath,
showCta = true,
}: ServicesOverviewContentProps) {
return (
<div className="space-y-12 pb-16">
{/* Hero */}
<ServicesHero
title="Our Services"
description="Connectivity and support solutions for Japan's international community."
eyebrow={
<span className="inline-flex items-center gap-2 rounded-full bg-primary/8 border border-primary/15 px-4 py-2 text-sm text-primary font-medium normal-case tracking-normal">
<CheckCircle2 className="h-4 w-4" />
Full English Support
</span>
}
animated
/>
{/* Value Props - Compact */}
<section
className="flex flex-wrap justify-center gap-6 text-sm animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "300ms" }}
>
<div className="flex items-center gap-2 text-muted-foreground">
<Globe className="h-4 w-4 text-primary" />
<span>One provider, all services</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Headphones className="h-4 w-4 text-success" />
<span>English support</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-info" />
<span>No hidden fees</span>
</div>
</section>
{/* All Services - Clean Grid with staggered animations */}
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 cp-stagger-children">
<ServiceCard
href={`${basePath}/internet`}
icon={<Wifi className="h-6 w-6" />}
title="Internet"
description="NTT Optical Fiber for homes and apartments. Speeds up to 10Gbps with professional installation."
price="¥3,200/mo"
accentColor="blue"
/>
<ServiceCard
href={`${basePath}/sim`}
icon={<Smartphone className="h-6 w-6" />}
title="SIM & eSIM"
description="Data, voice & SMS on NTT Docomo network. Physical SIM or instant eSIM activation."
price="¥1,100/mo"
badge="1st month free"
accentColor="green"
/>
<ServiceCard
href={`${basePath}/vpn`}
icon={<ShieldCheck className="h-6 w-6" />}
title="VPN Router"
description="Access US & UK streaming content with a pre-configured router. Simple plug-and-play."
price="¥2,500/mo"
accentColor="purple"
/>
{/* Business, Onsite, and TV services only have public pages (no account-specific routes) */}
<ServiceCard
href="/services/business"
icon={<Building2 className="h-6 w-6" />}
title="Business"
description="Enterprise solutions for offices and commercial spaces. Dedicated support and SLAs."
accentColor="orange"
/>
<ServiceCard
href="/services/onsite"
icon={<Wrench className="h-6 w-6" />}
title="Onsite Support"
description="Professional technicians visit your location for setup, troubleshooting, and maintenance."
accentColor="cyan"
/>
<ServiceCard
href="/services/tv"
icon={<Tv className="h-6 w-6" />}
title="TV"
description="Streaming TV packages with international channels. Watch content from home countries."
accentColor="pink"
/>
</section>
{/* CTA */}
{showCta && (
<section
className="rounded-2xl bg-gradient-to-br from-muted/50 to-muted/80 p-8 text-center animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "500ms" }}
>
<h2 className="text-xl font-bold text-foreground font-display mb-3">
Need help choosing?
</h2>
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
Our bilingual team can help you find the right solution.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<Link
href="/contact"
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 font-medium text-primary-foreground hover:bg-primary-hover transition-colors"
>
Contact Us
<ArrowRight className="h-4 w-4" />
</Link>
<a
href="tel:0120660470"
className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
aria-label="Call us toll free at 0120-660-470"
>
<Phone className="h-4 w-4" aria-hidden="true" />
0120-660-470 (Toll Free)
</a>
</div>
</section>
)}
</div>
);
}

View File

@ -1,27 +1,16 @@
import { ServicesGrid } from "@/features/services/components/common/ServicesGrid";
import { ServicesOverviewContent } from "@/features/services/components/common/ServicesOverviewContent";
/**
* AccountServicesOverview - Authenticated user's services landing page.
*
* Shows available services for the logged-in user with a header
* and services grid linking to account-specific service pages.
* Shows available services for the logged-in user with the same rich
* design as the public page, linking to account-specific service pages.
*/
export function AccountServicesOverview() {
return (
<div className="py-8">
<div className="max-w-[var(--cp-page-max-width)] mx-auto px-4 sm:px-6 md:px-8">
{/* Header */}
<div className="text-center mb-16 pt-8">
<h1 className="text-4xl sm:text-5xl font-extrabold text-foreground mb-6 tracking-tight">
Our Services
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
From high-speed internet to onsite support, we provide comprehensive solutions for your
home and business.
</p>
</div>
<ServicesGrid basePath="/account/services" />
<ServicesOverviewContent basePath="/account/services" />
</div>
</div>
);

View File

@ -1,18 +1,4 @@
import Link from "next/link";
import {
Wifi,
Smartphone,
ShieldCheck,
ArrowRight,
Phone,
CheckCircle2,
Globe,
Headphones,
Building2,
Wrench,
Tv,
} from "lucide-react";
import { ServiceCard } from "@/components/molecules/ServiceCard";
import { ServicesOverviewContent } from "@/features/services/components/common/ServicesOverviewContent";
/**
* PublicServicesOverview - Public-facing services landing page.
@ -21,137 +7,5 @@ import { ServiceCard } from "@/components/molecules/ServiceCard";
* service cards grid, and contact CTA.
*/
export function PublicServicesOverview() {
return (
<div className="space-y-12 pb-16">
{/* Hero */}
<section className="text-center pt-8">
<div
className="animate-in fade-in slide-in-from-bottom-4 duration-500"
style={{ animationDelay: "0ms" }}
>
<span className="inline-flex items-center gap-2 rounded-full bg-primary/8 border border-primary/15 px-4 py-2 text-sm text-primary font-medium mb-6">
<CheckCircle2 className="h-4 w-4" />
Full English Support
</span>
</div>
<h1
className="text-display-lg font-display font-bold text-foreground mb-4 animate-in fade-in slide-in-from-bottom-6 duration-700"
style={{ animationDelay: "100ms" }}
>
Our Services
</h1>
<p
className="text-lg text-muted-foreground max-w-xl mx-auto animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "200ms" }}
>
Connectivity and support solutions for Japan&apos;s international community.
</p>
</section>
{/* Value Props - Compact */}
<section
className="flex flex-wrap justify-center gap-6 text-sm animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "300ms" }}
>
<div className="flex items-center gap-2 text-muted-foreground">
<Globe className="h-4 w-4 text-primary" />
<span>One provider, all services</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Headphones className="h-4 w-4 text-success" />
<span>English support</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-info" />
<span>No hidden fees</span>
</div>
</section>
{/* All Services - Clean Grid with staggered animations */}
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 cp-stagger-children">
<ServiceCard
href="/services/internet"
icon={<Wifi className="h-6 w-6" />}
title="Internet"
description="NTT Optical Fiber for homes and apartments. Speeds up to 10Gbps with professional installation."
price="¥3,200/mo"
accentColor="blue"
/>
<ServiceCard
href="/services/sim"
icon={<Smartphone className="h-6 w-6" />}
title="SIM & eSIM"
description="Data, voice & SMS on NTT Docomo network. Physical SIM or instant eSIM activation."
price="¥1,100/mo"
badge="1st month free"
accentColor="green"
/>
<ServiceCard
href="/services/vpn"
icon={<ShieldCheck className="h-6 w-6" />}
title="VPN Router"
description="Access US & UK streaming content with a pre-configured router. Simple plug-and-play."
price="¥2,500/mo"
accentColor="purple"
/>
<ServiceCard
href="/services/business"
icon={<Building2 className="h-6 w-6" />}
title="Business"
description="Enterprise solutions for offices and commercial spaces. Dedicated support and SLAs."
accentColor="orange"
/>
<ServiceCard
href="/services/onsite"
icon={<Wrench className="h-6 w-6" />}
title="Onsite Support"
description="Professional technicians visit your location for setup, troubleshooting, and maintenance."
accentColor="cyan"
/>
<ServiceCard
href="/services/tv"
icon={<Tv className="h-6 w-6" />}
title="TV"
description="Streaming TV packages with international channels. Watch content from home countries."
accentColor="pink"
/>
</section>
{/* CTA */}
<section
className="rounded-2xl bg-gradient-to-br from-muted/50 to-muted/80 p-8 text-center animate-in fade-in slide-in-from-bottom-8 duration-700"
style={{ animationDelay: "500ms" }}
>
<h2 className="text-xl font-bold text-foreground font-display mb-3">Need help choosing?</h2>
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
Our bilingual team can help you find the right solution.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<Link
href="/contact"
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 font-medium text-primary-foreground hover:bg-primary-hover transition-colors"
>
Contact Us
<ArrowRight className="h-4 w-4" />
</Link>
<a
href="tel:0120660470"
className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
aria-label="Call us toll free at 0120-660-470"
>
<Phone className="h-4 w-4" aria-hidden="true" />
0120-660-470 (Toll Free)
</a>
</div>
</section>
</div>
);
return <ServicesOverviewContent basePath="/services" />;
}

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useState, useMemo, useRef, useEffect } from "react";
import {
CalendarIcon,
ClockIcon,
@ -10,6 +10,9 @@ import {
EnvelopeIcon,
ChatBubbleLeftIcon,
PaperClipIcon,
UserIcon,
ChatBubbleLeftRightIcon,
CheckIcon,
} from "@heroicons/react/24/outline";
import { TicketIcon as TicketIconSolid } from "@heroicons/react/24/solid";
import { PageLayout } from "@/components/templates/PageLayout";
@ -88,6 +91,111 @@ function groupMessagesByDate(messages: CaseMessage[]): MessageGroup[] {
});
}
/**
* Group consecutive messages from the same sender within 5 minutes
* for a cleaner conversation display
*/
interface SenderMessageGroup {
senderId: string;
isCustomer: boolean;
messages: CaseMessage[];
}
function groupConsecutiveMessages(messages: CaseMessage[]): SenderMessageGroup[] {
if (messages.length === 0) return [];
const groups: SenderMessageGroup[] = [];
let currentGroup: SenderMessageGroup | null = null;
for (const message of messages) {
const senderId = message.author.email ?? message.author.name;
const isCustomer = message.author.isCustomer;
// Check if this message should be part of the current group
if (currentGroup && currentGroup.senderId === senderId) {
const lastMessage = currentGroup.messages.at(-1);
if (lastMessage) {
const timeDiff =
new Date(message.createdAt).getTime() - new Date(lastMessage.createdAt).getTime();
const fiveMinutes = 5 * 60 * 1000;
// Group if same sender and within 5 minutes
if (timeDiff < fiveMinutes) {
currentGroup.messages.push(message);
continue;
}
}
}
// Start a new group
currentGroup = {
senderId,
isCustomer,
messages: [message],
};
groups.push(currentGroup);
}
return groups;
}
const CASE_VIEW_PREFIX = "case-";
const CASE_VIEW_SUFFIX = "-last-viewed";
const MAX_STORED_CASE_VIEWS = 50;
/**
* Get the last viewed timestamp for a case from localStorage
*/
function getLastViewedTimestamp(caseId: string): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(`${CASE_VIEW_PREFIX}${caseId}${CASE_VIEW_SUFFIX}`);
}
/**
* Set the last viewed timestamp for a case in localStorage
* Also cleans up old entries to prevent localStorage bloat
*/
function setLastViewedTimestamp(caseId: string): void {
if (typeof window === "undefined") return;
const key = `${CASE_VIEW_PREFIX}${caseId}${CASE_VIEW_SUFFIX}`;
localStorage.setItem(key, new Date().toISOString());
// Cleanup old entries if we have too many
cleanupOldCaseViewTimestamps();
}
/**
* Remove old case view timestamps to prevent localStorage bloat
*/
function cleanupOldCaseViewTimestamps(): void {
// Collect all localStorage keys first to avoid iteration issues during removal
const allKeys: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) allKeys.push(key);
}
// Filter to only case view keys and collect timestamps
const caseViewKeys: { key: string; timestamp: number }[] = [];
for (const key of allKeys) {
if (key.startsWith(CASE_VIEW_PREFIX) && key.endsWith(CASE_VIEW_SUFFIX)) {
const value = localStorage.getItem(key);
const timestamp = value ? new Date(value).getTime() : 0;
caseViewKeys.push({ key, timestamp });
}
}
// If we have more than MAX entries, remove the oldest ones
if (caseViewKeys.length > MAX_STORED_CASE_VIEWS) {
caseViewKeys.sort((a, b) => b.timestamp - a.timestamp); // newest first
const keysToRemove = caseViewKeys.slice(MAX_STORED_CASE_VIEWS);
for (const { key } of keysToRemove) {
localStorage.removeItem(key);
}
}
}
interface SupportCaseDetailViewProps {
caseId: string;
}
@ -105,6 +213,23 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
const addCommentMutation = useAddCaseComment(caseId);
const [replyText, setReplyText] = useState("");
const [pendingMessage, setPendingMessage] = useState<CaseMessage | null>(null);
const [showSentConfirmation, setShowSentConfirmation] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [lastViewedTime] = useState(() => getLastViewedTimestamp(caseId));
// Mark case as viewed when component mounts
useEffect(() => {
setLastViewedTimestamp(caseId);
}, [caseId]);
// Auto-scroll to bottom when new messages arrive
const hasPendingMessage = pendingMessage !== null;
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messagesData?.messages.length, hasPendingMessage]);
const pageError =
error && process.env.NODE_ENV !== "development"
@ -119,11 +244,30 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
e.preventDefault();
if (!replyText.trim() || addCommentMutation.isPending) return;
const messageText = replyText.trim();
// Create optimistic message
const optimisticMsg: CaseMessage = {
id: `pending-${Date.now()}`,
type: "comment",
body: messageText,
author: { name: "You", email: null, isCustomer: true },
createdAt: new Date().toISOString(),
direction: null,
};
setPendingMessage(optimisticMsg);
setReplyText("");
try {
await addCommentMutation.mutateAsync({ body: replyText.trim() });
setReplyText("");
await addCommentMutation.mutateAsync({ body: messageText });
// Show sent confirmation briefly
setShowSentConfirmation(true);
setTimeout(() => setShowSentConfirmation(false), 2000);
} catch {
// Error is handled by the mutation
// Restore the text if failed
setReplyText(messageText);
} finally {
setPendingMessage(null);
}
};
@ -245,6 +389,10 @@ export function SupportCaseDetailView({ caseId }: SupportCaseDetailViewProps) {
supportCase={supportCase}
messagesData={messagesData}
messagesLoading={messagesLoading}
pendingMessage={pendingMessage}
lastViewedTime={lastViewedTime}
messagesEndRef={messagesEndRef}
showSentConfirmation={showSentConfirmation}
/>
{/* Reply Form */}
@ -318,15 +466,23 @@ interface ConversationThreadProps {
};
messagesData: { messages: CaseMessage[] } | undefined;
messagesLoading: boolean;
pendingMessage: CaseMessage | null;
lastViewedTime: string | null;
messagesEndRef: React.RefObject<HTMLDivElement | null>;
showSentConfirmation: boolean;
}
/**
* Conversation thread with date grouping
* Conversation thread with date grouping and sender grouping
*/
function ConversationThread({
supportCase,
messagesData,
messagesLoading,
pendingMessage,
lastViewedTime,
messagesEndRef,
showSentConfirmation,
}: ConversationThreadProps) {
// Create initial description message
const descriptionMessage: CaseMessage = useMemo(
@ -341,11 +497,15 @@ function ConversationThread({
[supportCase.description, supportCase.createdAt]
);
// Combine description with messages and group by date
// Combine description with messages, add pending if exists, and group by date
const messageGroups = useMemo(() => {
const allMessages = [descriptionMessage, ...(messagesData?.messages ?? [])];
const allMessages = [
descriptionMessage,
...(messagesData?.messages ?? []),
...(pendingMessage ? [pendingMessage] : []),
];
return groupMessagesByDate(allMessages);
}, [descriptionMessage, messagesData?.messages]);
}, [descriptionMessage, messagesData?.messages, pendingMessage]);
if (messagesLoading) {
return (
@ -360,55 +520,147 @@ function ConversationThread({
return (
<div className="p-5">
<div className="space-y-6">
{messageGroups.map(group => (
<div key={group.date}>
{/* Date separator */}
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-px bg-border" />
<span className="text-xs font-medium text-muted-foreground px-2">
{group.dateLabel}
</span>
<div className="flex-1 h-px bg-border" />
</div>
{messageGroups.map(group => {
// Group consecutive messages within this date group
const senderGroups = groupConsecutiveMessages(group.messages);
{/* Messages for this date */}
<div className="space-y-4">
{group.messages.map(message => (
<MessageBubble key={message.id} message={message} />
))}
return (
<div key={group.date}>
{/* Date separator */}
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-px bg-border" />
<span className="text-xs font-medium text-muted-foreground px-2">
{group.dateLabel}
</span>
<div className="flex-1 h-px bg-border" />
</div>
{/* Messages for this date, grouped by sender */}
<div className="space-y-4">
{senderGroups.map((senderGroup, groupIndex) => (
<MessageGroupBubbles
key={`${senderGroup.senderId}-${groupIndex}`}
senderGroup={senderGroup}
pendingMessageId={pendingMessage?.id ?? null}
lastViewedTime={lastViewedTime}
showSentConfirmation={
showSentConfirmation && groupIndex === senderGroups.length - 1
}
/>
))}
</div>
</div>
</div>
))}
);
})}
{/* No replies yet message */}
{!hasReplies && (
<p className="text-center text-sm text-muted-foreground py-4">
No replies yet. Our team will respond shortly.
</p>
<div className="text-center py-6">
<ChatBubbleLeftRightIcon className="h-10 w-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">
Your message has been received! Our support team typically responds within 24 hours.
</p>
</div>
)}
{/* Auto-scroll anchor */}
<div ref={messagesEndRef} />
</div>
</div>
);
}
/**
* Grouped messages from the same sender - shows avatar only on first message
*/
function MessageGroupBubbles({
senderGroup,
pendingMessageId,
lastViewedTime,
showSentConfirmation,
}: {
senderGroup: SenderMessageGroup;
pendingMessageId: string | null;
lastViewedTime: string | null;
showSentConfirmation: boolean;
}) {
const { isCustomer, messages } = senderGroup;
return (
<div className={`flex gap-2 ${isCustomer ? "justify-end" : "justify-start"}`}>
{/* Avatar - only show for support (on left side) */}
{!isCustomer && (
<div className="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center bg-muted text-muted-foreground mt-1">
<ChatBubbleLeftRightIcon className="w-4 h-4" />
</div>
)}
{/* Message bubbles */}
<div
className={`flex flex-col gap-1 max-w-[85%] ${isCustomer ? "items-end" : "items-start"}`}
>
{messages.map((message, index) => {
const isFirst = index === 0;
const isLast = index === messages.length - 1;
const isPending = message.id === pendingMessageId;
const isNew = Boolean(
!isCustomer && lastViewedTime && new Date(message.createdAt) > new Date(lastViewedTime)
);
return (
<MessageBubble
key={message.id}
message={message}
showHeader={isFirst}
isPending={isPending}
isNew={isNew}
showSentConfirmation={showSentConfirmation && isLast && isCustomer}
/>
);
})}
</div>
{/* Avatar - only show for customer (on right side) */}
{isCustomer && (
<div className="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center bg-primary/10 text-primary mt-1">
<UserIcon className="w-4 h-4" />
</div>
)}
</div>
);
}
/**
* Message bubble component for displaying individual messages
*/
function MessageBubble({ message }: { message: CaseMessage }) {
function MessageBubble({
message,
showHeader = true,
isPending = false,
isNew = false,
showSentConfirmation = false,
}: {
message: CaseMessage;
showHeader?: boolean;
isPending?: boolean;
isNew?: boolean;
showSentConfirmation?: boolean;
}) {
const isCustomer = message.author.isCustomer;
const isEmail = message.type === "email";
const hasAttachment = message.hasAttachment === true;
const displayName = isCustomer ? "You" : message.author.name;
return (
<div className={`flex ${isCustomer ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[85%] rounded-2xl px-4 py-3 ${
isCustomer
? "bg-primary text-primary-foreground rounded-br-md"
: "bg-muted text-foreground rounded-bl-md"
}`}
>
{/* Author and type indicator */}
<div
className={`rounded-2xl px-4 py-3 shadow-sm ${
isCustomer
? "bg-primary text-primary-foreground rounded-br-md"
: "bg-muted text-foreground rounded-bl-md"
} ${isPending ? "opacity-70" : ""}`}
>
{/* Author and type indicator - only show on first message of group */}
{showHeader && (
<div
className={`flex items-center gap-2 mb-1 text-xs ${
isCustomer ? "text-primary-foreground/70" : "text-muted-foreground"
@ -419,7 +671,7 @@ function MessageBubble({ message }: { message: CaseMessage }) {
) : (
<ChatBubbleLeftIcon className="h-3 w-3" />
)}
<span className="font-medium">{message.author.name}</span>
<span className="font-medium">{displayName}</span>
<span></span>
<span>{formatIsoRelative(message.createdAt)}</span>
{hasAttachment && (
@ -431,11 +683,38 @@ function MessageBubble({ message }: { message: CaseMessage }) {
</span>
</>
)}
{isNew && (
<span className="ml-1 px-1.5 py-0.5 rounded bg-primary text-primary-foreground text-[10px] font-semibold uppercase">
New
</span>
)}
</div>
)}
{/* Message body */}
<p className="whitespace-pre-wrap text-sm leading-relaxed">{message.body}</p>
</div>
{/* Message body */}
<p className="whitespace-pre-wrap text-sm leading-relaxed">{message.body}</p>
{/* Pending/Sent status indicator */}
{isPending && (
<div
className={`flex items-center gap-1 mt-1 text-xs ${
isCustomer ? "text-primary-foreground/60" : "text-muted-foreground"
}`}
>
<Spinner size="xs" />
<span>Sending...</span>
</div>
)}
{showSentConfirmation && !isPending && (
<div
className={`flex items-center gap-1 mt-1 text-xs ${
isCustomer ? "text-primary-foreground/60" : "text-muted-foreground"
} animate-fade-in`}
>
<CheckIcon className="h-3 w-3" />
<span>Sent</span>
</div>
)}
</div>
);
}

View File

@ -368,10 +368,12 @@ export function transformEmailMessageToCaseMessage(
const fromName = ensureString(email.FromName) ?? fromEmail ?? "Unknown";
// Determine if this is from the customer
// Incoming emails are from customer, or check email match
// Incoming emails are from customer, or check email match (with trimming for robustness)
const isCustomer =
isIncoming ||
(customerEmail ? fromEmail?.toLowerCase() === customerEmail.toLowerCase() : false);
(customerEmail && fromEmail
? fromEmail.trim().toLowerCase() === customerEmail.trim().toLowerCase()
: false);
// Get the raw email body and clean it (strip quoted content)
const rawBody = ensureString(email.TextBody) ?? ensureString(email.HtmlBody) ?? "";
@ -440,19 +442,21 @@ export function buildCaseCommentsForCaseQuery(caseId: string): string {
* Transform a Salesforce CaseComment to a unified CaseMessage.
*
* @param comment - Raw Salesforce CaseComment
* @param customerContactId - Customer's Salesforce Contact ID for comparison
* @param customerEmail - Customer's email address for comparison
*/
export function transformCaseCommentToCaseMessage(
comment: SalesforceCaseComment,
customerContactId?: string
customerEmail?: string
): CaseMessage {
const authorName = comment.CreatedBy?.Name ?? "Unknown";
const authorEmail = comment.CreatedBy?.Email ?? null;
// Determine if this is from the customer
// If CreatedById matches customer's contact ID, it's from customer
// Otherwise, we assume it's from an agent (conservative approach)
const isCustomer = customerContactId ? comment.CreatedById === customerContactId : false;
// Determine if this is from the customer using email-based matching
// (consistent with EmailMessage approach for unified attribution, with trimming for robustness)
const isCustomer =
customerEmail && authorEmail
? authorEmail.trim().toLowerCase() === customerEmail.trim().toLowerCase()
: false;
return caseMessageSchema.parse({
id: comment.Id,
@ -473,9 +477,9 @@ export function transformCaseCommentToCaseMessage(
*/
export function transformCaseCommentsToCaseMessages(
comments: SalesforceCaseComment[],
customerContactId?: string
customerEmail?: string
): CaseMessage[] {
return comments.map(comment => transformCaseCommentToCaseMessage(comment, customerContactId));
return comments.map(comment => transformCaseCommentToCaseMessage(comment, customerEmail));
}
// ============================================================================