From 31ed73bb6acd0cb37566e7f59fe2c370ac360c9f Mon Sep 17 00:00:00 2001 From: deekshas99 Date: Tue, 23 Dec 2025 20:00:56 +0530 Subject: [PATCH 1/5] Contact card --- app/components/ContactCard.tsx | 379 +++++++++++++++++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 app/components/ContactCard.tsx diff --git a/app/components/ContactCard.tsx b/app/components/ContactCard.tsx new file mode 100644 index 0000000..bba9a3e --- /dev/null +++ b/app/components/ContactCard.tsx @@ -0,0 +1,379 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { PencilIcon, ArrowUpTrayIcon, ChevronUpIcon } from '@heroicons/react/24/outline'; +import { cn } from '@/lib/utils'; + +interface Contact { + id: string; + firstName: string | null; + lastName: string | null; + screenName: string | null; + email: string | null; + nostrPubkey: string | null; + metadata: Record | null; +} + +interface SharedPrism { + id: string; + name: string; + slug: string; + thumbnail?: string | null; // Profile image/thumbnail URL +} + +interface Tag { + text: string; + color?: string; // Hex color code +} + +interface ContactCardProps { + contact: Contact; + variant?: 'open' | 'close'; +} + +export default function ContactCard({ contact, variant = 'open' }: ContactCardProps) { + // Match Figma variants: + // - "open": details panel expanded by default + // - "close": compact card with no details panel + const [isOpen, setIsOpen] = useState(variant === 'open'); + const [sharedPrisms, setSharedPrisms] = useState([]); + const [detailsCount, setDetailsCount] = useState(0); + + // Fetch shared prisms for this contact + useEffect(() => { + const fetchSharedPrisms = async () => { + try { + const response = await fetch(`/api/contacts/${contact.id}/shared-prisms`); + if (response.ok) { + const data = await response.json(); + setSharedPrisms(data.prisms || []); + } + } catch (err) { + console.error('Error fetching shared prisms:', err); + setSharedPrisms([]); + } + }; + + fetchSharedPrisms(); + }, [contact.id]); + + // Calculate details count + useEffect(() => { + let count = 0; + if (contact.email) count++; + if (contact.nostrPubkey) count++; + const metadata = contact.metadata && typeof contact.metadata === 'object' ? contact.metadata : {}; + if (metadata.telegram) count++; + if (metadata.twitter) count++; + if (metadata.github) count++; + setDetailsCount(count); + }, [contact]); + + const displayName = contact.firstName && contact.lastName + ? `${contact.firstName} ${contact.lastName}` + : contact.screenName || 'Unnamed Contact'; + + const initials = contact.firstName && contact.lastName + ? `${contact.firstName[0]}${contact.lastName[0]}`.toUpperCase() + : contact.screenName + ? contact.screenName[0].toUpperCase() + : '?'; + + const metadata = contact.metadata && typeof contact.metadata === 'object' ? contact.metadata : {}; + const telegram = typeof metadata.telegram === 'string' ? metadata.telegram : null; + const twitter = typeof metadata.twitter === 'string' ? metadata.twitter : null; + const github = typeof metadata.github === 'string' ? metadata.github : null; + + // Extract tags from metadata with color support + const tags: Tag[] = []; + + // Support tags as array of objects with text and color + if (Array.isArray(metadata.tags)) { + metadata.tags.forEach((tag: unknown) => { + if (typeof tag === 'string') { + tags.push({ text: tag }); + } else if (typeof tag === 'object' && tag !== null && 'text' in tag) { + tags.push({ + text: String(tag.text), + color: 'color' in tag ? String(tag.color) : undefined, + }); + } + }); + } + + // Support interests as tags + if (Array.isArray(metadata.interests)) { + metadata.interests.forEach((interest: unknown) => { + if (typeof interest === 'string') { + tags.push({ text: interest }); + } + }); + } + + // Extract tags from bio if it contains specific keywords + if (typeof metadata.bio === 'string' && metadata.bio) { + const bioLower = metadata.bio.toLowerCase(); + if (bioLower.includes('lightning')) { + tags.push({ text: 'Lightning Network enthusiast', color: '#8a05ff' }); + } + if (bioLower.includes('conference') || bioLower.includes('bitcoin')) { + tags.push({ text: 'bitcoin Conference 2023', color: '#345204' }); + } + } + + // Format screen name display + const screenNameDisplay = contact.screenName + ? `${contact.firstName || contact.screenName}@${contact.screenName}` + : contact.email || ''; + + const toggleDetails = () => { + setIsOpen(!isOpen); + }; + + const handleShareProfile = async () => { + try { + // Create a shareable link or copy to clipboard + const profileUrl = `${window.location.origin}/contacts/${contact.id}`; + + if (navigator.share) { + await navigator.share({ + title: `${displayName}'s Profile`, + text: `Check out ${displayName}'s contact profile`, + url: profileUrl, + }); + } else { + // Fallback: copy to clipboard + await navigator.clipboard.writeText(profileUrl); + alert('Profile link copied to clipboard!'); + } + } catch (err) { + console.error('Error sharing profile:', err); + } + }; + + const handleCopyAddress = async () => { + const address = contact.nostrPubkey || contact.email || screenNameDisplay; + if (address) { + try { + await navigator.clipboard.writeText(address); + // Could show a toast notification here + } catch (err) { + console.error('Failed to copy:', err); + } + } + }; + + // Default tag colors if not specified + const getTagColor = (index: number, tag: Tag): string => { + if (tag.color) return tag.color; + // Default colors: purple, green, blue, orange, etc. + const defaultColors = ['#8a05ff', '#345204', '#0066cc', '#ff6600', '#cc0066']; + return defaultColors[index % defaultColors.length]; + }; + + // Get remaining prism count (after showing first 3) + const remainingPrismCount = sharedPrisms.length > 3 ? sharedPrisms.length - 3 : 0; + const visiblePrisms = sharedPrisms.slice(0, 3); + + // Only show the details section for the "open" variant + const showDetailsSection = variant === 'open'; + + return ( +
+ {/* Header with Shared Prisms and CTAs */} +
+ {/* Shared Prisms with Thumbnails */} +
+ {sharedPrisms.length > 0 && ( + <> +
+ {/* Show up to 3 prism thumbnails */} + {visiblePrisms.map((prism, index) => ( +
+ {prism.thumbnail ? ( + {prism.name} { + // Fallback to blank if image fails to load + e.currentTarget.style.display = 'none'; + }} + /> + ) : ( + // Blank thumbnail if no profile set +
+ )} +
+ ))} +
+ {remainingPrismCount > 0 && ( +
+

+ +{remainingPrismCount} +

+

+ prism +

+
+ )} + + )} +
+ + {/* CTA Buttons: Edit Profile and Share Profile */} +
+ + + + +
+
+ + {/* Main Content */} +
+ {/* Avatar and Name Section */} +
+ {/* Avatar */} +
+ {initials} + {/* Placeholder for avatar image if available */} +
+ + {/* Name and Screen Name */} +
+

+ {displayName} +

+ +
+
+ + {/* Details Section */} + {showDetailsSection && ( +
+ + + {/* Expanded Details */} + {isOpen && ( +
+ {contact.email && ( +
+

Email

+

+ {contact.email} +

+
+ )} + + {contact.nostrPubkey && ( +
+

Nostr

+

+ {contact.nostrPubkey.slice(0, 20)}... +

+
+ )} + + {telegram && ( +
+

Telegram

+

+ {telegram} +

+
+ )} + + {twitter && ( +
+

Twitter

+

+ {twitter} +

+
+ )} + + {github && ( +
+

Github

+

+ {github} +

+
+ )} +
+ )} +
+ )} + + {/* Tags Section with Varied Colors */} + {tags.length > 0 && ( +
+ {tags.map((tag, index) => { + const tagColor = getTagColor(index, tag); + return ( +
+

+ {tag.text} +

+
+ ); + })} +
+ )} +
+
+ ); +} From d70ce4db9add97a79fc10a3e2f095a2cdfc81491 Mon Sep 17 00:00:00 2001 From: deekshas99 Date: Tue, 23 Dec 2025 20:21:09 +0530 Subject: [PATCH 2/5] Refactor ContactCard component to improve prism count handling and display. Updated logic to show total prism count instead of remaining count, enhancing clarity in the UI. --- app/components/ContactCard.tsx | 71 ++++++++++++++++------------------ 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/app/components/ContactCard.tsx b/app/components/ContactCard.tsx index bba9a3e..a70c9f7 100644 --- a/app/components/ContactCard.tsx +++ b/app/components/ContactCard.tsx @@ -173,7 +173,8 @@ export default function ContactCard({ contact, variant = 'open' }: ContactCardPr }; // Get remaining prism count (after showing first 3) - const remainingPrismCount = sharedPrisms.length > 3 ? sharedPrisms.length - 3 : 0; + const prismCount = sharedPrisms.length; + const remainingPrismCount = prismCount > 3 ? prismCount - 3 : 0; const visiblePrisms = sharedPrisms.slice(0, 3); // Only show the details section for the "open" variant @@ -185,44 +186,38 @@ export default function ContactCard({ contact, variant = 'open' }: ContactCardPr
{/* Shared Prisms with Thumbnails */}
- {sharedPrisms.length > 0 && ( - <> -
- {/* Show up to 3 prism thumbnails */} - {visiblePrisms.map((prism, index) => ( -
- {prism.thumbnail ? ( - {prism.name} { - // Fallback to blank if image fails to load - e.currentTarget.style.display = 'none'; - }} - /> - ) : ( - // Blank thumbnail if no profile set -
- )} -
- ))} +
+ {/* Show up to 3 prism thumbnails */} + {visiblePrisms.map((prism) => ( +
+ {prism.thumbnail ? ( + {prism.name} { + // Fallback to blank if image fails to load + e.currentTarget.style.display = 'none'; + }} + /> + ) : ( + // Blank thumbnail if no profile set +
+ )}
- {remainingPrismCount > 0 && ( -
-

- +{remainingPrismCount} -

-

- prism -

-
- )} - - )} + ))} +
+
+

+ +{prismCount} +

+

+ prism +

+
{/* CTA Buttons: Edit Profile and Share Profile */} From 2656a7504de309e669a394ce3e5b378e2681377b Mon Sep 17 00:00:00 2001 From: deekshas99 Date: Tue, 23 Dec 2025 20:27:10 +0530 Subject: [PATCH 3/5] Update ContactCard component styles for improved UI consistency. Adjusted button and SVG sizes, and modified hover effects for better accessibility and visual appeal. --- app/components/ContactCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/ContactCard.tsx b/app/components/ContactCard.tsx index a70c9f7..ead2845 100644 --- a/app/components/ContactCard.tsx +++ b/app/components/ContactCard.tsx @@ -265,11 +265,11 @@ export default function ContactCard({ contact, variant = 'open' }: ContactCardPr From 0db05c242dece3ef9224057ca97561e37d015756 Mon Sep 17 00:00:00 2001 From: deekshas99 Date: Tue, 23 Dec 2025 20:41:34 +0530 Subject: [PATCH 4/5] Refactor ContactsPage to utilize ContactCard component for rendering contacts. Updated layout styles for improved responsiveness and consistency. --- app/components/ContactCard.tsx | 2 +- app/contacts/page.tsx | 45 +++++----------------------------- 2 files changed, 7 insertions(+), 40 deletions(-) diff --git a/app/components/ContactCard.tsx b/app/components/ContactCard.tsx index ead2845..1239895 100644 --- a/app/components/ContactCard.tsx +++ b/app/components/ContactCard.tsx @@ -181,7 +181,7 @@ export default function ContactCard({ contact, variant = 'open' }: ContactCardPr const showDetailsSection = variant === 'open'; return ( -
+
{/* Header with Shared Prisms and CTAs */}
{/* Shared Prisms with Thumbnails */} diff --git a/app/contacts/page.tsx b/app/contacts/page.tsx index 4f506c7..2f68398 100644 --- a/app/contacts/page.tsx +++ b/app/contacts/page.tsx @@ -1,9 +1,9 @@ 'use client'; import { useEffect, useState } from 'react'; -import Link from 'next/link'; import { useRouter } from 'next/navigation'; import Button from '@/app/components/Button'; +import ContactCard from '@/app/components/ContactCard'; interface Contact { id: string; @@ -61,7 +61,7 @@ export default function ContactsPage() { if (loading) { return ( -
+
@@ -79,7 +79,7 @@ export default function ContactsPage() { if (error) { return ( -
+

Error

{error}

@@ -99,7 +99,7 @@ export default function ContactsPage() { } return ( -
+

Contacts

-
+
{contacts.map((contact) => ( -
-
-
-

- {contact.firstName && contact.lastName - ? `${contact.firstName} ${contact.lastName}` - : contact.screenName || 'Unnamed Contact'} -

- {contact.screenName && ( -

@{contact.screenName}

- )} - {contact.email && ( -

{contact.email}

- )} - {contact.nostrPubkey && ( -

- {contact.nostrPubkey} -

- )} -
- - Edit - - - - -
- {contact.metadata && typeof contact.metadata === 'object' && 'bio' in contact.metadata && typeof contact.metadata.bio === 'string' && ( -

{contact.metadata.bio}

- )} -
+ ))}
From abf2fab2f0f99e75f672cb77df3acc3f99e92f22 Mon Sep 17 00:00:00 2001 From: deekshas99 Date: Tue, 23 Dec 2025 20:44:09 +0530 Subject: [PATCH 5/5] Contact card --- app/api/contacts/[id]/prism-count/route.ts | 35 ++++++++++++++ app/api/contacts/[id]/shared-prisms/route.ts | 50 ++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 app/api/contacts/[id]/prism-count/route.ts create mode 100644 app/api/contacts/[id]/shared-prisms/route.ts diff --git a/app/api/contacts/[id]/prism-count/route.ts b/app/api/contacts/[id]/prism-count/route.ts new file mode 100644 index 0000000..7d23d07 --- /dev/null +++ b/app/api/contacts/[id]/prism-count/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import prisma from '@/lib/prisma'; +import { requireSuperAdmin } from '@/lib/auth'; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const { id } = await context.params; + try { + await requireSuperAdmin(); + + // Count unique prisms for this contact through payment destinations -> splits -> prisms + const prismCount = await prisma.prism.count({ + where: { + splits: { + some: { + paymentDestination: { + contactId: id, + }, + }, + }, + }, + }); + + return NextResponse.json({ count: prismCount }); + } catch (error) { + if (error instanceof Error && error.message.includes('Unauthorized')) { + return new NextResponse('Unauthorized', { status: 401 }); + } + console.error('Error fetching prism count:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } +} + diff --git a/app/api/contacts/[id]/shared-prisms/route.ts b/app/api/contacts/[id]/shared-prisms/route.ts new file mode 100644 index 0000000..f3f74c6 --- /dev/null +++ b/app/api/contacts/[id]/shared-prisms/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server'; +import prisma from '@/lib/prisma'; +import { requireSuperAdmin } from '@/lib/auth'; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const { id } = await context.params; + try { + await requireSuperAdmin(); + + // Get unique prisms for this contact through payment destinations -> splits -> prisms + const prisms = await prisma.prism.findMany({ + where: { + splits: { + some: { + paymentDestination: { + contactId: id, + }, + }, + }, + }, + select: { + id: true, + name: true, + slug: true, + }, + distinct: ['id'], + }); + + // For now, prisms don't have thumbnails in the schema + // When thumbnail support is added, it can be included here + const prismsWithThumbnails = prisms.map(prism => ({ + id: prism.id, + name: prism.name, + slug: prism.slug, + thumbnail: null, // Will show blank thumbnail if no profile is set + })); + + return NextResponse.json({ prisms: prismsWithThumbnails, count: prisms.length }); + } catch (error) { + if (error instanceof Error && error.message.includes('Unauthorized')) { + return new NextResponse('Unauthorized', { status: 401 }); + } + console.error('Error fetching shared prisms:', error); + return new NextResponse('Internal Server Error', { status: 500 }); + } +} +