diff --git a/p2p-safe-swap/frontend/components/chat/chat-header.tsx b/p2p-safe-swap/frontend/components/chat/chat-header.tsx new file mode 100644 index 0000000..82ea0bb --- /dev/null +++ b/p2p-safe-swap/frontend/components/chat/chat-header.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { cn } from "@/lib/utils"; +import { WalletBadge } from "@/frontend/components/ui/wallet-badge"; +import { truncateAddress } from "./utils"; + +export interface ChatHeaderProps { + counterpartAddress: string; + isOnline?: boolean; + onBack?: () => void; + onMore?: () => void; + className?: string; +} + +export function ChatHeader({ + counterpartAddress, + isOnline = true, + onBack, + onMore, + className, +}: ChatHeaderProps) { + const [copied, setCopied] = useState(false); + const truncated = truncateAddress(counterpartAddress); + + const handleCopy = useCallback(async () => { + if (typeof navigator === "undefined" || !navigator.clipboard) return; + try { + await navigator.clipboard.writeText(counterpartAddress); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + setCopied(false); + } + }, [counterpartAddress]); + + return ( + + {onBack ? ( + + + + + + ) : null} + + + + + + {truncated} + + + + {isOnline ? "Activa ahora" : "Desconectada"} + + + + + {copied ? ( + + + + ) : ( + + + + + )} + + + + + + + + + + + ); +} diff --git a/p2p-safe-swap/frontend/components/chat/chat-input-bar.tsx b/p2p-safe-swap/frontend/components/chat/chat-input-bar.tsx new file mode 100644 index 0000000..01ed05b --- /dev/null +++ b/p2p-safe-swap/frontend/components/chat/chat-input-bar.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState, type FormEvent, type KeyboardEvent } from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/frontend/components/ui/Button/Button"; + +export interface ChatInputBarProps { + onSendMessage: (text: string) => void; + onSendPayment: () => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export function ChatInputBar({ + onSendMessage, + onSendPayment, + placeholder = "Escribe un mensaje…", + disabled = false, + className, +}: ChatInputBarProps) { + const [text, setText] = useState(""); + + const submit = () => { + const trimmed = text.trim(); + if (!trimmed || disabled) return; + onSendMessage(trimmed); + setText(""); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + submit(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + submit(); + } + }; + + const canSend = text.trim().length > 0 && !disabled; + + return ( + + + + + Mensaje + + setText(event.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled} + rows={1} + className={cn( + "min-h-10 max-h-32 flex-1 resize-none rounded-2xl border border-border bg-muted/50 px-4 py-2 text-sm text-foreground placeholder:text-muted-foreground", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + "disabled:cursor-not-allowed disabled:opacity-50" + )} + /> + + + + ); +} diff --git a/p2p-safe-swap/frontend/components/chat/chat-screen.tsx b/p2p-safe-swap/frontend/components/chat/chat-screen.tsx new file mode 100644 index 0000000..7147783 --- /dev/null +++ b/p2p-safe-swap/frontend/components/chat/chat-screen.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useEffect, useMemo, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { PaymentBubble } from "@/frontend/components/PaymentBubble/PaymentBubble"; +import { ChatHeader } from "./chat-header"; +import { ChatInputBar } from "./chat-input-bar"; +import { ChatMessageBubble } from "./chat-message-bubble"; +import { DateSeparator } from "./date-separator"; +import type { ChatMessage } from "./types"; +import { formatTime, groupMessagesByDay } from "./utils"; + +export interface ChatScreenProps { + counterpartAddress: string; + messages: ChatMessage[]; + onSendMessage: (text: string) => void; + onSendPayment: () => void; + onBack?: () => void; + onMore?: () => void; + onViewReceipt?: (messageId: string) => void; + onAcceptPaymentRequest?: (messageId: string) => void; + onRejectPaymentRequest?: (messageId: string) => void; + isOnline?: boolean; + lang?: "es" | "en"; + className?: string; +} + +interface PaymentHandlers { + onViewReceipt?: (messageId: string) => void; + onAcceptPaymentRequest?: (messageId: string) => void; + onRejectPaymentRequest?: (messageId: string) => void; +} + +function renderMessage( + message: ChatMessage, + lang: "es" | "en", + handlers: PaymentHandlers +) { + if (message.type === "text") { + return ; + } + + const isSelf = message.author === "self"; + const isRequest = message.type === "request"; + const variant = isRequest ? "request" : "sent"; + const side = isSelf ? "sender" : "receiver"; + + return ( + + handlers.onAcceptPaymentRequest?.(message.id) + : undefined + } + onReject={ + isRequest + ? () => handlers.onRejectPaymentRequest?.(message.id) + : undefined + } + onViewReceipt={ + !isRequest + ? () => handlers.onViewReceipt?.(message.id) + : undefined + } + /> + + {formatTime(message.timestamp)} + + + ); +} + +export function ChatScreen({ + counterpartAddress, + messages, + onSendMessage, + onSendPayment, + onBack, + onMore, + onViewReceipt, + onAcceptPaymentRequest, + onRejectPaymentRequest, + isOnline, + lang = "es", + className, +}: ChatScreenProps) { + const groups = useMemo(() => groupMessagesByDay(messages), [messages]); + const scrollRef = useRef(null); + + useEffect(() => { + const node = scrollRef.current; + if (!node) return; + node.scrollTop = node.scrollHeight; + }, [messages]); + + const handlers: PaymentHandlers = { + onViewReceipt, + onAcceptPaymentRequest, + onRejectPaymentRequest, + }; + + return ( + + + + + {groups.length === 0 ? ( + + + Sin mensajes aún + + Envía el primer mensaje o un pago para empezar. + + ) : ( + groups.map((group) => ( + + + {group.messages.map((message) => + renderMessage(message, lang, handlers) + )} + + )) + )} + + + + + ); +} diff --git a/p2p-safe-swap/frontend/components/chat/date-separator.tsx b/p2p-safe-swap/frontend/components/chat/date-separator.tsx new file mode 100644 index 0000000..446e791 --- /dev/null +++ b/p2p-safe-swap/frontend/components/chat/date-separator.tsx @@ -0,0 +1,21 @@ +import { cn } from "@/lib/utils"; + +export interface DateSeparatorProps { + label: string; + className?: string; +} + +export function DateSeparator({ label, className }: DateSeparatorProps) { + return ( + + + {label} + + + ); +} diff --git a/p2p-safe-swap/frontend/components/chat/index.ts b/p2p-safe-swap/frontend/components/chat/index.ts index 2a2f0fb..1401de7 100644 --- a/p2p-safe-swap/frontend/components/chat/index.ts +++ b/p2p-safe-swap/frontend/components/chat/index.ts @@ -1,7 +1,19 @@ +export { ChatHeader } from "./chat-header"; +export type { ChatHeaderProps } from "./chat-header"; +export { ChatInputBar } from "./chat-input-bar"; +export type { ChatInputBarProps } from "./chat-input-bar"; export { ChatMessageBubble } from "./chat-message-bubble"; export type { ChatMessageBubbleProps } from "./chat-message-bubble"; +export { ChatScreen } from "./chat-screen"; +export type { ChatScreenProps } from "./chat-screen"; +export { DateSeparator } from "./date-separator"; +export type { DateSeparatorProps } from "./date-separator"; export type { + ChatMessage, ChatMessageBase, MessageAuthor, + PaymentMessage, + PaymentRequestMessage, + PaymentStatus, TextMessage, } from "./types"; diff --git a/p2p-safe-swap/frontend/components/chat/types.ts b/p2p-safe-swap/frontend/components/chat/types.ts index f6d4925..101ebc3 100644 --- a/p2p-safe-swap/frontend/components/chat/types.ts +++ b/p2p-safe-swap/frontend/components/chat/types.ts @@ -11,3 +11,24 @@ export interface TextMessage extends ChatMessageBase { text: string; deliveryStatus?: "sent" | "delivered" | "read"; } + +export type PaymentStatus = "pending" | "completed" | "rejected"; + +export interface PaymentMessage extends ChatMessageBase { + type: "payment"; + amount: number; + currency: string; + memo?: string; + status: PaymentStatus; + receiptUrl?: string; +} + +export interface PaymentRequestMessage extends ChatMessageBase { + type: "request"; + amount: number; + currency: string; + memo?: string; + status: PaymentStatus; +} + +export type ChatMessage = TextMessage | PaymentMessage | PaymentRequestMessage; diff --git a/p2p-safe-swap/frontend/components/chat/utils.ts b/p2p-safe-swap/frontend/components/chat/utils.ts index 3067732..80000fe 100644 --- a/p2p-safe-swap/frontend/components/chat/utils.ts +++ b/p2p-safe-swap/frontend/components/chat/utils.ts @@ -1,3 +1,5 @@ +import type { ChatMessage } from "./types"; + export function formatTime(timestamp: string | Date): string { const date = typeof timestamp === "string" ? new Date(timestamp) : timestamp; return new Intl.DateTimeFormat("es-ES", { @@ -6,3 +8,66 @@ export function formatTime(timestamp: string | Date): string { hour12: false, }).format(date); } + +export function truncateAddress(address: string): string { + if (address.length <= 12) return address; + return `${address.slice(0, 6)}…${address.slice(-4)}`; +} + +function startOfDay(date: Date): number { + const copy = new Date(date); + copy.setHours(0, 0, 0, 0); + return copy.getTime(); +} + +export function formatDayLabel(timestamp: string | Date): string { + const date = typeof timestamp === "string" ? new Date(timestamp) : timestamp; + const today = startOfDay(new Date()); + const target = startOfDay(date); + const dayMs = 24 * 60 * 60 * 1000; + + if (target === today) return "HOY"; + if (target === today - dayMs) return "AYER"; + + return new Intl.DateTimeFormat("es-ES", { + day: "2-digit", + month: "long", + }) + .format(date) + .toUpperCase(); +} + +export interface ChatMessageGroup { + dayKey: number; + dayLabel: string; + messages: ChatMessage[]; +} + +export function groupMessagesByDay(messages: ChatMessage[]): ChatMessageGroup[] { + const sorted = [...messages].sort((a, b) => { + const aTime = new Date(a.timestamp).getTime(); + const bTime = new Date(b.timestamp).getTime(); + return aTime - bTime; + }); + + const groups: ChatMessageGroup[] = []; + + for (const message of sorted) { + const date = new Date(message.timestamp); + const dayKey = startOfDay(date); + const last = groups[groups.length - 1]; + + if (last && last.dayKey === dayKey) { + last.messages.push(message); + continue; + } + + groups.push({ + dayKey, + dayLabel: formatDayLabel(date), + messages: [message], + }); + } + + return groups; +}