diff --git a/.env.example b/.env.example index 4566e61..2ac1636 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ VITE_BASE_URL="http://localhost:3000/api/v1" VITE_SPACE_URL="http://localhost:5403" VITE_SPACE_API_KEY="" VITE_LOG_LEVEL="info" -VITE_CDN_DOMAIN="s3-cdn-server-for-retrieving-files" \ No newline at end of file +VITE_CDN_DOMAIN="s3-cdn-server-for-retrieving-files" +VITE_SOCIAL_WS_URL="http://localhost:3004" \ No newline at end of file diff --git a/package.json b/package.json index 2f00c55..c88cf36 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "react-dom": "^18.3.1", "react-router-dom": "^7.9.6", "recharts": "^3.5.0", + "socket.io": "^4.8.3", "space-react-client": "^0.3.0", "wavesurfer.js": "^7.11.1", "zustand": "^5.0.9" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa2569f..64360d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: recharts: specifier: ^3.5.0 version: 3.5.0(@types/react@18.3.27)(eslint@9.39.1)(react-dom@18.3.1(react@18.3.1))(react-is@19.2.0)(react@18.3.1)(redux@5.0.1) + socket.io: + specifier: ^4.8.3 + version: 4.8.3 space-react-client: specifier: ^0.3.0 version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -56,7 +59,7 @@ importers: version: 18.3.7(@types/react@18.3.27) '@vitejs/plugin-react-swc': specifier: ^4.1.0 - version: 4.2.1(vite@7.2.2) + version: 4.2.1(vite@7.2.2(@types/node@25.0.3)) eslint: specifier: ^9.36.0 version: 9.39.1 @@ -71,7 +74,7 @@ importers: version: 16.5.0 vite: specifier: ^7.1.7 - version: 7.2.2 + version: 7.2.2(@types/node@25.0.3) packages: @@ -506,6 +509,9 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -539,6 +545,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@25.0.3': + resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -559,6 +568,10 @@ packages: peerDependencies: vite: ^4 || ^5 || ^6 || ^7 + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -588,6 +601,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -625,10 +642,18 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.0.2: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -713,6 +738,10 @@ packages: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} engines: {node: '>=10.0.0'} + engine.io@6.6.5: + resolution: {integrity: sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==} + engines: {node: '>=10.2.0'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1014,6 +1043,14 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1143,6 +1180,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + socket.io-adapter@2.5.6: + resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} + socket.io-client@4.8.3: resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} engines: {node: '>=10.0.0'} @@ -1151,6 +1191,10 @@ packages: resolution: {integrity: sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==} engines: {node: '>=10.0.0'} + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1195,6 +1239,9 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1203,6 +1250,10 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} @@ -1584,6 +1635,10 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@types/cors@2.8.19': + dependencies: + '@types/node': 25.0.3 + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -1612,6 +1667,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@25.0.3': + dependencies: + undici-types: 7.16.0 + '@types/prop-types@15.7.15': {} '@types/react-dom@18.3.7(@types/react@18.3.27)': @@ -1625,14 +1684,19 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} - '@vitejs/plugin-react-swc@4.2.1(vite@7.2.2)': + '@vitejs/plugin-react-swc@4.2.1(vite@7.2.2(@types/node@25.0.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.46 '@swc/core': 1.15.0 - vite: 7.2.2 + vite: 7.2.2(@types/node@25.0.3) transitivePeerDependencies: - '@swc/helpers' + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -1664,6 +1728,8 @@ snapshots: balanced-match@1.0.2: {} + base64id@2.0.0: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -1697,8 +1763,15 @@ snapshots: content-type@1.0.5: {} + cookie@0.7.2: {} + cookie@1.0.2: {} + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -1777,6 +1850,22 @@ snapshots: engine.io-parser@5.2.3: {} + engine.io@6.6.5: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 25.0.3 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.5 + debug: 4.4.3 + engine.io-parser: 5.2.3 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -2091,6 +2180,10 @@ snapshots: natural-compare@1.4.0: {} + negotiator@0.6.3: {} + + object-assign@4.1.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2239,6 +2332,15 @@ snapshots: shebang-regex@3.0.0: {} + socket.io-adapter@2.5.6: + dependencies: + debug: 4.4.3 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 @@ -2257,6 +2359,20 @@ snapshots: transitivePeerDependencies: - supports-color + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.4.3 + engine.io: 6.6.5 + socket.io-adapter: 2.5.6 + socket.io-parser: 4.2.5 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + source-map-js@1.2.1: {} space-react-client@0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -2303,6 +2419,8 @@ snapshots: uint8array-extras@1.5.0: {} + undici-types@7.16.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -2311,6 +2429,8 @@ snapshots: dependencies: react: 18.3.1 + vary@1.1.2: {} + victory-vendor@37.3.6: dependencies: '@types/d3-array': 3.2.2 @@ -2328,7 +2448,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@7.2.2: + vite@7.2.2(@types/node@25.0.3): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -2337,6 +2457,7 @@ snapshots: rollup: 4.52.5 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 25.0.3 fsevents: 2.3.3 wavesurfer.js@7.12.1: {} diff --git a/public/config.js b/public/config.js index 061aa35..9224385 100644 --- a/public/config.js +++ b/public/config.js @@ -4,8 +4,6 @@ window.RUNTIME_CONFIG = { VITE_CDN_DOMAIN: "s3-cdn-server-for-retrieving-files", VITE_SPACE_URL: "http://localhost:5403", VITE_SPACE_API_KEY: "", - // Analytics SSE endpoint - DEBE pasar por API Gateway para que añada headers de auth - // En desarrollo: http://localhost:3000 (API Gateway) - // En producción: https://api.socialbeats.es/socialbeats-api - VITE_ANALYTICS_SERVICE_URL: "http://localhost:3000" + VITE_ANALYTICS_SERVICE_URL: "http://localhost:3003", + VITE_SOCIAL_WS_URL: "http://localhost:3004" }; diff --git a/src/App.jsx b/src/App.jsx index 4c2da4e..a09be40 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -34,6 +34,9 @@ import PlaylistDetails from './pages/app/beats-interaction/playlist/PlaylistDeta import ForgotPassword from './pages/auth/ForgotPassword'; import ResetPassword from './pages/auth/ResetPassword'; import VerifyEmail from './pages/auth/VerifyEmail'; +import MessagingLayout from './pages/app/messages/MessagingLayout'; +import ConversationsPage from './pages/app/messages/ConversationsPage.jsx'; +import ConversationThreadPage from './pages/app/messages/ConversationThreadPage.jsx'; function App() { const spaceClient = useSpaceClient(); @@ -81,10 +84,15 @@ function App() { } /> } /> } /> - } /> } /> } /> + {/* Rutas de Mensajería */} + }> + } /> + } /> + + {/* Rutas del microservicio Dashboards */} } /> } /> diff --git a/src/components/feed/FeedItem.jsx b/src/components/feed/FeedItem.jsx index 684e6b6..45d86c9 100644 --- a/src/components/feed/FeedItem.jsx +++ b/src/components/feed/FeedItem.jsx @@ -51,6 +51,33 @@ export default function FeedItem({ item }) { } }, [item.type, item.actorId, item.friendId]); + // Enrich actor username for other event types (comments, ratings, beats) + useEffect(() => { + if (item.type !== 'friendship' && item.actorId) { + const loadActor = async () => { + try { + const profile = await enrichWithProfile(item.actorId); + setUsernames(prev => ({ ...prev, actor: profile?.username || prev.actor })); + } catch (err) { + console.error('Error loading actor username:', err); + } + }; + loadActor(); + } + }, [item.type, item.actorId]); + + const getActorUsername = () => item?.metadata?.actorUsername || usernames.actor; + + const renderActorLink = () => { + const username = getActorUsername(); + if (!username) return Usuario; + return ( + + {username} + + ); + }; + const renderContent = () => { switch (item.type) { case 'friendship': @@ -81,7 +108,7 @@ export default function FeedItem({ item }) { {item.title} )}

- Nuevo beat:{' '} + {renderActorLink()} subió un beat:{' '} {item.title} @@ -97,7 +124,7 @@ export default function FeedItem({ item }) {

💬

- {item.metadata?.actorUsername} comentó en{' '} + {renderActorLink()} comentó en{' '} {item.metadata?.beatTitle} @@ -113,7 +140,7 @@ export default function FeedItem({ item }) {

⭐

- {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 */} +
+ + + {/* Composer (siempre visible) */} +
+ setText(e.target.value)} + placeholder="Escribe un mensaje…" + className="ctp-input" + maxLength={1000} + disabled={sending} + /> + +
+
+
+
+
+
+ ); +} 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 ( +
+
+