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:
parent
789e2d95a5
commit
2a40d84691
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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'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" />;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user