From 2a40d84691fa1f168eced4496ac8bfc8985929b5 Mon Sep 17 00:00:00 2001 From: barsa Date: Mon, 19 Jan 2026 16:29:26 +0900 Subject: [PATCH] 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 --- .../services/salesforce-case.service.ts | 2 +- .../components/common/ServicesGrid.tsx | 146 ------- .../common/ServicesOverviewContent.tsx | 159 ++++++++ .../views/AccountServicesOverview.tsx | 19 +- .../services/views/PublicServicesOverview.tsx | 150 +------- .../support/views/SupportCaseDetailView.tsx | 363 ++++++++++++++++-- .../support/providers/salesforce/mapper.ts | 24 +- 7 files changed, 501 insertions(+), 362 deletions(-) delete mode 100644 apps/portal/src/features/services/components/common/ServicesGrid.tsx create mode 100644 apps/portal/src/features/services/components/common/ServicesOverviewContent.tsx diff --git a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts index cc5d7293..eba4e972 100644 --- a/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts +++ b/apps/bff/src/integrations/salesforce/services/salesforce-case.service.ts @@ -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); diff --git a/apps/portal/src/features/services/components/common/ServicesGrid.tsx b/apps/portal/src/features/services/components/common/ServicesGrid.tsx deleted file mode 100644 index 9bf7d9c3..00000000 --- a/apps/portal/src/features/services/components/common/ServicesGrid.tsx +++ /dev/null @@ -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 ( - -
- {/* Icon */} -
- {icon} -
- - {/* Content */} -

{title}

-

- {description} -

- - {/* Price or Highlight */} -
-
- {price && ( - - From {price} - - )} - {highlight && ( - - {highlight} - - )} - {!price && !highlight && ( - Learn more - )} -
-
- View Plans - -
-
-
- - ); -} - -export function ServicesGrid({ basePath = "/services" }: ServicesGridProps) { - return ( -
- {/* Internet - Featured */} - } - title="Internet" - description="NTT fiber with speeds up to 10Gbps. Professional installation and full support included." - price="¥4,000/mo" - featured - /> - - {/* SIM */} - } - title="SIM & eSIM" - description="Data, voice & SMS on NTT Docomo's network. Physical SIM or instant eSIM." - highlight="No contract" - /> - - {/* VPN */} - } - title="VPN" - description="Access US/UK content with pre-configured routers. Simple setup." - price="¥2,500/mo" - /> - - {/* Business */} - } - title="Business" - description="DIA, Office LAN, and enterprise connectivity solutions." - /> - - {/* Onsite */} - } - title="Onsite" - description="Setup and troubleshooting at your location by certified technicians." - /> - - {/* TV */} - } - title="TV Services" - description="Satellite, cable, and optical fiber TV with international packages." - /> -
- ); -} diff --git a/apps/portal/src/features/services/components/common/ServicesOverviewContent.tsx b/apps/portal/src/features/services/components/common/ServicesOverviewContent.tsx new file mode 100644 index 00000000..de3fd309 --- /dev/null +++ b/apps/portal/src/features/services/components/common/ServicesOverviewContent.tsx @@ -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 ( +
+ {/* Hero */} + + + Full English Support + + } + animated + /> + + {/* Value Props - Compact */} +
+
+ + One provider, all services +
+
+ + English support +
+
+ + No hidden fees +
+
+ + {/* All Services - Clean Grid with staggered animations */} +
+ } + title="Internet" + description="NTT Optical Fiber for homes and apartments. Speeds up to 10Gbps with professional installation." + price="¥3,200/mo" + accentColor="blue" + /> + + } + 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" + /> + + } + 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) */} + } + title="Business" + description="Enterprise solutions for offices and commercial spaces. Dedicated support and SLAs." + accentColor="orange" + /> + + } + title="Onsite Support" + description="Professional technicians visit your location for setup, troubleshooting, and maintenance." + accentColor="cyan" + /> + + } + title="TV" + description="Streaming TV packages with international channels. Watch content from home countries." + accentColor="pink" + /> +
+ + {/* CTA */} + {showCta && ( +
+

+ Need help choosing? +

+

+ Our bilingual team can help you find the right solution. +

+ +
+ + Contact Us + + + + +
+
+ )} +
+ ); +} diff --git a/apps/portal/src/features/services/views/AccountServicesOverview.tsx b/apps/portal/src/features/services/views/AccountServicesOverview.tsx index 5b8cbab3..65bf6d9d 100644 --- a/apps/portal/src/features/services/views/AccountServicesOverview.tsx +++ b/apps/portal/src/features/services/views/AccountServicesOverview.tsx @@ -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 (
- {/* Header */} -
-

- Our Services -

-

- From high-speed internet to onsite support, we provide comprehensive solutions for your - home and business. -

-
- - +
); diff --git a/apps/portal/src/features/services/views/PublicServicesOverview.tsx b/apps/portal/src/features/services/views/PublicServicesOverview.tsx index 71401ec1..d25c0456 100644 --- a/apps/portal/src/features/services/views/PublicServicesOverview.tsx +++ b/apps/portal/src/features/services/views/PublicServicesOverview.tsx @@ -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 ( -
- {/* Hero */} -
-
- - - Full English Support - -
- -

- Our Services -

- -

- Connectivity and support solutions for Japan's international community. -

-
- - {/* Value Props - Compact */} -
-
- - One provider, all services -
-
- - English support -
-
- - No hidden fees -
-
- - {/* All Services - Clean Grid with staggered animations */} -
- } - title="Internet" - description="NTT Optical Fiber for homes and apartments. Speeds up to 10Gbps with professional installation." - price="¥3,200/mo" - accentColor="blue" - /> - - } - 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" - /> - - } - title="VPN Router" - description="Access US & UK streaming content with a pre-configured router. Simple plug-and-play." - price="¥2,500/mo" - accentColor="purple" - /> - - } - title="Business" - description="Enterprise solutions for offices and commercial spaces. Dedicated support and SLAs." - accentColor="orange" - /> - - } - title="Onsite Support" - description="Professional technicians visit your location for setup, troubleshooting, and maintenance." - accentColor="cyan" - /> - - } - title="TV" - description="Streaming TV packages with international channels. Watch content from home countries." - accentColor="pink" - /> -
- - {/* CTA */} -
-

Need help choosing?

-

- Our bilingual team can help you find the right solution. -

- -
- - Contact Us - - - - -
-
-
- ); + return ; } diff --git a/apps/portal/src/features/support/views/SupportCaseDetailView.tsx b/apps/portal/src/features/support/views/SupportCaseDetailView.tsx index d4e5b287..153678e3 100644 --- a/apps/portal/src/features/support/views/SupportCaseDetailView.tsx +++ b/apps/portal/src/features/support/views/SupportCaseDetailView.tsx @@ -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(null); + const [showSentConfirmation, setShowSentConfirmation] = useState(false); + const messagesEndRef = useRef(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; + 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 (
- {messageGroups.map(group => ( -
- {/* Date separator */} -
-
- - {group.dateLabel} - -
-
+ {messageGroups.map(group => { + // Group consecutive messages within this date group + const senderGroups = groupConsecutiveMessages(group.messages); - {/* Messages for this date */} -
- {group.messages.map(message => ( - - ))} + return ( +
+ {/* Date separator */} +
+
+ + {group.dateLabel} + +
+
+ + {/* Messages for this date, grouped by sender */} +
+ {senderGroups.map((senderGroup, groupIndex) => ( + + ))} +
-
- ))} + ); + })} {/* No replies yet message */} {!hasReplies && ( -

- No replies yet. Our team will respond shortly. -

+
+ +

+ Your message has been received! Our support team typically responds within 24 hours. +

+
)} + + {/* Auto-scroll anchor */} +
); } +/** + * 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 ( +
+ {/* Avatar - only show for support (on left side) */} + {!isCustomer && ( +
+ +
+ )} + + {/* Message bubbles */} +
+ {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 ( + + ); + })} +
+ + {/* Avatar - only show for customer (on right side) */} + {isCustomer && ( +
+ +
+ )} +
+ ); +} + /** * 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 ( -
-
- {/* Author and type indicator */} +
+ {/* Author and type indicator - only show on first message of group */} + {showHeader && (
)} - {message.author.name} + {displayName} {formatIsoRelative(message.createdAt)} {hasAttachment && ( @@ -431,11 +683,38 @@ function MessageBubble({ message }: { message: CaseMessage }) { )} + {isNew && ( + + New + + )}
+ )} - {/* Message body */} -

{message.body}

-
+ {/* Message body */} +

{message.body}

+ + {/* Pending/Sent status indicator */} + {isPending && ( +
+ + Sending... +
+ )} + {showSentConfirmation && !isPending && ( +
+ + Sent +
+ )}
); } diff --git a/packages/domain/support/providers/salesforce/mapper.ts b/packages/domain/support/providers/salesforce/mapper.ts index 4382c0b7..678526ae 100644 --- a/packages/domain/support/providers/salesforce/mapper.ts +++ b/packages/domain/support/providers/salesforce/mapper.ts @@ -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)); } // ============================================================================