From e912b664de02b10e733acd58780e214055794ccf Mon Sep 17 00:00:00 2001 From: DammmyFayo Date: Sat, 27 Jun 2026 16:20:58 +0000 Subject: [PATCH 1/3] refactor(conversations): replace last-message preview with ciphertext-safe metadata --- apps/tests/routes/conversations.test.js | 63 ++++++++++++++++++ apps/web/src/app/routes/conversations.js | 75 ++++++++++++++++++++++ apps/web/src/service/cacheService.js | 82 ++++++++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 apps/tests/routes/conversations.test.js create mode 100644 apps/web/src/app/routes/conversations.js create mode 100644 apps/web/src/service/cacheService.js diff --git a/apps/tests/routes/conversations.test.js b/apps/tests/routes/conversations.test.js new file mode 100644 index 0000000..e09d934 --- /dev/null +++ b/apps/tests/routes/conversations.test.js @@ -0,0 +1,63 @@ +const request = require('supertest'); +// Adjust these absolute/relative path strings to align with your app server config +const app = require('../../src/app'); +const redisClient = require('../../src/config/redis'); + +describe('GET /conversations - Ciphertext Safe Metadata Isolation Integration Tests', () => { + // Clear the redis cache environment variables before each test runs to avoid cross-pollution + beforeEach(async () => { + if (redisClient && typeof redisClient.flushall === 'function') { + await redisClient.flushall(); + } + }); + + // Safe mock JWT signature to bypass access barriers + const mockAuthToken = 'bearer-mock-jwt-token-string'; + + test('should return conversation listings matching safe metadata profiles completely empty of message text bodies', async () => { + const response = await request(app) + .get('/api/conversations') + .set('Authorization', `Bearer ${mockAuthToken}`) + .expect(200); + + expect(response.body.success).toBe(true); + + const conversations = response.body.data; + expect(Array.isArray(conversations)).toBe(true); + + // If active seed items exist, cross-examine the structure fields inside lastMessage arrays + if (conversations.length > 0 && conversations[0].lastMessage) { + const lastMsg = conversations[0].lastMessage; + + // 1. ACCEPTANCE CRITERIA: Assert ONLY unclassified structural metadata properties exist + expect(lastMsg).toHaveProperty('senderId'); + expect(lastMsg).toHaveProperty('senderDeviceId'); + expect(lastMsg).toHaveProperty('contentType'); + expect(lastMsg).toHaveProperty('sequenceNumber'); + expect(lastMsg).toHaveProperty('createdAt'); + + // 2. PRIVACY SECURE LINE: Assert that content/plaintext/ciphertext values are strictly undefined + expect(lastMsg.body).toBeUndefined(); + expect(lastMsg.text).toBeUndefined(); + expect(lastMsg.content).toBeUndefined(); + expect(lastMsg.ciphertext).toBeUndefined(); + expect(lastMsg.preview).toBeUndefined(); + } + }); + + test('should maintain conversation counters and ordering keys via top level metadata blocks', async () => { + const response = await request(app) + .get('/api/conversations') + .set('Authorization', `Bearer ${mockAuthToken}`) + .expect(200); + + const conversations = response.body.data; + if (conversations.length > 0) { + // Unread counters must remain accessible on parent layer to preserve badges functionality + expect(conversations[0]).toHaveProperty('id'); + expect(conversations[0]).toHaveProperty('unreadCount'); + expect(conversations[0]).toHaveProperty('updatedAt'); + expect(typeof conversations[0].unreadCount).toBe('number'); + } + }); +}); \ No newline at end of file diff --git a/apps/web/src/app/routes/conversations.js b/apps/web/src/app/routes/conversations.js new file mode 100644 index 0000000..ea825d7 --- /dev/null +++ b/apps/web/src/app/routes/conversations.js @@ -0,0 +1,75 @@ +/** + * Conversations Routing Interface + * Handles retrieval of active user chats sanitized of end-to-end encryption leaks. + */ + +const express = require('express'); +const router = express.Router(); + +// Mock imports matching standard patterns. Adjust these paths if your service +// directory is structured differently relative to this routes folder. +const conversationService = require('../services/conversationService'); +const cacheService = require('../services/cacheService'); + +/** + * GET /api/conversations + * Fetches the active profile's conversations list including safe metadata only. + * * Acceptance Criteria Met: + * - No plaintext or ciphertext preview leaves the server configuration. + * - Unread counts + sorting still function cleanly using the allowed metadata fields. + */ +router.get('/', async (req, res, next) => { + try { + // Fallback to a mock or extracted user ID if your auth middleware populates req.userId instead + const userId = req.user?.id || req.userId; + + if (!userId) { + return res.status(401).json({ + success: false, + error: 'Unauthorized', + message: 'A valid session or bearer token identity is required.' + }); + } + + // 1. Attempt to resolve active data array from Redis cache layers + let conversations = await cacheService.getConversationList(userId); + + if (!conversations) { + // 2. Fallback execution pipeline querying underlying SQL database records on cache miss + const rawConversations = await conversationService.getUserConversations(userId); + + // 3. ENFORCE SECURE METADATA ISOLATION: + // Map through results to strip structural content fields ('body', 'text', 'ciphertext', etc.) + conversations = rawConversations.map(conv => { + const safeLastMessage = conv.lastMessage ? { + senderId: conv.lastMessage.senderId, + senderDeviceId: conv.lastMessage.senderDeviceId, + contentType: conv.lastMessage.contentType, + sequenceNumber: conv.lastMessage.sequenceNumber, + createdAt: conv.lastMessage.createdAt + // CRITICAL: Explicitly excluding raw text body, message string payloads, or cipher fragments here. + } : null; + + return { + id: conv.id, + participants: conv.participants || [], + unreadCount: conv.unreadCount || 0, // Retained to support unread badges and ordering logic + updatedAt: conv.updatedAt || conv.lastMessage?.createdAt, + lastMessage: safeLastMessage + }; + }); + + // 4. Hydrate Redis store with the sanitized schema configuration + await cacheService.setConversationList(userId, conversations); + } + + return res.status(200).json({ + success: true, + data: conversations + }); + } catch (error) { + return next(error); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/apps/web/src/service/cacheService.js b/apps/web/src/service/cacheService.js new file mode 100644 index 0000000..6b6dcc8 --- /dev/null +++ b/apps/web/src/service/cacheService.js @@ -0,0 +1,82 @@ +const redisClient = require('../config/redis'); + +/** + * Retrieves the cached conversation list for a specific user. + * * @param {string} userId - The unique identifier of the user. + * @returns {Promise} Sanitized conversation array or null on cache miss. + */ +async function getConversationList(userId) { + const cacheKey = `user:${userId}:conversations`; + const cachedData = await redisClient.get(cacheKey); + return cachedData ? JSON.parse(cachedData) : null; +} + +/** + * Commits a full, pre-sanitized conversation list to the Redis cache. + * * @param {string} userId - The unique identifier of the user. + * @param {Array} conversations - Sanitized conversation objects. + */ +async function setConversationList(userId, conversations) { + const cacheKey = `user:${userId}:conversations`; + // Cache data with a standard 24-hour expiration safety window + await redisClient.set(cacheKey, JSON.stringify(conversations), 'EX', 86400); +} + +/** + * Updates a singular conversation entry cache tracking shape securely. + * Called automatically when real-time messages are broadcasted across sockets. + * * Acceptance Criteria Met: + * - Drops structural message body/text/ciphertext fragments. + * - Retains only isolated metadata fields (senderId, senderDeviceId, contentType, sequenceNumber, createdAt). + */ +async function updateConversationCache(userId, conversationId, lastMessagePayload, unreadCount) { + const cacheKey = `user:${userId}:conversations`; + + // 1. Fetch the existing cache array list + const cachedData = await redisClient.get(cacheKey); + let list = cachedData ? JSON.parse(cachedData) : []; + + // 2. Find if the target conversation context already exists in the array + let convItem = list.find(c => c.id === conversationId); + + // 3. SECURE PREVIEW ISOLATION MATRIX + // Manually map out the verified unclassified attributes. + // CRITICAL: Never spread (...lastMessagePayload) as it risks inheriting forbidden message bodies. + const sanitizedMessageMetadata = lastMessagePayload ? { + senderId: lastMessagePayload.senderId, + senderDeviceId: lastMessagePayload.senderDeviceId, + contentType: lastMessagePayload.contentType, + sequenceNumber: lastMessagePayload.sequenceNumber, + createdAt: lastMessagePayload.createdAt + } : null; + + const currentTimestamp = lastMessagePayload?.createdAt || new Date().toISOString(); + + if (convItem) { + // 4a. Update reference nodes on existing entry + convItem.unreadCount = unreadCount; + convItem.lastMessage = sanitizedMessageMetadata; + convItem.updatedAt = currentTimestamp; + } else { + // 4b. Push a brand new sanitized profile block if conversation entry is new + list.push({ + id: conversationId, + participants: [], // Will be hydrated on full sync + unreadCount: unreadCount, + lastMessage: sanitizedMessageMetadata, + updatedAt: currentTimestamp + }); + } + + // 5. Keep the conversation feed list sorted perfectly by latest updates (Ordering Requirement) + list.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); + + // 6. Save back to the Redis database instances + await redisClient.set(cacheKey, JSON.stringify(list), 'EX', 86400); +} + +module.exports = { + getConversationList, + setConversationList, + updateConversationCache +}; \ No newline at end of file From 1d486c4a68004ccf91eec6fbf27472d284921954 Mon Sep 17 00:00:00 2001 From: codebestia Date: Wed, 1 Jul 2026 20:59:07 +0100 Subject: [PATCH 2/3] style: format and lint codebase --- .claude/settings.json | 4 +- .github/pull_request_template.md | 3 + README.md | 87 ++++++------- apps/backend/drizzle/meta/0000_snapshot.json | 18 +-- apps/backend/drizzle/meta/0001_snapshot.json | 55 +++------ apps/backend/drizzle/meta/0002_snapshot.json | 63 +++------- apps/backend/drizzle/meta/0004_snapshot.json | 83 ++++--------- apps/backend/drizzle/meta/_journal.json | 2 +- apps/backend/package.json | 2 +- apps/backend/tsconfig.json | 2 +- apps/web/next.config.ts | 6 +- .../src/app/app/conversations/[id]/page.tsx | 6 +- apps/web/src/app/app/layout.tsx | 45 +++---- apps/web/src/app/app/messages/page.tsx | 2 +- apps/web/src/app/app/page.tsx | 104 ++++++++++------ apps/web/src/app/app/proposals/page.tsx | 89 ++++++++------ apps/web/src/app/app/treasury/page.tsx | 115 ++++++++++++++---- apps/web/src/app/chat/page.tsx | 81 ++++++------ apps/web/src/app/conversations/[id]/page.tsx | 75 +++++------- apps/web/src/app/layout.tsx | 14 +-- apps/web/src/app/page.tsx | 18 +-- apps/web/src/app/providers.tsx | 6 +- apps/web/src/components/ToastDemo.tsx | 13 +- apps/web/src/components/ToastProvider.tsx | 41 ++++--- apps/web/src/components/auth/AuthContext.tsx | 4 +- apps/web/src/components/auth/AuthProvider.tsx | 14 +-- .../src/components/auth/ProtectedRoute.tsx | 22 ++-- apps/web/src/components/auth/useAuth.ts | 8 +- .../components/chat/ConversationHeader.tsx | 38 +++--- apps/web/src/components/chat/MessageInput.tsx | 44 +++---- .../components/chat/NewConversationModal.tsx | 18 +-- apps/web/src/components/chat/TransferCard.tsx | 8 +- .../conversations/ConversationListSidebar.tsx | 91 +++++++------- apps/web/src/components/landing/Features.tsx | 42 +++---- apps/web/src/components/landing/Hero.tsx | 32 +++-- .../web/src/components/landing/HowItWorks.tsx | 24 ++-- apps/web/src/components/landing/Navbar.tsx | 12 +- apps/web/src/components/landing/TechStack.tsx | 36 +++--- .../components/messaging/MessageThread.tsx | 36 +++--- .../treasury/ProposeWithdrawalModal.tsx | 63 +++++----- apps/web/src/components/ui/Avatar.tsx | 14 +-- apps/web/src/components/ui/Badge.tsx | 18 ++- apps/web/src/components/ui/EmptyState.tsx | 5 +- apps/web/src/components/ui/Modal.tsx | 52 ++++---- apps/web/src/components/ui/SkeletonLoader.tsx | 13 +- apps/web/src/components/ui/Spinner.tsx | 11 +- .../components/wallet/WalletConnectButton.tsx | 26 ++-- apps/web/src/contexts/AuthContext.tsx | 30 ++--- apps/web/src/contexts/WalletContext.tsx | 8 +- apps/web/src/hooks/useMessageHistory.ts | 23 ++-- apps/web/src/hooks/useSocket.ts | 10 +- apps/web/src/lib/api.ts | 4 +- apps/web/src/lib/auth.tsx | 14 +-- apps/web/src/lib/freighter.ts | 28 +++-- apps/web/src/lib/socket.ts | 5 +- apps/web/src/lib/soroban.ts | 33 +++-- apps/web/src/lib/useToast.ts | 8 +- contracts/README.md | 3 +- src/components/ui/WalletAddress.tsx | 5 +- turbo.json | 36 +++--- 60 files changed, 881 insertions(+), 891 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 9baf066..8c15f06 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -12,8 +12,6 @@ "Bash(git add *)", "Bash(echo \"exit: $?\")" ], - "additionalDirectories": [ - "/private/tmp" - ] + "additionalDirectories": ["/private/tmp"] } } diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9603ba2..11284f5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,13 +1,16 @@ ## Description + ## Type of change + - [ ] Bug fix - [ ] New feature - [ ] Documentation update - [ ] Other ## Checklist + - [ ] I have read the contributing guidelines - [ ] I have tested my changes locally - [ ] My code follows the project's coding standards diff --git a/README.md b/README.md index 6ea2b97..4897243 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ Built on blockchain infrastructure and modern messaging protocols, the platform ## ✨ Core Capabilities -* 💬 Real-time wallet-to-wallet messaging -* 💸 Send and receive tokens directly in chat -* 👥 Group treasuries for shared funds -* 🧾 Proposal creation and community funding -* 🗳️ Lightweight DAO-style voting -* 🤖 AI-powered insights (fraud detection, proposal analysis, smart assistants) +- 💬 Real-time wallet-to-wallet messaging +- 💸 Send and receive tokens directly in chat +- 👥 Group treasuries for shared funds +- 🧾 Proposal creation and community funding +- 🗳️ Lightweight DAO-style voting +- 🤖 AI-powered insights (fraud detection, proposal analysis, smart assistants) --- @@ -29,50 +29,49 @@ To create a **financial coordination layer for communities**, where communicatio ## 🖥️ Frontend -* Next.js (React + TypeScript) -* TailwindCSS - +- Next.js (React + TypeScript) +- TailwindCSS --- ## ⚙️ Backend -* Node.js (Express) -* WebSockets (Socket.IO) -* PostgreSQL (persistent storage) -* Redis (pub/sub, caching) +- Node.js (Express) +- WebSockets (Socket.IO) +- PostgreSQL (persistent storage) +- Redis (pub/sub, caching) --- ## 🔗 Blockchain -* Smart Contracts (Soroban) -* stellar-sdk (interaction layer) -* Event listeners for syncing on-chain activity +- Smart Contracts (Soroban) +- stellar-sdk (interaction layer) +- Event listeners for syncing on-chain activity --- ## 🤖 AI Layer -* Python (FastAPI) -* LLM APIs -* Vector DB (Weaviate) +- Python (FastAPI) +- LLM APIs +- Vector DB (Weaviate) --- ## 💬 Messaging Infrastructure -* XMTP (or similar Web3 messaging protocol) -* Optional WebRTC for peer-to-peer communication +- XMTP (or similar Web3 messaging protocol) +- Optional WebRTC for peer-to-peer communication --- ## 🧰 Dev Tools -* Turborepo (monorepo management) -* Docker (containerization) -* ESLint + Prettier (code quality) -* Jest / Vitest (testing) +- Turborepo (monorepo management) +- Docker (containerization) +- ESLint + Prettier (code quality) +- Jest / Vitest (testing) --- @@ -82,12 +81,11 @@ To create a **financial coordination layer for communities**, where communicatio Make sure you have installed: -* Node.js (>= 18) -* pnpm -* uv (Python Package Manager) -* Stellar CLI (for Soroban Smart Contracts) -* Docker (optional but recommended) - +- Node.js (>= 18) +- pnpm +- uv (Python Package Manager) +- Stellar CLI (for Soroban Smart Contracts) +- Docker (optional but recommended) --- @@ -116,11 +114,13 @@ cp .env.example .env ### Start all services First, start the local database and redis container: + ```bash docker compose -f infra/docker-compose.yml up -d ``` Then, run the node apps (Web and Backend): + ```bash pnpm run dev ``` @@ -164,41 +164,44 @@ We welcome contributions from developers, designers, and researchers. ```bash git checkout -b feature/your-feature-name ``` + 3. Make your changes 4. Commit your changes ```bash git commit -m "feat: add new feature" ``` + 5. Push to your fork ```bash git push origin feature/your-feature-name ``` + 6. Open a Pull Request --- ## 🧭 Contribution Guidelines -* Follow existing code style and structure -* Write clear and concise commit messages -* Add tests where necessary -* Keep PRs small and focused -* Document new features or changes +- Follow existing code style and structure +- Write clear and concise commit messages +- Add tests where necessary +- Keep PRs small and focused +- Document new features or changes --- - ## 💡 Areas to Contribute -* Smart contract development -* Frontend UX improvements -* AI agent development -* Security enhancements -* Performance optimization +- Smart contract development +- Frontend UX improvements +- AI agent development +- Security enhancements +- Performance optimization --- + # 📜 License MIT License diff --git a/apps/backend/drizzle/meta/0000_snapshot.json b/apps/backend/drizzle/meta/0000_snapshot.json index 969e45b..de73d2b 100644 --- a/apps/backend/drizzle/meta/0000_snapshot.json +++ b/apps/backend/drizzle/meta/0000_snapshot.json @@ -49,9 +49,7 @@ "users_username_unique": { "name": "users_username_unique", "nullsNotDistinct": false, - "columns": [ - "username" - ] + "columns": ["username"] } }, "policies": {}, @@ -102,12 +100,8 @@ "name": "wallets_user_id_users_id_fk", "tableFrom": "wallets", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -117,9 +111,7 @@ "wallets_address_unique": { "name": "wallets_address_unique", "nullsNotDistinct": false, - "columns": [ - "address" - ] + "columns": ["address"] } }, "policies": {}, @@ -138,4 +130,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/apps/backend/drizzle/meta/0001_snapshot.json b/apps/backend/drizzle/meta/0001_snapshot.json index 19501d4..9c9a0d9 100644 --- a/apps/backend/drizzle/meta/0001_snapshot.json +++ b/apps/backend/drizzle/meta/0001_snapshot.json @@ -41,12 +41,8 @@ "name": "conversation_members_conversation_id_conversations_id_fk", "tableFrom": "conversation_members", "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -54,12 +50,8 @@ "name": "conversation_members_user_id_users_id_fk", "tableFrom": "conversation_members", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -154,12 +146,8 @@ "name": "messages_conversation_id_conversations_id_fk", "tableFrom": "messages", "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -167,12 +155,8 @@ "name": "messages_sender_id_users_id_fk", "tableFrom": "messages", "tableTo": "users", - "columnsFrom": [ - "sender_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["sender_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -228,9 +212,7 @@ "users_username_unique": { "name": "users_username_unique", "nullsNotDistinct": false, - "columns": [ - "username" - ] + "columns": ["username"] } }, "policies": {}, @@ -281,12 +263,8 @@ "name": "wallets_user_id_users_id_fk", "tableFrom": "wallets", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -296,9 +274,7 @@ "wallets_address_unique": { "name": "wallets_address_unique", "nullsNotDistinct": false, - "columns": [ - "address" - ] + "columns": ["address"] } }, "policies": {}, @@ -310,10 +286,7 @@ "public.conversation_type": { "name": "conversation_type", "schema": "public", - "values": [ - "dm", - "group" - ] + "values": ["dm", "group"] } }, "schemas": {}, @@ -326,4 +299,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/apps/backend/drizzle/meta/0002_snapshot.json b/apps/backend/drizzle/meta/0002_snapshot.json index 88c537b..ac83d45 100644 --- a/apps/backend/drizzle/meta/0002_snapshot.json +++ b/apps/backend/drizzle/meta/0002_snapshot.json @@ -47,12 +47,8 @@ "name": "conversation_members_conversation_id_conversations_id_fk", "tableFrom": "conversation_members", "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -60,12 +56,8 @@ "name": "conversation_members_user_id_users_id_fk", "tableFrom": "conversation_members", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -73,12 +65,8 @@ "name": "conversation_members_last_read_message_id_messages_id_fk", "tableFrom": "conversation_members", "tableTo": "messages", - "columnsFrom": [ - "last_read_message_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["last_read_message_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -173,12 +161,8 @@ "name": "messages_conversation_id_conversations_id_fk", "tableFrom": "messages", "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -186,12 +170,8 @@ "name": "messages_sender_id_users_id_fk", "tableFrom": "messages", "tableTo": "users", - "columnsFrom": [ - "sender_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["sender_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -247,9 +227,7 @@ "users_username_unique": { "name": "users_username_unique", "nullsNotDistinct": false, - "columns": [ - "username" - ] + "columns": ["username"] } }, "policies": {}, @@ -300,12 +278,8 @@ "name": "wallets_user_id_users_id_fk", "tableFrom": "wallets", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -315,9 +289,7 @@ "wallets_address_unique": { "name": "wallets_address_unique", "nullsNotDistinct": false, - "columns": [ - "address" - ] + "columns": ["address"] } }, "policies": {}, @@ -329,10 +301,7 @@ "public.conversation_type": { "name": "conversation_type", "schema": "public", - "values": [ - "dm", - "group" - ] + "values": ["dm", "group"] } }, "schemas": {}, @@ -345,4 +314,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/apps/backend/drizzle/meta/0004_snapshot.json b/apps/backend/drizzle/meta/0004_snapshot.json index ba18857..47b44dd 100644 --- a/apps/backend/drizzle/meta/0004_snapshot.json +++ b/apps/backend/drizzle/meta/0004_snapshot.json @@ -47,12 +47,8 @@ "name": "conversation_members_conversation_id_conversations_id_fk", "tableFrom": "conversation_members", "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -60,12 +56,8 @@ "name": "conversation_members_user_id_users_id_fk", "tableFrom": "conversation_members", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -73,12 +65,8 @@ "name": "conversation_members_last_read_message_id_messages_id_fk", "tableFrom": "conversation_members", "tableTo": "messages", - "columnsFrom": [ - "last_read_message_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["last_read_message_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -189,12 +177,8 @@ "name": "messages_conversation_id_conversations_id_fk", "tableFrom": "messages", "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -202,12 +186,8 @@ "name": "messages_sender_id_users_id_fk", "tableFrom": "messages", "tableTo": "users", - "columnsFrom": [ - "sender_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["sender_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -285,12 +265,8 @@ "name": "token_transfers_conversation_id_conversations_id_fk", "tableFrom": "token_transfers", "tableTo": "conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -298,12 +274,8 @@ "name": "token_transfers_sender_id_users_id_fk", "tableFrom": "token_transfers", "tableTo": "users", - "columnsFrom": [ - "sender_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["sender_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -313,9 +285,7 @@ "token_transfers_tx_hash_unique": { "name": "token_transfers_tx_hash_unique", "nullsNotDistinct": false, - "columns": [ - "tx_hash" - ] + "columns": ["tx_hash"] } }, "policies": {}, @@ -367,9 +337,7 @@ "users_username_unique": { "name": "users_username_unique", "nullsNotDistinct": false, - "columns": [ - "username" - ] + "columns": ["username"] } }, "policies": {}, @@ -420,12 +388,8 @@ "name": "wallets_user_id_users_id_fk", "tableFrom": "wallets", "tableTo": "users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -435,9 +399,7 @@ "wallets_address_unique": { "name": "wallets_address_unique", "nullsNotDistinct": false, - "columns": [ - "address" - ] + "columns": ["address"] } }, "policies": {}, @@ -449,10 +411,7 @@ "public.conversation_type": { "name": "conversation_type", "schema": "public", - "values": [ - "dm", - "group" - ] + "values": ["dm", "group"] } }, "schemas": {}, @@ -465,4 +424,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index a58ae36..8494da4 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -59,4 +59,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/backend/package.json b/apps/backend/package.json index 5d8b3bd..8a389d8 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -59,4 +59,4 @@ "typescript-eslint": "^8.59.3", "vitest": "^4.1.6" } -} \ No newline at end of file +} diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index cec4a3a..d22d4a3 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -39,6 +39,6 @@ "isolatedModules": true, "noUncheckedSideEffectImports": true, "moduleDetection": "force", - "skipLibCheck": true, + "skipLibCheck": true } } diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index e9ffa30..30a7faa 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,7 +1,5 @@ -import type { NextConfig } from "next"; +import type { NextConfig } from 'next'; -const nextConfig: NextConfig = { - /* config options here */ -}; +const nextConfig: NextConfig = {/* config options here */}; export default nextConfig; diff --git a/apps/web/src/app/app/conversations/[id]/page.tsx b/apps/web/src/app/app/conversations/[id]/page.tsx index a407835..7085e08 100644 --- a/apps/web/src/app/app/conversations/[id]/page.tsx +++ b/apps/web/src/app/app/conversations/[id]/page.tsx @@ -1,8 +1,4 @@ -export default async function ConversationPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { +export default async function ConversationPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; return ( diff --git a/apps/web/src/app/app/layout.tsx b/apps/web/src/app/app/layout.tsx index 6754a1c..175275a 100644 --- a/apps/web/src/app/app/layout.tsx +++ b/apps/web/src/app/app/layout.tsx @@ -1,9 +1,9 @@ -"use client"; +'use client'; -import React, { useState } from "react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { useWallet } from "@/contexts/WalletContext"; +import React, { useState } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { useWallet } from '@/contexts/WalletContext'; // Custom premium SVG Icons to avoid dependency weight const LogoIcon = () => ( @@ -108,12 +108,7 @@ const ProposalsIcon = () => ( ); const WalletIcon = () => ( - + = ({ href, label, icon, active }) => { href={href} className={`group relative flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 ${ active - ? "bg-accent/15 text-white font-medium shadow-[0_0_15px_rgba(124,92,252,0.15)]" - : "text-foreground/60 hover:text-foreground hover:bg-white/5" + ? 'bg-accent/15 text-white font-medium shadow-[0_0_15px_rgba(124,92,252,0.15)]' + : 'text-foreground/60 hover:text-foreground hover:bg-white/5' }`} > {active && ( )} -
+
{icon}
@@ -167,7 +162,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { try { await connect(); } catch (err) { - console.error("Wallet connection failed:", err); + console.error('Wallet connection failed:', err); } finally { setIsConnecting(false); } @@ -175,14 +170,12 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { }; const navItems = [ - { href: "/app/messages", label: "Messages", icon: }, - { href: "/app/treasury", label: "Treasury", icon: }, - { href: "/app/proposals", label: "Proposals", icon: }, + { href: '/app/messages', label: 'Messages', icon: }, + { href: '/app/treasury', label: 'Treasury', icon: }, + { href: '/app/proposals', label: 'Proposals', icon: }, ]; - const displayAddress = publicKey - ? `${publicKey.slice(0, 4)}...${publicKey.slice(-4)}` - : ""; + const displayAddress = publicKey ? `${publicKey.slice(0, 4)}...${publicKey.slice(-4)}` : ''; return (
@@ -205,7 +198,9 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { href={item.href} label={item.label} icon={item.icon} - active={pathname === item.href || (item.href === "/app/messages" && pathname === "/app")} // default to messages if exactly /app + active={ + pathname === item.href || (item.href === '/app/messages' && pathname === '/app') + } // default to messages if exactly /app /> ))} @@ -243,7 +238,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { > - {isConnecting ? "Connecting..." : "Connect Wallet"} + {isConnecting ? 'Connecting...' : 'Connect Wallet'} )} @@ -251,9 +246,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { {/* Main Content Area */}
-
- {children} -
+
{children}
); diff --git a/apps/web/src/app/app/messages/page.tsx b/apps/web/src/app/app/messages/page.tsx index bee8ff3..2e74e2d 100644 --- a/apps/web/src/app/app/messages/page.tsx +++ b/apps/web/src/app/app/messages/page.tsx @@ -1 +1 @@ -export { default } from "../page"; +export { default } from '../page'; diff --git a/apps/web/src/app/app/page.tsx b/apps/web/src/app/app/page.tsx index 0fccd04..a2d1070 100644 --- a/apps/web/src/app/app/page.tsx +++ b/apps/web/src/app/app/page.tsx @@ -1,6 +1,6 @@ -"use client"; +'use client'; -import React, { useState } from "react"; +import React, { useState } from 'react'; interface Message { id: string; @@ -19,37 +19,37 @@ interface Message { export default function MessagesPage() { const [messages, setMessages] = useState([ { - id: "1", - sender: "Jed McCaleb", - avatar: "J", - text: "Hey! Did you check out the new stellar-core upgrade? The transaction speeds are looking incredibly solid.", - timestamp: "10:24 AM", + id: '1', + sender: 'Jed McCaleb', + avatar: 'J', + text: 'Hey! Did you check out the new stellar-core upgrade? The transaction speeds are looking incredibly solid.', + timestamp: '10:24 AM', isSelf: false, }, { - id: "2", - sender: "You", - avatar: "Y", - text: "Yes! The ledger close times are consistently under 4 seconds now. Just sent some test transactions.", - timestamp: "10:26 AM", + id: '2', + sender: 'You', + avatar: 'Y', + text: 'Yes! The ledger close times are consistently under 4 seconds now. Just sent some test transactions.', + timestamp: '10:26 AM', isSelf: true, }, { - id: "3", - sender: "Jed McCaleb", - avatar: "J", + id: '3', + sender: 'Jed McCaleb', + avatar: 'J', text: "Awesome. I've sent you the 50 XLM for the contract review. Let me know when you receive it.", - timestamp: "10:27 AM", + timestamp: '10:27 AM', isSelf: false, tokenTransfer: { - amount: "50 XLM", - token: "Stellar Lumens", - txHash: "0x78ab...e912", + amount: '50 XLM', + token: 'Stellar Lumens', + txHash: '0x78ab...e912', }, }, ]); - const [inputText, setInputText] = useState(""); + const [inputText, setInputText] = useState(''); const handleSendMessage = (e: React.FormEvent) => { e.preventDefault(); @@ -57,15 +57,15 @@ export default function MessagesPage() { const newMessage: Message = { id: Date.now().toString(), - sender: "You", - avatar: "Y", + sender: 'You', + avatar: 'Y', text: inputText, timestamp: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), isSelf: true, }; setMessages([...messages, newMessage]); - setInputText(""); + setInputText(''); }; return ( @@ -88,7 +88,12 @@ export default function MessagesPage() { {/* Quick Pay Action Button */} @@ -101,7 +106,7 @@ export default function MessagesPage() {
{/* Avatar */} @@ -109,8 +114,8 @@ export default function MessagesPage() {
{msg.avatar} @@ -122,8 +127,8 @@ export default function MessagesPage() {
{msg.text} @@ -133,12 +138,24 @@ export default function MessagesPage() {
- - + +
-

Received {msg.tokenTransfer.amount}

+

+ Received {msg.tokenTransfer.amount} +

{msg.tokenTransfer.token}

@@ -149,9 +166,7 @@ export default function MessagesPage() { )}
{msg.timestamp} @@ -161,7 +176,10 @@ export default function MessagesPage() {
{/* Input Form */} -
+ - - + + diff --git a/apps/web/src/app/app/proposals/page.tsx b/apps/web/src/app/app/proposals/page.tsx index ee9f2b0..cb2e319 100644 --- a/apps/web/src/app/app/proposals/page.tsx +++ b/apps/web/src/app/app/proposals/page.tsx @@ -1,64 +1,67 @@ -"use client"; +'use client'; -import React, { useState } from "react"; +import React, { useState } from 'react'; interface Proposal { id: string; title: string; creator: string; description: string; - status: "Active" | "Succeeded" | "Defeated"; + status: 'Active' | 'Succeeded' | 'Defeated'; yesVotes: number; noVotes: number; endsIn: string; - voted?: "yes" | "no"; + voted?: 'yes' | 'no'; } export default function ProposalsPage() { const [proposals, setProposals] = useState([ { - id: "1", - title: "CP-024: Allocate 50,000 XLM for Stellar-Rust SDK Improvements", - creator: "0xDeon", - description: "Upgrade the Stellar Rust SDK to improve memory safety and efficiency for smart contracts, introducing robust bindings and better transaction helpers.", - status: "Active", + id: '1', + title: 'CP-024: Allocate 50,000 XLM for Stellar-Rust SDK Improvements', + creator: '0xDeon', + description: + 'Upgrade the Stellar Rust SDK to improve memory safety and efficiency for smart contracts, introducing robust bindings and better transaction helpers.', + status: 'Active', yesVotes: 324000, noVotes: 42000, - endsIn: "2 days left", + endsIn: '2 days left', }, { - id: "2", - title: "CP-023: Deploy Multi-Sig Messaging Vault V2", - creator: "Jed McCaleb", - description: "Migrate current community multisig wallets to the audited V2 standard, adding instant chat-based transaction signing flows directly through the UI.", - status: "Succeeded", + id: '2', + title: 'CP-023: Deploy Multi-Sig Messaging Vault V2', + creator: 'Jed McCaleb', + description: + 'Migrate current community multisig wallets to the audited V2 standard, adding instant chat-based transaction signing flows directly through the UI.', + status: 'Succeeded', yesVotes: 512000, noVotes: 12000, - endsIn: "Ended 1 day ago", + endsIn: 'Ended 1 day ago', }, { - id: "3", - title: "CP-022: Increase Validator Quorum to 7 Members", - creator: "StellarDev", - description: "Proposed increase of validator consensus threshold nodes from 5 to 7 to improve fault tolerance and absolute decentralization metrics.", - status: "Defeated", + id: '3', + title: 'CP-022: Increase Validator Quorum to 7 Members', + creator: 'StellarDev', + description: + 'Proposed increase of validator consensus threshold nodes from 5 to 7 to improve fault tolerance and absolute decentralization metrics.', + status: 'Defeated', yesVotes: 110000, noVotes: 240000, - endsIn: "Ended 5 days ago", + endsIn: 'Ended 5 days ago', }, ]); - const handleVote = (id: string, type: "yes" | "no") => { + const handleVote = (id: string, type: 'yes' | 'no') => { setProposals( proposals.map((prop) => { - if (prop.id !== id || prop.status !== "Active" || prop.voted) return prop; + if (prop.id !== id || prop.status !== 'Active' || prop.voted) return prop; return { ...prop, voted: type, - yesVotes: type === "yes" ? prop.yesVotes + 10000 : prop.yesVotes, - noVotes: type === "no" ? prop.noVotes + 10000 : prop.noVotes, + yesVotes: type === 'yes' ? prop.yesVotes + 10000 : prop.yesVotes, + noVotes: type === 'no' ? prop.noVotes + 10000 : prop.noVotes, }; - }) + }), ); }; @@ -70,7 +73,9 @@ export default function ProposalsPage() {

Governance Proposals

-

Vote on community improvements and treasury resource allocations.

+

+ Vote on community improvements and treasury resource allocations. +

-
-
+
+
{prop.yesVotes.toLocaleString()} XLM @@ -145,22 +156,22 @@ export default function ProposalsPage() { {prop.endsIn} - {prop.status === "Active" && ( + {prop.status === 'Active' && ( <> {prop.voted ? ( - Voted {prop.voted === "yes" ? "Yes" : "No"} + Voted {prop.voted === 'yes' ? 'Yes' : 'No'} ) : (
diff --git a/apps/web/src/components/chat/NewConversationModal.tsx b/apps/web/src/components/chat/NewConversationModal.tsx index 80acc3c..52577cf 100644 --- a/apps/web/src/components/chat/NewConversationModal.tsx +++ b/apps/web/src/components/chat/NewConversationModal.tsx @@ -1,9 +1,9 @@ -"use client"; +'use client'; -import { useEffect, useState } from "react"; -import { EmptyState } from "@/components/ui/EmptyState"; +import { useEffect, useState } from 'react'; +import { EmptyState } from '@/components/ui/EmptyState'; -const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; +const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001'; const MAX_QUERY_LENGTH = 120; type SearchUser = { @@ -27,14 +27,14 @@ export function NewConversationModal({ onClose, onSelectUser, }: NewConversationModalProps) { - const [query, setQuery] = useState(""); + const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { if (!open) { - setQuery(""); + setQuery(''); setResults([]); setError(null); setLoading(false); @@ -57,18 +57,18 @@ export function NewConversationModal({ const timeout = window.setTimeout(async () => { try { const response = await fetch(`${API_URL}/users/search?q=${encodeURIComponent(safeQuery)}`, { - headers: { Authorization: "Bearer " + token }, + headers: { Authorization: 'Bearer ' + token }, }); if (!response.ok) { - throw new Error("Failed to search users"); + throw new Error('Failed to search users'); } const payload = (await response.json()) as SearchUser[]; setResults(payload); } catch (searchError) { setResults([]); - setError(searchError instanceof Error ? searchError.message : "Failed to search users"); + setError(searchError instanceof Error ? searchError.message : 'Failed to search users'); } finally { setLoading(false); } diff --git a/apps/web/src/components/chat/TransferCard.tsx b/apps/web/src/components/chat/TransferCard.tsx index f108590..e6561b2 100644 --- a/apps/web/src/components/chat/TransferCard.tsx +++ b/apps/web/src/components/chat/TransferCard.tsx @@ -1,6 +1,6 @@ -"use client"; +'use client'; -import React from "react"; +import React from 'react'; type Props = { amount: number; @@ -8,8 +8,8 @@ type Props = { txHash: string; }; -export default function TransferCard({ amount, token = "TOKEN", txHash }: Props) { - const network = process.env.NEXT_PUBLIC_NETWORK || "test"; +export default function TransferCard({ amount, token = 'TOKEN', txHash }: Props) { + const network = process.env.NEXT_PUBLIC_NETWORK || 'test'; const explorer = `https://explorer.stellar.org/tx/${txHash}?network=${network}`; return (
diff --git a/apps/web/src/components/conversations/ConversationListSidebar.tsx b/apps/web/src/components/conversations/ConversationListSidebar.tsx index a7dc39c..46871c8 100644 --- a/apps/web/src/components/conversations/ConversationListSidebar.tsx +++ b/apps/web/src/components/conversations/ConversationListSidebar.tsx @@ -1,14 +1,14 @@ -"use client"; - -import Link from "next/link"; -import { useParams } from "next/navigation"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { API_BASE_URL } from "@/lib/api"; -import { useAuth } from "@/contexts/AuthContext"; -import { useSocket } from "@/hooks/useSocket"; -import { EmptyState } from "@/components/ui/EmptyState"; -import { SkeletonLoader } from "@/components/ui/SkeletonLoader"; -import { Avatar } from "@/components/ui/Avatar"; +'use client'; + +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { API_BASE_URL } from '@/lib/api'; +import { useAuth } from '@/contexts/AuthContext'; +import { useSocket } from '@/hooks/useSocket'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { SkeletonLoader } from '@/components/ui/SkeletonLoader'; +import { Avatar } from '@/components/ui/Avatar'; interface Wallet { address?: string; @@ -32,7 +32,7 @@ interface Message { interface Conversation { id: string; - type: "dm" | "group"; + type: 'dm' | 'group'; name?: string | null; createdAt?: string; members?: Member[]; @@ -45,22 +45,22 @@ function truncate(value: string, length: number) { } function relativeTime(value?: string) { - if (!value) return ""; + if (!value) return ''; const diffSeconds = Math.max(1, Math.floor((Date.now() - new Date(value).getTime()) / 1000)); const units = [ - ["y", 31536000], - ["mo", 2592000], - ["d", 86400], - ["h", 3600], - ["m", 60], + ['y', 31536000], + ['mo', 2592000], + ['d', 86400], + ['h', 3600], + ['m', 60], ] as const; for (const [label, seconds] of units) { if (diffSeconds >= seconds) return `${Math.floor(diffSeconds / seconds)}${label} ago`; } - return "just now"; + return 'just now'; } function conversationTitle(conversation: Conversation, walletAddress?: string) { @@ -70,13 +70,13 @@ function conversationTitle(conversation: Conversation, walletAddress?: string) { ?.flatMap((member) => member.user?.wallets ?? []) .find((wallet) => wallet.address && wallet.address !== walletAddress); - return peer?.address ?? "Direct message"; + return peer?.address ?? 'Direct message'; } function getPeerUser(conversation: Conversation, currentWalletAddress?: string) { - if (conversation.type !== "dm") return null; + if (conversation.type !== 'dm') return null; const peerMember = conversation.members?.find((m) => - m.user?.wallets?.some((w) => w.address && w.address !== currentWalletAddress) + m.user?.wallets?.some((w) => w.address && w.address !== currentWalletAddress), ); return peerMember?.user ?? null; } @@ -85,7 +85,7 @@ function UnreadBadge({ count }: { count: number }) { if (count <= 0) return null; return ( - {count > 99 ? "99+" : count} + {count > 99 ? '99+' : count} ); } @@ -132,7 +132,7 @@ export function ConversationListSidebar() { }); if (!response.ok) { - throw new Error("Unable to fetch conversations"); + throw new Error('Unable to fetch conversations'); } const data = (await response.json()) as Conversation[]; @@ -151,7 +151,8 @@ export function ConversationListSidebar() { setUnreadCounts(counts); latestMessageIds.current = lastIds; } catch (err) { - if (!cancelled) setError(err instanceof Error ? err.message : "Unable to load conversations"); + if (!cancelled) + setError(err instanceof Error ? err.message : 'Unable to load conversations'); } finally { if (!cancelled) setIsLoading(false); } @@ -167,7 +168,7 @@ export function ConversationListSidebar() { useEffect(() => { if (!token || conversations.length === 0) return; - const dmConversations = conversations.filter((c) => c.type === "dm"); + const dmConversations = conversations.filter((c) => c.type === 'dm'); dmConversations.forEach(async (conv) => { const peer = getPeerUser(conv, user?.walletAddress); const peerUserId = peer?.id; @@ -186,7 +187,7 @@ export function ConversationListSidebar() { }); } } catch (err) { - console.error("Failed to fetch presence for", peerUserId, err); + console.error('Failed to fetch presence for', peerUserId, err); } }); }, [conversations, token, user?.walletAddress]); @@ -248,14 +249,14 @@ export function ConversationListSidebar() { } } - socket.on("user_online", onUserOnline); - socket.on("user_offline", onUserOffline); - socket.on("presence_update", onPresenceUpdate); + socket.on('user_online', onUserOnline); + socket.on('user_offline', onUserOffline); + socket.on('presence_update', onPresenceUpdate); return () => { - socket.off("user_online", onUserOnline); - socket.off("user_offline", onUserOffline); - socket.off("presence_update", onPresenceUpdate); + socket.off('user_online', onUserOnline); + socket.off('user_offline', onUserOffline); + socket.off('presence_update', onPresenceUpdate); }; }, [socket]); @@ -272,7 +273,7 @@ export function ConversationListSidebar() { if (conversationId === selectedIdRef.current) { // Conversation is open — mark read immediately - socket!.emit("message_read", { conversationId, lastReadMessageId: id }); + socket!.emit('message_read', { conversationId, lastReadMessageId: id }); } else { // Background conversation — increment badge setUnreadCounts((prev) => { @@ -283,9 +284,9 @@ export function ConversationListSidebar() { } } - socket.on("new_message", onNewMessage); + socket.on('new_message', onNewMessage); return () => { - socket.off("new_message", onNewMessage); + socket.off('new_message', onNewMessage); }; }, [socket]); @@ -302,7 +303,7 @@ export function ConversationListSidebar() { const lastId = latestMessageIds.current.get(selectedId); if (lastId) { - socket.emit("message_read", { conversationId: selectedId, lastReadMessageId: lastId }); + socket.emit('message_read', { conversationId: selectedId, lastReadMessageId: lastId }); } }, [selectedId, socket]); @@ -342,25 +343,23 @@ export function ConversationListSidebar() { href={`/app/conversations/${conversation.id}`} className={`flex gap-3 rounded-2xl border p-4 transition-colors ${ isSelected - ? "border-accent bg-(--accent)/15" - : "border-transparent hover:border-border hover:bg-(--background)/60" + ? 'border-accent bg-(--accent)/15' + : 'border-transparent hover:border-border hover:bg-(--background)/60' }`} >
-

- {title} -

- {conversation.type === "group" && ( +

{title}

+ {conversation.type === 'group' && ( - {memberCount} member{memberCount !== 1 ? "s" : ""} + {memberCount} member{memberCount !== 1 ? 's' : ''} )}
@@ -372,7 +371,7 @@ export function ConversationListSidebar() {

- {lastMessage ? truncate(lastMessage.content, 40) : "No messages yet"} + {lastMessage ? truncate(lastMessage.content, 40) : 'No messages yet'}

diff --git a/apps/web/src/components/landing/Features.tsx b/apps/web/src/components/landing/Features.tsx index 967f159..8d2dbc5 100644 --- a/apps/web/src/components/landing/Features.tsx +++ b/apps/web/src/components/landing/Features.tsx @@ -1,41 +1,39 @@ - - const FEATURES = [ { - icon: "💬", - title: "Wallet-to-Wallet Messaging", + icon: '💬', + title: 'Wallet-to-Wallet Messaging', description: - "Chat directly with any Stellar wallet address. No email, no username — just your public key.", + 'Chat directly with any Stellar wallet address. No email, no username — just your public key.', }, { - icon: "💸", - title: "Send Tokens in Chat", + icon: '💸', + title: 'Send Tokens in Chat', description: - "Transfer XLM or any Soroban token inside a conversation. Payments feel as natural as sending a message.", + 'Transfer XLM or any Soroban token inside a conversation. Payments feel as natural as sending a message.', }, { - icon: "🏦", - title: "Group Treasuries", + icon: '🏦', + title: 'Group Treasuries', description: - "Communities pool funds into a shared on-chain treasury. Transparent, permissionless, always auditable.", + 'Communities pool funds into a shared on-chain treasury. Transparent, permissionless, always auditable.', }, { - icon: "📋", - title: "Community Proposals", + icon: '📋', + title: 'Community Proposals', description: - "Submit funding ideas and let the group decide. Proposals live on-chain — no back-room decisions.", + 'Submit funding ideas and let the group decide. Proposals live on-chain — no back-room decisions.', }, { - icon: "🗳️", - title: "DAO-style Voting", + icon: '🗳️', + title: 'DAO-style Voting', description: - "Lightweight on-chain voting tied to your wallet stake. One address, one voice — or weighted by contribution.", + 'Lightweight on-chain voting tied to your wallet stake. One address, one voice — or weighted by contribution.', }, { - icon: "🤖", - title: "AI-powered Insights", + icon: '🤖', + title: 'AI-powered Insights', description: - "Fraud detection, proposal summarisation, and smart assistants baked into the conversation layer.", + 'Fraud detection, proposal summarisation, and smart assistants baked into the conversation layer.', }, ]; @@ -59,7 +57,9 @@ export function Features() { > {f.icon}

{f.title}

-

{f.description}

+

+ {f.description} +

))}
diff --git a/apps/web/src/components/landing/Hero.tsx b/apps/web/src/components/landing/Hero.tsx index 67ad69a..33c2ab6 100644 --- a/apps/web/src/components/landing/Hero.tsx +++ b/apps/web/src/components/landing/Hero.tsx @@ -16,16 +16,15 @@ export function Hero() {

- Chat. Pay.{" "} + Chat. Pay.{' '} Build together.

- Clicked is a decentralized messaging platform where you can send tokens - as easily as messages, fund community ideas, and govern shared - treasuries — all in one place. + Clicked is a decentralized messaging platform where you can send tokens as easily as + messages, fund community ideas, and govern shared treasuries — all in one place.

@@ -57,8 +56,17 @@ export function Hero() {
- - + +
@@ -73,21 +81,23 @@ function ChatBubble({ message, highlight, }: { - align: "left" | "right"; + align: 'left' | 'right'; name: string; message: string; highlight?: boolean; }) { return ( -
+
-
+
{name}
{message} diff --git a/apps/web/src/components/landing/HowItWorks.tsx b/apps/web/src/components/landing/HowItWorks.tsx index 84d1d0a..4eda36f 100644 --- a/apps/web/src/components/landing/HowItWorks.tsx +++ b/apps/web/src/components/landing/HowItWorks.tsx @@ -1,27 +1,27 @@ const STEPS = [ { - step: "01", - title: "Connect your wallet", + step: '01', + title: 'Connect your wallet', description: - "Sign in with your Freighter wallet. No account creation — your Stellar address is your identity.", + 'Sign in with your Freighter wallet. No account creation — your Stellar address is your identity.', }, { - step: "02", - title: "Start or join a conversation", + step: '02', + title: 'Start or join a conversation', description: - "Open a DM with any wallet address or join a group. Conversations are end-to-end linked to on-chain identities.", + 'Open a DM with any wallet address or join a group. Conversations are end-to-end linked to on-chain identities.', }, { - step: "03", - title: "Send tokens inside the chat", + step: '03', + title: 'Send tokens inside the chat', description: - "Type a transfer command or tap the payment button. Tokens move on-chain; the receipt appears inline in the thread.", + 'Type a transfer command or tap the payment button. Tokens move on-chain; the receipt appears inline in the thread.', }, { - step: "04", - title: "Fund ideas together", + step: '04', + title: 'Fund ideas together', description: - "Create a proposal, let the group vote, and release treasury funds — all without leaving the conversation.", + 'Create a proposal, let the group vote, and release treasury funds — all without leaving the conversation.', }, ]; diff --git a/apps/web/src/components/landing/Navbar.tsx b/apps/web/src/components/landing/Navbar.tsx index e834327..f3dd586 100644 --- a/apps/web/src/components/landing/Navbar.tsx +++ b/apps/web/src/components/landing/Navbar.tsx @@ -6,9 +6,15 @@ export function Navbar() { clicked. = { - Frontend: "bg-blue-500/10 text-blue-400 border-blue-500/20", - Backend: "bg-green-500/10 text-green-400 border-green-500/20", - Blockchain: "bg-purple-500/10 text-purple-400 border-purple-500/20", - Messaging: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20", - AI: "bg-pink-500/10 text-pink-400 border-pink-500/20", - Infra: "bg-orange-500/10 text-orange-400 border-orange-500/20", + Frontend: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + Backend: 'bg-green-500/10 text-green-400 border-green-500/20', + Blockchain: 'bg-purple-500/10 text-purple-400 border-purple-500/20', + Messaging: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', + AI: 'bg-pink-500/10 text-pink-400 border-pink-500/20', + Infra: 'bg-orange-500/10 text-orange-400 border-orange-500/20', }; export function TechStack() { diff --git a/apps/web/src/components/messaging/MessageThread.tsx b/apps/web/src/components/messaging/MessageThread.tsx index f1d77fd..d8c71dd 100644 --- a/apps/web/src/components/messaging/MessageThread.tsx +++ b/apps/web/src/components/messaging/MessageThread.tsx @@ -1,11 +1,11 @@ -"use client"; +'use client'; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import type { ChatMessage } from "@/hooks/useMessageHistory"; -import type { Socket } from "socket.io-client"; -import { EmptyState } from "@/components/ui/EmptyState"; -import { Spinner } from "@/components/ui/Spinner"; +import type { ChatMessage } from '@/hooks/useMessageHistory'; +import type { Socket } from 'socket.io-client'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { Spinner } from '@/components/ui/Spinner'; export interface MessageThreadProps { messages: ChatMessage[]; @@ -87,14 +87,14 @@ export function MessageThread({ setTypingUsers(new Set()); } - socket.on("typing_start", onTypingStart); - socket.on("typing_stop", onTypingStop); - socket.on("new_message", onNewMessage); + socket.on('typing_start', onTypingStart); + socket.on('typing_stop', onTypingStop); + socket.on('new_message', onNewMessage); return () => { - socket.off("typing_start", onTypingStart); - socket.off("typing_stop", onTypingStop); - socket.off("new_message", onNewMessage); + socket.off('typing_start', onTypingStart); + socket.off('typing_stop', onTypingStop); + socket.off('new_message', onNewMessage); }; }, [socket, conversationId, currentUserId]); @@ -141,8 +141,8 @@ export function MessageThread({ triggeredRef.current = true; onLoadOlder(); } - el.addEventListener("scroll", handleScroll, { passive: true }); - return () => el.removeEventListener("scroll", handleScroll); + el.addEventListener('scroll', handleScroll, { passive: true }); + return () => el.removeEventListener('scroll', handleScroll); }, [triggerDistance, loadingOlder, hasReachedStart, onLoadOlder]); return ( @@ -181,8 +181,12 @@ export function MessageThread({ )} {typingUsers.size > 0 && ( -
- {[...typingUsers].join(", ")} {typingUsers.size === 1 ? "is" : "are"} typing… +
+ {[...typingUsers].join(', ')} {typingUsers.size === 1 ? 'is' : 'are'} typing…
)} diff --git a/apps/web/src/components/treasury/ProposeWithdrawalModal.tsx b/apps/web/src/components/treasury/ProposeWithdrawalModal.tsx index 7868886..c0d64d0 100644 --- a/apps/web/src/components/treasury/ProposeWithdrawalModal.tsx +++ b/apps/web/src/components/treasury/ProposeWithdrawalModal.tsx @@ -1,19 +1,19 @@ -"use client"; +'use client'; -import { useState, type FormEvent } from "react"; -import { Modal } from "@/components/ui/Modal"; -import { apiFetch } from "@/lib/api"; -import { useToast } from "@/lib/useToast"; +import { useState, type FormEvent } from 'react'; +import { Modal } from '@/components/ui/Modal'; +import { apiFetch } from '@/lib/api'; +import { useToast } from '@/lib/useToast'; const STELLAR_ADDRESS_RE = /^G[A-Z2-7]{55}$/; const TTL_OPTIONS = [ - { label: "24 hours", value: "24h" }, - { label: "72 hours", value: "72h" }, - { label: "7 days", value: "7d" }, + { label: '24 hours', value: '24h' }, + { label: '72 hours', value: '72h' }, + { label: '7 days', value: '7d' }, ] as const; -type TTL = (typeof TTL_OPTIONS)[number]["value"]; +type TTL = (typeof TTL_OPTIONS)[number]['value']; interface Props { isOpen: boolean; @@ -24,17 +24,17 @@ interface Props { export function ProposeWithdrawalModal({ isOpen, onClose, onSuccess }: Props) { const { success, error: toastError } = useToast(); - const [amount, setAmount] = useState(""); - const [token, setToken] = useState("XLM"); - const [recipient, setRecipient] = useState(""); - const [ttl, setTtl] = useState("24h"); - const [recipientError, setRecipientError] = useState(""); + const [amount, setAmount] = useState(''); + const [token, setToken] = useState('XLM'); + const [recipient, setRecipient] = useState(''); + const [ttl, setTtl] = useState('24h'); + const [recipientError, setRecipientError] = useState(''); const [loading, setLoading] = useState(false); function validateRecipient(value: string): string { - if (!value) return "Recipient address is required"; - if (!STELLAR_ADDRESS_RE.test(value)) return "Must be a valid Stellar address (G...)"; - return ""; + if (!value) return 'Recipient address is required'; + if (!STELLAR_ADDRESS_RE.test(value)) return 'Must be a valid Stellar address (G...)'; + return ''; } async function handleSubmit(e: FormEvent) { @@ -51,29 +51,30 @@ export function ProposeWithdrawalModal({ isOpen, onClose, onSuccess }: Props) { setLoading(true); try { - const token_stored = typeof window !== "undefined" ? window.localStorage.getItem("clicked.jwt") : null; - const res = await apiFetch("/treasury/propose", { - method: "POST", + const token_stored = + typeof window !== 'undefined' ? window.localStorage.getItem('clicked.jwt') : null; + const res = await apiFetch('/treasury/propose', { + method: 'POST', body: JSON.stringify({ amount: parsedAmount, token, recipient, ttl }), headers: token_stored ? { Authorization: `Bearer ${token_stored}` } : {}, }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string }; - toastError(body.error ?? "Failed to submit proposal"); + toastError(body.error ?? 'Failed to submit proposal'); return; } - success("Withdrawal proposal submitted successfully"); + success('Withdrawal proposal submitted successfully'); onSuccess(); onClose(); // Reset - setAmount(""); - setToken("XLM"); - setRecipient(""); - setTtl("24h"); + setAmount(''); + setToken('XLM'); + setRecipient(''); + setTtl('24h'); } catch { - toastError("Network error — please try again"); + toastError('Network error — please try again'); } finally { setLoading(false); } @@ -134,12 +135,10 @@ export function ProposeWithdrawalModal({ isOpen, onClose, onSuccess }: Props) { onBlur={() => setRecipientError(validateRecipient(recipient))} placeholder="G..." className={`w-full rounded-lg bg-white/5 border px-3 py-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-accent ${ - recipientError ? "border-rose-500" : "border-white/10" + recipientError ? 'border-rose-500' : 'border-white/10' }`} /> - {recipientError && ( -

{recipientError}

- )} + {recipientError &&

{recipientError}

}
{/* TTL */} @@ -166,7 +165,7 @@ export function ProposeWithdrawalModal({ isOpen, onClose, onSuccess }: Props) { disabled={loading} className="w-full rounded-xl bg-accent py-2.5 text-sm font-semibold text-white transition hover:bg-accent/90 disabled:opacity-50 disabled:cursor-not-allowed" > - {loading ? "Submitting…" : "Submit Proposal"} + {loading ? 'Submitting…' : 'Submit Proposal'} diff --git a/apps/web/src/components/ui/Avatar.tsx b/apps/web/src/components/ui/Avatar.tsx index 78cbb4b..35bfe30 100644 --- a/apps/web/src/components/ui/Avatar.tsx +++ b/apps/web/src/components/ui/Avatar.tsx @@ -1,6 +1,6 @@ -"use client"; +'use client'; -import { useMemo, useState } from "react"; +import { useMemo, useState } from 'react'; const SIZE_MAP = { sm: 24, @@ -13,12 +13,12 @@ type Size = keyof typeof SIZE_MAP; function getInitials(value: string) { const cleaned = value.trim(); if (!cleaned) { - return "?"; + return '?'; } const parts = cleaned .split(/\s+/) - .map((part) => part.replace(/[^\p{L}\p{N}]/gu, "")) + .map((part) => part.replace(/[^\p{L}\p{N}]/gu, '')) .filter(Boolean); if (parts.length === 0) { @@ -64,11 +64,7 @@ export function Avatar({ src, fallback, size, online }: AvatarProps & { online?: } as const; return ( -
+
{showImage ? ( // eslint-disable-next-line @next/next/no-img-element diff --git a/apps/web/src/components/ui/Badge.tsx b/apps/web/src/components/ui/Badge.tsx index 5f470d0..54a52b8 100644 --- a/apps/web/src/components/ui/Badge.tsx +++ b/apps/web/src/components/ui/Badge.tsx @@ -1,6 +1,6 @@ -import React from "react"; +import React from 'react'; -export type BadgeVariant = "default" | "success" | "warning" | "danger"; +export type BadgeVariant = 'default' | 'success' | 'warning' | 'danger'; export interface BadgeProps { variant?: BadgeVariant; @@ -9,20 +9,18 @@ export interface BadgeProps { } const VARIANT_CLASS: Record = { - default: - "border-[var(--accent)]/30 bg-[var(--accent)]/15 text-[var(--accent-light)]", - success: "border-green-500/30 bg-green-500/15 text-green-300", - warning: "border-yellow-500/30 bg-yellow-500/15 text-yellow-200", - danger: "border-red-500/30 bg-red-500/15 text-red-300", + default: 'border-[var(--accent)]/30 bg-[var(--accent)]/15 text-[var(--accent-light)]', + success: 'border-green-500/30 bg-green-500/15 text-green-300', + warning: 'border-yellow-500/30 bg-yellow-500/15 text-yellow-200', + danger: 'border-red-500/30 bg-red-500/15 text-red-300', }; -export function Badge({ variant = "default", children, className }: BadgeProps) { +export function Badge({ variant = 'default', children, className }: BadgeProps) { return ( {children} ); } - diff --git a/apps/web/src/components/ui/EmptyState.tsx b/apps/web/src/components/ui/EmptyState.tsx index b38301d..fe2de9f 100644 --- a/apps/web/src/components/ui/EmptyState.tsx +++ b/apps/web/src/components/ui/EmptyState.tsx @@ -1,6 +1,6 @@ -"use client"; +'use client'; -import React from "react"; +import React from 'react'; export interface EmptyStateProps { icon: string; @@ -32,4 +32,3 @@ export function EmptyState({ icon, title, description, action }: EmptyStateProps
); } - diff --git a/apps/web/src/components/ui/Modal.tsx b/apps/web/src/components/ui/Modal.tsx index c16e7c2..aae4a75 100644 --- a/apps/web/src/components/ui/Modal.tsx +++ b/apps/web/src/components/ui/Modal.tsx @@ -1,16 +1,16 @@ -"use client"; +'use client'; -import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; -import { createPortal } from "react-dom"; +import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; const FOCUSABLE = [ - "a[href]", - "button:not([disabled])", - "input:not([disabled])", - "textarea:not([disabled])", - "select:not([disabled])", + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'textarea:not([disabled])', + 'select:not([disabled])', '[tabindex]:not([tabindex="-1"])', -].join(", "); +].join(', '); interface ModalProps { isOpen: boolean; @@ -20,15 +20,15 @@ interface ModalProps { } export function Modal({ isOpen, onClose, title, children }: ModalProps) { - const [visible, setVisible] = useState<"closed" | "open" | "closing">("closed"); + const [visible, setVisible] = useState<'closed' | 'open' | 'closing'>('closed'); const contentRef = useRef(null); const prevFocus = useRef(null); useEffect(() => { if (!isOpen) { - if (visible === "open") { + if (visible === 'open') { const frame = window.requestAnimationFrame(() => { - setVisible("closing"); + setVisible('closing'); }); return () => window.cancelAnimationFrame(frame); } @@ -38,16 +38,16 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) { prevFocus.current = document.activeElement as HTMLElement; const frame = window.requestAnimationFrame(() => { - setVisible("open"); + setVisible('open'); }); return () => window.cancelAnimationFrame(frame); }, [isOpen, visible]); useEffect(() => { - if (visible !== "closing") return; + if (visible !== 'closing') return; const timer = setTimeout(() => { - setVisible("closed"); + setVisible('closed'); prevFocus.current?.focus(); }, 150); return () => clearTimeout(timer); @@ -55,12 +55,12 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) { const handleKeyDown = useCallback( (e: KeyboardEvent) => { - if (e.key === "Escape") { + if (e.key === 'Escape') { onClose(); return; } - if (e.key === "Tab" && contentRef.current) { + if (e.key === 'Tab' && contentRef.current) { const focusable = contentRef.current.querySelectorAll(FOCUSABLE); if (focusable.length === 0) return; @@ -80,7 +80,7 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) { ); useEffect(() => { - if (visible !== "open") return; + if (visible !== 'open') return; const content = contentRef.current; if (content) { @@ -88,20 +88,20 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) { first?.focus(); } - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); }, [visible, handleKeyDown]); useEffect(() => { - if (visible === "open") { - document.body.style.overflow = "hidden"; + if (visible === 'open') { + document.body.style.overflow = 'hidden'; } return () => { - document.body.style.overflow = ""; + document.body.style.overflow = ''; }; }, [visible]); - if (visible === "closed") return null; + if (visible === 'closed') return null; return createPortal(
@@ -120,7 +120,7 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) { ref={contentRef} tabIndex={-1} className={`relative w-full max-w-lg rounded-2xl border border-white/15 bg-[#0F172A] p-5 text-white shadow-2xl outline-none transition-all duration-150 ${ - visible === "open" ? "scale-100 opacity-100" : "scale-95 opacity-0" + visible === 'open' ? 'scale-100 opacity-100' : 'scale-95 opacity-0' }`} >
diff --git a/apps/web/src/components/ui/SkeletonLoader.tsx b/apps/web/src/components/ui/SkeletonLoader.tsx index 7f984c1..2fc08e8 100644 --- a/apps/web/src/components/ui/SkeletonLoader.tsx +++ b/apps/web/src/components/ui/SkeletonLoader.tsx @@ -1,6 +1,6 @@ -import React from "react"; +import React from 'react'; -export type SkeletonVariant = "text" | "avatar" | "card"; +export type SkeletonVariant = 'text' | 'avatar' | 'card'; export interface SkeletonLoaderProps { variant: SkeletonVariant; @@ -12,7 +12,7 @@ export interface SkeletonLoaderProps { } function clampCount(value: number | undefined) { - const n = typeof value === "number" ? value : 2; + const n = typeof value === 'number' ? value : 2; return Math.max(1, Math.min(3, n)); } @@ -21,7 +21,7 @@ export function SkeletonLoader({ variant, count }: SkeletonLoaderProps) { return ( <> - {variant === "text" ? ( + {variant === 'text' ? (
{Array.from({ length: safeCount }).map((_, idx) => { const widths = [100, 85, 70] as const; @@ -37,14 +37,14 @@ export function SkeletonLoader({ variant, count }: SkeletonLoaderProps) {
) : null} - {variant === "avatar" ? ( + {variant === 'avatar' ? (