- {item.metadata?.actorUsername} valoró{' '}
+ {renderActorLink()} valoró{' '}
{item.metadata?.beatTitle}
diff --git a/src/components/forms/BeatForm.jsx b/src/components/forms/BeatForm.jsx
index 1149cb1..10a8e33 100644
--- a/src/components/forms/BeatForm.jsx
+++ b/src/components/forms/BeatForm.jsx
@@ -226,7 +226,7 @@ const BeatForm = ({
)}
{/* Cover Art Section */}
-
+
diff --git a/src/components/ui/messages/SendMessageButton.css b/src/components/ui/messages/SendMessageButton.css
new file mode 100644
index 0000000..d794ade
--- /dev/null
+++ b/src/components/ui/messages/SendMessageButton.css
@@ -0,0 +1,36 @@
+.sendMessageBtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+
+ padding: 6px 12px;
+ font-size: 14px;
+ font-weight: 500;
+
+ color: #ffffff;
+ background: rgba(0, 0, 0, 0.25);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 8px;
+
+ cursor: pointer;
+ user-select: none;
+ transition:
+ background 0.15s ease,
+ transform 0.15s ease,
+ opacity 0.15s ease;
+}
+
+.sendMessageBtn:hover:not(:disabled) {
+ background: rgba(0, 0, 0, 0.35);
+ transform: translateY(-1px);
+}
+
+.sendMessageBtn:active:not(:disabled) {
+ transform: translateY(0);
+}
+
+.sendMessageBtn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
\ No newline at end of file
diff --git a/src/components/ui/messages/SendMessageButton.jsx b/src/components/ui/messages/SendMessageButton.jsx
new file mode 100644
index 0000000..ff3cee6
--- /dev/null
+++ b/src/components/ui/messages/SendMessageButton.jsx
@@ -0,0 +1,52 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { upsertDirectConversation } from '@/services/messagingService';
+import './SendMessageButton.css';
+
+export default function SendMessageButton({
+ otherUserId,
+ otherUsername, // opcional
+ label = 'Enviar mensaje',
+ className = '',
+}) {
+ const navigate = useNavigate();
+ const [loading, setLoading] = useState(false);
+
+ // Sin userId no tiene sentido renderizar el botón
+ if (!otherUserId) return null;
+
+ async function handleClick() {
+ if (loading) return;
+
+ setLoading(true);
+ try {
+ const res = await upsertDirectConversation({ otherUserId });
+ const conversationId = res?.conversation?._id;
+
+ if (conversationId) {
+ navigate(`/app/messages/${conversationId}`, {
+ state: {
+ otherUserId,
+ otherUsername,
+ },
+ });
+ }
+ } catch (e) {
+ console.error('Error al abrir o crear la conversación', e);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/Pricing.jsx b/src/pages/Pricing.jsx
index 04b296f..553a090 100644
--- a/src/pages/Pricing.jsx
+++ b/src/pages/Pricing.jsx
@@ -13,7 +13,7 @@ import { logger } from '../logger';
import PricingConfirmModal from './pricing/PricingConfirmModal';
import './Pricing.css';
-// Plan configuration - matches backend plans.config.js
+// Plan configuration - matches backend plans.config.js and SocialBeats-1.1.yaml
const plans = [
{
name: 'FREE',
@@ -27,11 +27,16 @@ const plans = [
features: [
{ text: 'Escucha beats ilimitados', included: true },
{ text: 'Máximo 3 beats subidos', included: true },
- { text: 'Perfil básico de artista', included: true },
- { text: 'Estadísticas básicas', included: true },
+ { text: 'Tamaño máximo por beat: 10MB', included: true },
+ { text: 'Almacenamiento: 30MB', included: true },
+ { text: 'Hasta 5 certificaciones', included: true },
+ { text: '1 playlist', included: true },
+ { text: '3 dashboards de analíticas', included: true },
+ { text: 'Perfil avanzado de artista', included: true },
{ text: 'Banner personalizado', included: false },
{ text: 'Cover de perfil', included: false },
- { text: 'Beats destacados', included: false },
+ { text: 'Decorativos de perfil', included: false },
+ { text: 'Descarga de beats', included: false },
],
},
{
@@ -45,12 +50,17 @@ const plans = [
popular: true,
features: [
{ text: 'Escucha beats ilimitados', included: true },
- { text: 'Máximo 25 beats subidos', included: true },
- { text: 'Perfil verificado de artista', included: true },
- { text: 'Estadísticas avanzadas', included: true },
+ { text: 'Máximo 30 beats subidos', included: true },
+ { text: 'Tamaño máximo por beat: 25MB', included: true },
+ { text: 'Almacenamiento: 750MB', included: true },
+ { text: 'Hasta 10 certificaciones', included: true },
+ { text: '3 playlists', included: true },
+ { text: '30 dashboards de analíticas', included: true },
+ { text: 'Perfil avanzado de artista', included: true },
{ text: 'Banner personalizado', included: true },
{ text: 'Cover de perfil', included: true },
- { text: 'Hasta 3 beats destacados', included: true },
+ { text: 'Decorativos de perfil', included: false },
+ { text: 'Descarga de beats', included: false },
],
},
{
@@ -64,12 +74,17 @@ const plans = [
popular: false,
features: [
{ text: 'Escucha beats ilimitados', included: true },
- { text: 'Beats ilimitados subidos', included: true },
- { text: 'Perfil verificado premium', included: true },
- { text: 'Estadísticas completas + insights', included: true },
+ { text: 'Beats ilimitados', included: true },
+ { text: 'Tamaño máximo por beat: 50MB', included: true },
+ { text: 'Almacenamiento: 1GB', included: true },
+ { text: 'Certificaciones ilimitadas', included: true },
+ { text: '30 playlists', included: true },
+ { text: 'Dashboards ilimitados', included: true },
+ { text: 'Perfil avanzado de artista', included: true },
{ text: 'Banner personalizado', included: true },
{ text: 'Cover de perfil', included: true },
- { text: 'Beats destacados ilimitados', included: true },
+ { text: 'Decorativos de perfil', included: true },
+ { text: 'Descarga de beats', included: true },
],
}
];
diff --git a/src/pages/app/explore/components/BeatCard.tsx b/src/pages/app/explore/components/BeatCard.tsx
index 662c0ef..e960e6e 100644
--- a/src/pages/app/explore/components/BeatCard.tsx
+++ b/src/pages/app/explore/components/BeatCard.tsx
@@ -4,6 +4,7 @@ import './BeatCard.css';
import { usePlayerStore } from '../../../../store/usePlayerStore';
import { Beat } from '../../../../store/usePlayerStore';
import { getAudioStreamUrl } from '../../../../services/beatsService';
+import logo from '../../../../assets/logo-dark-no-fondo.png';
/**
* BeatCard - Tarjeta de beat para la vista de exploración
@@ -80,14 +81,17 @@ export default function BeatCard({ beat, variant = 'default', onClick }: BeatCar
const beatId = beat._id || beat.id;
const { streamUrl, coverUrl } = await getAudioStreamUrl(beatId);
+ // Usar defaultCover como fallback si no hay cover disponible
+ const finalCover = coverUrl || coverImageUrl || defaultCover;
+
const beatForStore: Beat = {
...beat,
id: beatId,
author: beat.createdBy?.username || 'Unknown',
- cover: coverUrl || coverImageUrl || undefined,
+ cover: finalCover,
audio: {
url: streamUrl,
- coverUrl: coverUrl || coverImageUrl || undefined,
+ coverUrl: finalCover,
duration: beat.duration
}
};
@@ -100,14 +104,8 @@ export default function BeatCard({ beat, variant = 'default', onClick }: BeatCar
}
};
- // Imagen por defecto si no hay cover
- const defaultCover = 'data:image/svg+xml,' + encodeURIComponent(`
-
- `);
+ // Imagen por defecto si no hay cover (mismo logo que en BeatDetailPage y PlaylistDetails)
+ const defaultCover = logo;
return (
>> 0;
+ return h % 360;
+}
+
+export default function ConversationThreadPage() {
+ const { id: conversationId } = useParams();
+
+ const location = useLocation();
+ const navState = location.state || {};
+ const navOtherUserId = normalizeId(navState.otherUserId);
+ const navOtherUsername = navState.otherUsername;
+
+ const [items, setItems] = useState([]);
+ const [hasMore, setHasMore] = useState(false);
+ const [before, setBefore] = useState(null);
+
+ const [status, setStatus] = useState('idle'); // idle | loading | error
+ const [error, setError] = useState(null);
+
+ const [text, setText] = useState('');
+ const [sending, setSending] = useState(false);
+
+ const knownMessageIdsRef = useRef(new Set());
+ const scrollerRef = useRef(null);
+
+ // perfiles: userId -> { loading, data, error }
+ const [profiles, setProfiles] = useState({});
+ const inFlightProfilesRef = useRef(new Set());
+
+ const myUserId = useMemo(() => normalizeId(getCurrentUserId()), []);
+
+ const uniqueSenderIds = useMemo(() => {
+ const set = new Set();
+ for (const m of items) {
+ if (m?.senderId) set.add(String(m.senderId));
+ }
+ return Array.from(set);
+ }, [items]);
+
+ const otherUserId = useMemo(() => {
+ if (navOtherUserId) return navOtherUserId;
+
+ const mine = normalizeId(myUserId);
+ if (!mine) return uniqueSenderIds[0] || '';
+
+ const other = uniqueSenderIds.find((id) => normalizeId(id) !== mine);
+ return other || '';
+ }, [navOtherUserId, myUserId, uniqueSenderIds]);
+
+ useEffect(() => {
+ if (otherUserId) {
+ ensureProfilesFor([otherUserId]);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [otherUserId]);
+
+ const otherProfile = otherUserId ? profiles?.[otherUserId]?.data : null;
+ const otherName = navOtherUsername || pickDisplayName(otherProfile);
+
+ async function ensureProfilesFor(userIds) {
+ const ids = Array.from(new Set((userIds || []).filter(Boolean).map((x) => String(x))));
+ const toFetch = ids.filter((id) => !profiles[id] && !inFlightProfilesRef.current.has(id));
+ if (toFetch.length === 0) return;
+
+ toFetch.forEach((id) => inFlightProfilesRef.current.add(id));
+ setProfiles((prev) => {
+ const next = { ...prev };
+ toFetch.forEach((id) => {
+ next[id] = { loading: true, data: null, error: null };
+ });
+ return next;
+ });
+
+ await Promise.all(
+ toFetch.map(async (id) => {
+ try {
+ const data = await getProfileInfoByUserId(id);
+ setProfiles((prev) => ({ ...prev, [id]: { loading: false, data, error: null } }));
+ } catch (e) {
+ setProfiles((prev) => ({ ...prev, [id]: { loading: false, data: null, error: e } }));
+ } finally {
+ inFlightProfilesRef.current.delete(id);
+ }
+ })
+ );
+ }
+
+ function scrollToBottom({ smooth = false } = {}) {
+ const el = scrollerRef.current;
+ if (!el) return;
+ const behavior = smooth ? 'smooth' : 'auto';
+ el.scrollTo({ top: el.scrollHeight, behavior });
+ }
+
+ function isNearBottom() {
+ const el = scrollerRef.current;
+ if (!el) return true;
+ const threshold = 220;
+ const remaining = el.scrollHeight - (el.scrollTop + el.clientHeight);
+ return remaining < threshold;
+ }
+
+ async function loadFirstPage() {
+ setStatus('loading');
+ setError(null);
+ try {
+ const data = await listMessages(conversationId, { limit: 40 });
+ const msgs = data.items || [];
+ setItems(msgs);
+ setHasMore(Boolean(data.hasMore));
+ setBefore(data.nextCursor || null);
+
+ knownMessageIdsRef.current = new Set(msgs.map((m) => normalizeId(m._id)));
+ ensureProfilesFor(msgs.map((m) => m?.senderId));
+
+ setStatus('idle');
+ requestAnimationFrame(() => scrollToBottom({ smooth: false }));
+ } catch (e) {
+ setStatus('error');
+ setError(e);
+ }
+ }
+
+ async function loadOlder() {
+ if (!hasMore || !before) return;
+ const el = scrollerRef.current;
+
+ const prevScrollHeight = el?.scrollHeight || 0;
+ const prevScrollTop = el?.scrollTop || 0;
+
+ setStatus('loading');
+ setError(null);
+
+ try {
+ const data = await listMessages(conversationId, { before, limit: 35 });
+ const older = data.items || [];
+
+ setItems((prev) => {
+ const merged = [...older, ...prev];
+ for (const m of older) knownMessageIdsRef.current.add(normalizeId(m._id));
+ return merged;
+ });
+
+ setHasMore(Boolean(data.hasMore));
+ setBefore(data.nextCursor || null);
+
+ ensureProfilesFor(older.map((m) => m?.senderId));
+
+ setStatus('idle');
+
+ requestAnimationFrame(() => {
+ const el2 = scrollerRef.current;
+ if (!el2) return;
+ const newScrollHeight = el2.scrollHeight;
+ const delta = newScrollHeight - prevScrollHeight;
+ el2.scrollTop = prevScrollTop + delta;
+ });
+ } catch (e) {
+ setStatus('error');
+ setError(e);
+ }
+ }
+
+ async function onSubmit(e) {
+ e.preventDefault();
+ const trimmed = text.trim();
+ if (!trimmed) return;
+
+ setSending(true);
+ try {
+ const res = await sendMessage(conversationId, { text: trimmed });
+ const msg = res?.message;
+
+ if (msg && !knownMessageIdsRef.current.has(normalizeId(msg._id))) {
+ knownMessageIdsRef.current.add(normalizeId(msg._id));
+ setItems((prev) => [...prev, msg]);
+ ensureProfilesFor([msg?.senderId]);
+ }
+
+ setText('');
+ requestAnimationFrame(() => scrollToBottom({ smooth: true }));
+ } finally {
+ setSending(false);
+ }
+ }
+
+ useEffect(() => {
+ if (!conversationId) return;
+ loadFirstPage();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [conversationId]);
+
+ useEffect(() => {
+ const handler = ({ conversationId: incomingConvoId, message }) => {
+ if (normalizeId(incomingConvoId) !== normalizeId(conversationId)) return;
+ if (!message) return;
+
+ const mid = normalizeId(message._id);
+ if (knownMessageIdsRef.current.has(mid)) return;
+
+ const shouldStick = isNearBottom();
+ knownMessageIdsRef.current.add(mid);
+ setItems((prev) => [...prev, message]);
+ ensureProfilesFor([message?.senderId]);
+
+ requestAnimationFrame(() => {
+ if (shouldStick) scrollToBottom({ smooth: true });
+ });
+ };
+
+ onMessageNew(handler);
+ return () => offMessageNew(handler);
+ }, [conversationId]);
+
+ // Separadores por día
+ const renderRows = useMemo(() => {
+ const rows = [];
+ let lastDayLabel = '';
+
+ const mine = normalizeId(myUserId);
+
+ for (const m of items) {
+ const dayLabel = formatDayLabel(m?.createdAt);
+ if (dayLabel && dayLabel !== lastDayLabel) {
+ rows.push({ kind: 'day', key: `day-${dayLabel}-${m._id}`, dayLabel });
+ lastDayLabel = dayLabel;
+ }
+
+ const senderId = normalizeId(m?.senderId);
+
+ // si el senderId es miUserId -> DERECHA si no -> IZQUIERDA
+ const alignRight = senderId && mine && senderId === mine;
+
+ rows.push({
+ kind: 'msg',
+ key: normalizeId(m?._id) || `${m?.createdAt || ''}-${senderId}-${m?.text || ''}`,
+ message: m,
+ alignRight,
+ });
+ }
+
+ return rows;
+ }, [items, myUserId]);
+
+ const otherHue = hashToHue(otherUserId || conversationId);
+ const otherAvatarBg = `linear-gradient(135deg, hsla(${otherHue}, 85%, 58%, 0.95), hsla(${
+ (otherHue + 28) % 360
+ }, 85%, 48%, 0.95))`;
+
+ return (
+
+
+
+
+
+ {/* Header */}
+
+
{
+ e.currentTarget.style.transform = 'translateY(-1px)';
+ e.currentTarget.style.background = 'rgba(0,0,0,0.26)';
+ e.currentTarget.style.borderColor = 'rgba(255,255,255,0.18)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.transform = 'translateY(0px)';
+ e.currentTarget.style.background = 'rgba(0,0,0,0.18)';
+ e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)';
+ }}
+ aria-label="Volver a chats"
+ title="Volver"
+ >
+
+
+
+
+ {safeInitialFromName(otherName)}
+
+
+
+
+ {otherName}
+
+
+ {status === 'loading' ? 'Cargando mensajes…' : 'Chat activo'}
+
+
+
+
+ {status === 'error' && (
+
+
No se pudieron cargar los mensajes.
+
+ {error ? (
+
+ {String(error?.message || error)}
+
+ ) : null}
+
+ )}
+
+ {/* Scroll area */}
+
+
+
+
+ {hasMore && (
+
+
+
+ )}
+
+ {renderRows.map((row) => {
+ if (row.kind === 'day') {
+ return (
+
+ );
+ }
+
+ const m = row.message;
+ const alignRight = row.alignRight;
+ const time = formatTime(m?.createdAt);
+
+ const rowClass = alignRight ? 'ctp-row ctp-rowRight' : 'ctp-row ctp-rowLeft';
+ const bubbleClass = alignRight
+ ? 'ctp-bubbleBase ctp-bubbleRight'
+ : 'ctp-bubbleBase ctp-bubbleLeft';
+ const timeClass = alignRight
+ ? 'ctp-timeAbove ctp-timeAboveRight'
+ : 'ctp-timeAbove ctp-timeAboveLeft';
+
+ return (
+
+
+ {time ?
{time}
: null}
+
+
+
+ );
+ })}
+
+ {items.length === 0 && status !== 'loading' && status !== 'error' && (
+
Aún no hay mensajes. Di “hola”.
+ )}
+
+
+
+ {/* Composer (siempre visible) */}
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/app/messages/ConversationsPage.css b/src/pages/app/messages/ConversationsPage.css
new file mode 100644
index 0000000..24f3b72
--- /dev/null
+++ b/src/pages/app/messages/ConversationsPage.css
@@ -0,0 +1,597 @@
+.sb-messages-scope,
+.sb-messages-scope * {
+ box-sizing: border-box;
+}
+
+.sb-messages-scope h2,
+.sb-messages-scope p {
+ margin: 0;
+}
+
+@keyframes sbFloatA {
+ 0%,
+ 100% {
+ transform: translate(0px, 0px) scale(1);
+ }
+ 50% {
+ transform: translate(22px, 18px) scale(1.03);
+ }
+}
+
+@keyframes sbFloatB {
+ 0%,
+ 100% {
+ transform: translate(0px, 0px) scale(1);
+ }
+ 50% {
+ transform: translate(-18px, 22px) scale(1.02);
+ }
+}
+
+@keyframes sbFloatC {
+ 0%,
+ 100% {
+ transform: translate(0px, 0px) scale(1);
+ }
+ 50% {
+ transform: translate(10px, -16px) scale(1.02);
+ }
+}
+
+@keyframes sbBubble1 {
+ 0%,
+ 100% {
+ transform: translate(0px, 0px) scale(1);
+ opacity: 0.7;
+ }
+ 50% {
+ transform: translate(16px, -18px) scale(1.06);
+ opacity: 0.82;
+ }
+}
+
+@keyframes sbBubble2 {
+ 0%,
+ 100% {
+ transform: translate(0px, 0px) scale(1);
+ opacity: 0.66;
+ }
+ 50% {
+ transform: translate(-14px, 16px) scale(1.05);
+ opacity: 0.78;
+ }
+}
+
+@keyframes sbBubble3 {
+ 0%,
+ 100% {
+ transform: translate(0px, 0px) scale(1);
+ opacity: 0.62;
+ }
+ 50% {
+ transform: translate(10px, 14px) scale(1.07);
+ opacity: 0.74;
+ }
+}
+
+@keyframes sbBubble4 {
+ 0%,
+ 100% {
+ transform: translate(0px, 0px) scale(1);
+ opacity: 0.6;
+ }
+ 50% {
+ transform: translate(-12px, -16px) scale(1.06);
+ opacity: 0.72;
+ }
+}
+
+@keyframes sbBubble5 {
+ 0%,
+ 100% {
+ transform: translate(0px, 0px) scale(1);
+ opacity: 0.56;
+ }
+ 50% {
+ transform: translate(14px, 10px) scale(1.05);
+ opacity: 0.68;
+ }
+}
+
+/* Page/layout */
+.cp-page {
+ width: 100%;
+ margin: 0;
+ padding: 0;
+ position: relative;
+ display: block;
+ min-height: 100vh;
+ overflow: visible;
+ box-sizing: border-box;
+}
+
+.cp-reset {
+ box-sizing: border-box;
+}
+
+.cp-centered {
+ max-width: 1200px;
+ width: 100%;
+ margin: 0 auto;
+ padding: 22px 22px 30px;
+ box-sizing: border-box;
+ position: relative;
+}
+
+.cp-content {
+ position: relative;
+ z-index: 1;
+}
+
+/* Decorative blobs */
+.cp-decorWrap {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ overflow: visible;
+ z-index: 0;
+}
+
+.cp-decorCanvas {
+ position: absolute;
+ inset: -260px;
+ pointer-events: none;
+ overflow: visible;
+}
+
+.cp-blobA {
+ position: absolute;
+ width: 620px;
+ height: 620px;
+ left: -180px;
+ top: -180px;
+ border-radius: 999px;
+ background: radial-gradient(circle at 30% 30%, rgba(168, 85, 247, 0.22), transparent 62%);
+ opacity: 0.95;
+ animation: sbFloatA 12s ease-in-out infinite;
+ filter: blur(0.2px);
+}
+
+.cp-blobB {
+ position: absolute;
+ width: 760px;
+ height: 760px;
+ right: -260px;
+ top: -40px;
+ border-radius: 999px;
+ background: radial-gradient(circle at 70% 30%, rgba(59, 130, 246, 0.18), transparent 60%);
+ opacity: 0.9;
+ animation: sbFloatB 14s ease-in-out infinite;
+}
+
+.cp-blobC {
+ position: absolute;
+ width: 920px;
+ height: 920px;
+ left: 20%;
+ bottom: -620px;
+ border-radius: 999px;
+ background: radial-gradient(circle at 50% 50%, rgba(236, 72, 153, 0.1), transparent 62%);
+ opacity: 0.85;
+ animation: sbFloatC 16s ease-in-out infinite;
+}
+
+/* Extra bubbles */
+.cp-bubble1 {
+ position: absolute;
+ width: 260px;
+ height: 260px;
+ left: 8%;
+ top: 18%;
+ border-radius: 999px;
+ background: radial-gradient(circle at 30% 30%, rgba(56, 189, 248, 0.18), transparent 62%);
+ opacity: 0.75;
+ animation: sbBubble1 18s ease-in-out infinite;
+ filter: blur(0.6px);
+}
+
+.cp-bubble2 {
+ position: absolute;
+ width: 320px;
+ height: 320px;
+ right: 10%;
+ top: 28%;
+ border-radius: 999px;
+ background: radial-gradient(circle at 70% 35%, rgba(167, 139, 250, 0.18), transparent 62%);
+ opacity: 0.7;
+ animation: sbBubble2 22s ease-in-out infinite;
+ filter: blur(0.8px);
+}
+
+.cp-bubble3 {
+ position: absolute;
+ width: 220px;
+ height: 220px;
+ left: 36%;
+ top: 6%;
+ border-radius: 999px;
+ background: radial-gradient(circle at 55% 35%, rgba(244, 114, 182, 0.12), transparent 64%);
+ opacity: 0.7;
+ animation: sbBubble3 20s ease-in-out infinite;
+}
+
+.cp-bubble4 {
+ position: absolute;
+ width: 360px;
+ height: 360px;
+ right: -6%;
+ bottom: 14%;
+ border-radius: 999px;
+ background: radial-gradient(circle at 40% 45%, rgba(34, 211, 238, 0.14), transparent 64%);
+ opacity: 0.65;
+ animation: sbBubble4 24s ease-in-out infinite;
+ filter: blur(0.8px);
+}
+
+.cp-bubble5 {
+ position: absolute;
+ width: 240px;
+ height: 240px;
+ left: -4%;
+ bottom: 18%;
+ border-radius: 999px;
+ background: radial-gradient(circle at 55% 55%, rgba(59, 130, 246, 0.12), transparent 66%);
+ opacity: 0.6;
+ animation: sbBubble5 26s ease-in-out infinite;
+ filter: blur(0.7px);
+}
+
+/* Header */
+.cp-headerRow {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 12px;
+ margin: 0 0 18px 0;
+ padding: 0;
+}
+
+.cp-titleWrap {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.cp-title {
+ margin: 0;
+ font-size: 44px;
+ letter-spacing: -0.9px;
+ font-weight: 950;
+ color: rgba(255, 255, 255, 0.97);
+ text-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
+ line-height: 1.05;
+}
+
+/* Shell */
+.cp-shell {
+ width: 100%;
+ border-radius: 22px;
+ padding: 18px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.06));
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ box-shadow: 0 26px 85px rgba(0, 0, 0, 0.28);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ position: relative;
+ overflow: hidden;
+ box-sizing: border-box;
+ min-height: 560px;
+}
+
+.cp-shellPattern {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ opacity: 0.22;
+ background-image: radial-gradient(rgba(255, 255, 255, 0.12) 1px, transparent 1px);
+ background-size: 18px 18px;
+ mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1), rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0));
+ -webkit-mask-image: linear-gradient(
+ to bottom,
+ rgba(0, 0, 0, 1),
+ rgba(0, 0, 0, 0.55),
+ rgba(0, 0, 0, 0)
+ );
+}
+
+/* Search */
+.cp-searchWrap {
+ position: relative;
+ margin: 6px 0 18px;
+}
+
+.cp-searchIcon {
+ position: absolute;
+ left: 14px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 18px;
+ height: 18px;
+ opacity: 0.78;
+ pointer-events: none;
+ color: rgba(255, 255, 255, 0.88);
+}
+
+.cp-input {
+ width: 100%;
+ padding: 14px 14px 14px 42px;
+ border-radius: 16px;
+ outline: none;
+ border: 1px solid rgba(255, 255, 255, 0.16);
+ background: linear-gradient(180deg, rgba(0, 0, 0, 0.32), rgba(0, 0, 0, 0.22));
+ color: rgba(255, 255, 255, 0.92);
+ font-size: 16px;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 10px 28px rgba(0, 0, 0, 0.14);
+ box-sizing: border-box;
+}
+
+/* List */
+.cp-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.cp-item {
+ border-radius: 20px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.095), rgba(255, 255, 255, 0.05));
+ box-shadow: 0 14px 36px rgba(0, 0, 0, 0.22);
+ overflow: hidden;
+ transition: transform 160ms ease, border-color 160ms ease, background 160ms ease,
+ box-shadow 160ms ease;
+}
+
+.cp-itemInner {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 16px 16px;
+ min-height: 88px;
+}
+
+.cp-avatar {
+ width: 58px;
+ height: 58px;
+ border-radius: 999px;
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ color: rgba(255, 255, 255, 0.95);
+ font-weight: 950;
+ font-size: 18px;
+ letter-spacing: 0.3px;
+ border: 1px solid rgba(255, 255, 255, 0.22);
+ box-shadow: 0 16px 42px rgba(0, 0, 0, 0.26);
+}
+
+.cp-main {
+ flex: 1;
+ min-width: 0;
+ text-align: left;
+ display: grid;
+ grid-template-columns: 1fr auto;
+ grid-template-rows: auto auto;
+ column-gap: 14px;
+ row-gap: 6px;
+ align-items: center;
+}
+
+.cp-name {
+ grid-column: 1 / 2;
+ grid-row: 1 / 2;
+ justify-self: start;
+ font-weight: 950;
+ font-size: 18px;
+ color: rgba(255, 255, 255, 0.96);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ letter-spacing: -0.2px;
+ margin: 0;
+ line-height: 1.1;
+}
+
+.cp-loadingTag {
+ margin-left: 10px;
+ font-size: 12px;
+ opacity: 0.6;
+ font-weight: 800;
+}
+
+.cp-preview {
+ grid-column: 1 / 2;
+ grid-row: 2 / 3;
+ justify-self: start;
+ font-size: 15.5px;
+ color: rgba(255, 255, 255, 0.76);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ line-height: 1.25;
+ text-align: left;
+ margin: 0;
+}
+
+/* Date/Time pills */
+.cp-dateTimeWrap {
+ grid-column: 2 / 3;
+ grid-row: 1 / 3;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: stretch;
+ gap: 6px;
+ min-width: 84px;
+}
+
+.cp-datePill,
+.cp-timePill {
+ padding: 6px 12px;
+ border-radius: 12px;
+ background: rgba(0, 0, 0, 0.18);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ text-align: center;
+ line-height: 14px;
+}
+
+.cp-dateText {
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.72);
+ font-weight: 850;
+ letter-spacing: 0.2px;
+ white-space: nowrap;
+}
+
+.cp-timeText {
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.56);
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+/* Right column + open button */
+.cp-rightCol {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex: 0 0 auto;
+ margin-left: 10px;
+}
+
+.cp-openButton {
+ height: 62px;
+ min-width: 112px;
+ padding: 0 14px;
+ border-radius: 14px;
+ border: 1px solid rgba(0, 0, 0, 0.18);
+ background: linear-gradient(180deg, rgba(253, 230, 138, 0.98), rgba(252, 211, 77, 0.98));
+ color: rgba(10, 10, 10, 0.92);
+ display: inline-flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 15px;
+ font-weight: 950;
+ text-decoration: none;
+ box-shadow: 0 16px 36px rgba(0, 0, 0, 0.22);
+ cursor: pointer;
+ user-select: none;
+ white-space: nowrap;
+ transition: transform 150ms ease, filter 150ms ease, box-shadow 150ms ease;
+}
+
+.cp-openLabel {
+ line-height: 1;
+ padding-top: 1px;
+}
+
+.cp-playCircle {
+ width: 34px;
+ height: 34px;
+ border-radius: 999px;
+ background: rgba(0, 0, 0, 0.14);
+ border: 1px solid rgba(0, 0, 0, 0.18);
+ display: grid;
+ place-items: center;
+ flex: 0 0 auto;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
+}
+
+.cp-playIcon {
+ width: 16px;
+ height: 16px;
+ color: rgba(10, 10, 10, 0.92);
+}
+
+/* Filler */
+.cp-fillerArea {
+ margin-top: 12px;
+ border-radius: 18px;
+ border: 1px dashed rgba(255, 255, 255, 0.1);
+ background: linear-gradient(180deg, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.1));
+ opacity: 0.9;
+ position: relative;
+ overflow: hidden;
+}
+
+.cp-fillerGrid {
+ position: absolute;
+ inset: 0;
+ background-image: linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px);
+ background-size: 42px 42px;
+ opacity: 0.15;
+}
+
+/* State box + buttons */
+.cp-stateBox {
+ padding: 14px;
+ border-radius: 16px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(0, 0, 0, 0.22);
+ color: rgba(255, 255, 255, 0.85);
+ margin-top: 10px;
+}
+
+.cp-errorText {
+ margin: 0 0 10px;
+}
+
+.cp-errorDetails {
+ margin-top: 10px;
+ font-size: 12px;
+ opacity: 0.75;
+}
+
+.cp-button {
+ padding: 10px 12px;
+ border-radius: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ background: rgba(0, 0, 0, 0.22);
+ color: rgba(255, 255, 255, 0.9);
+ font-weight: 900;
+ cursor: pointer;
+}
+
+.cp-loadMoreWrap {
+ margin-top: 16px;
+ display: flex;
+ justify-content: center;
+}
+
+.cp-helperRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.cp-infoText {
+ margin: 0;
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.68);
+}
+
+.cp-rowDivider {
+ height: 1px;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(255, 255, 255, 0.1),
+ transparent
+ );
+}
\ No newline at end of file
diff --git a/src/pages/app/messages/ConversationsPage.jsx b/src/pages/app/messages/ConversationsPage.jsx
new file mode 100644
index 0000000..939deb4
--- /dev/null
+++ b/src/pages/app/messages/ConversationsPage.jsx
@@ -0,0 +1,396 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { Link } from 'react-router-dom';
+import { listConversations } from '@/services/messagingService';
+import { onMessageNew, offMessageNew } from '@/services/messagingSocket';
+import { getProfileInfoByUserId } from '@/services/profileService';
+
+import './ConversationsPage.css';
+
+function formatDateAndTime(isoOrDate) {
+ if (!isoOrDate) return { date: '', time: '' };
+ const d = new Date(isoOrDate);
+ if (Number.isNaN(d.getTime())) return { date: '', time: '' };
+
+ const now = new Date();
+ const isSameDay =
+ d.getFullYear() === now.getFullYear() &&
+ d.getMonth() === now.getMonth() &&
+ d.getDate() === now.getDate();
+
+ const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
+ const date = isSameDay
+ ? 'Hoy'
+ : d.toLocaleDateString(undefined, { day: '2-digit', month: 'short' });
+
+ return { date, time };
+}
+
+function safeInitialFromName(nameOrId) {
+ const s = String(nameOrId || '').trim();
+ if (!s) return 'C';
+ const letter = s[0]?.toUpperCase() || 'C';
+ return /[A-Z0-9]/i.test(letter) ? letter.toUpperCase() : 'C';
+}
+
+function hashToHue(input) {
+ const str = String(input || '');
+ let h = 0;
+ for (let i = 0; i < str.length; i += 1) h = (h * 31 + str.charCodeAt(i)) >>> 0;
+ return h % 360;
+}
+
+function pickDisplayName(profile) {
+ const u = String(profile?.username || '').trim();
+ return u ? u : 'Usuario';
+}
+
+export default function ConversationsPage() {
+ const [items, setItems] = useState([]);
+ const [hasMore, setHasMore] = useState(false);
+ const [cursor, setCursor] = useState(null);
+
+ const [status, setStatus] = useState('idle'); // idle | loading | error
+ const [error, setError] = useState(null);
+
+ const [query, setQuery] = useState('');
+
+ // Cache de perfiles (userId -> { loading, data, error })
+ const [profiles, setProfiles] = useState({});
+ const inFlightRef = useRef(new Set());
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q) return items;
+
+ return items.filter((c) => {
+ const otherId = String(c.otherUserId || '').toLowerCase();
+ const last = String(c.lastMessageText || '').toLowerCase();
+ const prof = profiles?.[c.otherUserId]?.data;
+ const name = String(
+ prof?.displayName || prof?.name || prof?.fullName || prof?.username || ''
+ ).toLowerCase();
+
+ return otherId.includes(q) || last.includes(q) || name.includes(q);
+ });
+ }, [items, query, profiles]);
+
+ async function loadFirstPage() {
+ setStatus('loading');
+ setError(null);
+ try {
+ const data = await listConversations({ limit: 20 });
+ setItems(data.items || []);
+ setHasMore(Boolean(data.hasMore));
+ setCursor(data.nextCursor || null);
+ setStatus('idle');
+ } catch (e) {
+ setStatus('error');
+ setError(e);
+ }
+ }
+
+ async function loadMore() {
+ if (!hasMore || !cursor) return;
+ setStatus('loading');
+ setError(null);
+ try {
+ const data = await listConversations({ cursor, limit: 20 });
+ setItems((prev) => [...prev, ...(data.items || [])]);
+ setHasMore(Boolean(data.hasMore));
+ setCursor(data.nextCursor || null);
+ setStatus('idle');
+ } catch (e) {
+ setStatus('error');
+ setError(e);
+ }
+ }
+
+ async function ensureProfilesFor(conversations) {
+ const userIds = Array.from(
+ new Set(
+ (conversations || [])
+ .map((c) => c?.otherUserId)
+ .filter(Boolean)
+ .map((id) => String(id))
+ )
+ );
+
+ const toFetch = userIds.filter((id) => !profiles[id] && !inFlightRef.current.has(id));
+ if (toFetch.length === 0) return;
+
+ toFetch.forEach((id) => inFlightRef.current.add(id));
+ setProfiles((prev) => {
+ const next = { ...prev };
+ toFetch.forEach((id) => {
+ next[id] = { loading: true, data: null, error: null };
+ });
+ return next;
+ });
+
+ await Promise.all(
+ toFetch.map(async (id) => {
+ try {
+ const data = await getProfileInfoByUserId(id);
+ setProfiles((prev) => ({
+ ...prev,
+ [id]: { loading: false, data, error: null },
+ }));
+ } catch (e) {
+ setProfiles((prev) => ({
+ ...prev,
+ [id]: { loading: false, data: null, error: e },
+ }));
+ } finally {
+ inFlightRef.current.delete(id);
+ }
+ })
+ );
+ }
+
+ useEffect(() => {
+ loadFirstPage();
+ }, []);
+
+ useEffect(() => {
+ if (items?.length) ensureProfilesFor(items);
+ }, [items]);
+
+ useEffect(() => {
+ const handler = ({ conversationId, message }) => {
+ if (!conversationId || !message) return;
+
+ setItems((prev) => {
+ const idx = prev.findIndex((c) => String(c._id) === String(conversationId));
+ if (idx === -1) {
+ loadFirstPage();
+ return prev;
+ }
+
+ const updated = { ...prev[idx] };
+ updated.lastMessageAt = message.createdAt;
+ updated.lastMessageText = String(message.text || '').slice(0, 200);
+
+ const without = prev.filter((_, i) => i !== idx);
+ const next = [updated, ...without];
+
+ ensureProfilesFor(next);
+ return next;
+ });
+ };
+
+ onMessageNew(handler);
+ return () => offMessageNew(handler);
+ }, []);
+
+ const MIN_ROWS_VISUAL = 6;
+ const rowHeightApprox = 88;
+ const fillerCount = Math.max(0, MIN_ROWS_VISUAL - filtered.length);
+
+ return (
+
+
+
+
+
+
+
+
+ {/* BURBUJAS EXTRA */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
setQuery(e.target.value)}
+ placeholder="Buscar por usuario (ID) o texto…"
+ className="cp-input"
+ />
+
+
+ {status === 'error' && (
+
+
No se pudieron cargar las conversaciones.
+
+ {error ? (
+
{String(error?.message || error)}
+ ) : null}
+
+ )}
+
+ {status !== 'error' && filtered.length === 0 && status !== 'loading' && (
+
+
No tienes conversaciones todavía.
+
+ )}
+
+
+ {filtered.map((c) => {
+ const otherId = c.otherUserId ? String(c.otherUserId) : '';
+ const profileState = otherId ? profiles?.[otherId] : null;
+ const displayName = pickDisplayName(profileState?.data);
+
+ const hue = hashToHue(otherId || c._id);
+ const avatarBg = `linear-gradient(135deg, hsla(${hue}, 85%, 58%, 0.95), hsla(${
+ (hue + 28) % 360
+ }, 85%, 48%, 0.95))`;
+
+ const titleForAvatar = profileState?.loading
+ ? 'Cargando perfil…'
+ : profileState?.error
+ ? 'No se pudo cargar el perfil'
+ : displayName;
+
+ const { date, time } = formatDateAndTime(c.lastMessageAt);
+
+ return (
+ - {
+ e.currentTarget.style.transform = 'translateY(-2px)';
+ e.currentTarget.style.borderColor = 'rgba(255,255,255,0.22)';
+ e.currentTarget.style.background =
+ 'linear-gradient(180deg, rgba(255,255,255,0.115), rgba(255,255,255,0.06))';
+ e.currentTarget.style.boxShadow = '0 20px 52px rgba(0,0,0,0.28)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.transform = 'translateY(0px)';
+ e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)';
+ e.currentTarget.style.background =
+ 'linear-gradient(180deg, rgba(255,255,255,0.095), rgba(255,255,255,0.05))';
+ e.currentTarget.style.boxShadow = '0 14px 36px rgba(0,0,0,0.22)';
+ }}
+ >
+
+
+ {safeInitialFromName(
+ (profileState?.data?.username || 'Usuario').toString().trim()
+ )}
+
+
+
+
+ {displayName}
+ {profileState?.loading ? (
+ (cargando…)
+ ) : null}
+
+
+
+
+ {date}
+
+
+ {time}
+
+
+
+
+ {c.lastMessageText || ''}
+
+
+
+
+
{
+ e.currentTarget.style.transform = 'translateY(-1px)';
+ e.currentTarget.style.filter = 'brightness(1.04)';
+ e.currentTarget.style.boxShadow = '0 20px 44px rgba(0,0,0,0.26)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.transform = 'translateY(0px)';
+ e.currentTarget.style.filter = 'none';
+ e.currentTarget.style.boxShadow = '0 16px 36px rgba(0,0,0,0.22)';
+ }}
+ >
+
Abrir
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+ {fillerCount > 0 && (
+
+ )}
+
+
+
+ {status === 'loading' ? 'Actualizando conversaciones…' : ' '}
+
+
+
+
+ {hasMore && (
+
+ )}
+
+
+
+ {/* /centered */}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/pages/app/messages/MessagingLayout.jsx b/src/pages/app/messages/MessagingLayout.jsx
new file mode 100644
index 0000000..8237bac
--- /dev/null
+++ b/src/pages/app/messages/MessagingLayout.jsx
@@ -0,0 +1,14 @@
+import { Outlet } from "react-router-dom";
+import { useEffect } from "react";
+import { connectMessagingSocket, disconnectMessagingSocket } from "@/services/messagingSocket";
+
+export default function MessagingLayout() {
+ useEffect(() => {
+ connectMessagingSocket();
+ return () => {
+ disconnectMessagingSocket();
+ };
+ }, []);
+
+ return
;
+}
\ No newline at end of file
diff --git a/src/pages/app/profile/ProfileView.jsx b/src/pages/app/profile/ProfileView.jsx
index 3037b33..5f77c57 100644
--- a/src/pages/app/profile/ProfileView.jsx
+++ b/src/pages/app/profile/ProfileView.jsx
@@ -245,14 +245,16 @@ export default function ProfileView() {
/>
)}
-
-
-
+ {isOwnProfile && (
+
+
+
+ )}
{/* Hero Section */}
friend?.id || friend?._id || friend;
@@ -37,6 +38,12 @@ function FriendRow({ friend, onRemove, isRemoving }) {
+