feat: sponsor-ready profiles with follow, events and audience stats

This commit is contained in:
SkateSpots 2026-10-29 20:20:49 +07:00
parent 9c22bd78f8
commit bc1987f78c
29 changed files with 438 additions and 10 deletions

View file

@ -119,6 +119,7 @@ export function useFollowActions() {
onSuccess: (_targetPubkey) => {
// Invalidate queries to refetch
queryClient.invalidateQueries({ queryKey: ['following', user?.pubkey] });
queryClient.invalidateQueries({ queryKey: ['followers', _targetPubkey] });
queryClient.invalidateQueries({ queryKey: ['trust-score'] });
toast({
@ -165,9 +166,10 @@ export function useFollowActions() {
return targetPubkey;
},
onSuccess: () => {
onSuccess: (targetPubkey) => {
// Invalidate queries
queryClient.invalidateQueries({ queryKey: ['following', user?.pubkey] });
queryClient.invalidateQueries({ queryKey: ['followers', targetPubkey] });
queryClient.invalidateQueries({ queryKey: ['trust-score'] });
toast({

View file

@ -105,6 +105,42 @@ export function useUpcomingSkateEvents(limit = 20, options: { enabled?: boolean
});
}
/** Events authored by one pubkey (profile page). Upcoming first, then recent. */
export function useUserEvents(pubkey: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['user-skate-events', pubkey, getQueryKeySuffix()],
enabled: !!pubkey,
select: (events: SkateEvent[]) => filterAdminHidden(events, CALENDAR_EVENT_KIND, hidden),
queryFn: async () => {
if (!pubkey) return [];
const signal = AbortSignal.timeout(12000);
const events = await querySkateContent(
[{
kinds: [CALENDAR_EVENT_KIND],
authors: [pubkey],
'#t': ['skateboarding'],
limit: 50,
}],
{ signal },
);
const now = Math.floor(Date.now() / 1000);
return events
.map(parseSkateEvent)
.filter((event): event is SkateEvent => !!event)
.sort((a, b) => {
const aUpcoming = a.start >= now;
const bUpcoming = b.start >= now;
if (aUpcoming !== bUpcoming) return aUpcoming ? -1 : 1;
return aUpcoming ? a.start - b.start : b.start - a.start;
});
},
staleTime: 60_000,
});
}
export function useSkateEvent(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();

View file

@ -962,6 +962,16 @@ const batch6 = {
nip05Verified: "Verificat amb NIP-05",
nip05NotVerified: "NIP-05 no verificat amb aquesta clau",
message: "Missatge",
eventsHosted: "Esdeveniments organitzats",
forSponsors: "Per a patrocinadors i col·laboracions",
sponsorPitch: "Patrocini, col·laboració, foto/vídeo o esdeveniment? Comença una conversa.",
startCollab: "Inicia un xat de col·laboració",
viewRiderCard: "Veure la fitxa del patinador",
collabMessage: "Hola {{name}}, voldria parlar d'un patrocini o d'una col·laboració.",
followers: "Seguidors",
following: "Seguint",
follow: "Segueix",
unfollow: "Deixa de seguir",
zapsReceived: "zaps rebuts",
memberSince: "Membre des de",
badges: "Insígnies",

View file

@ -936,6 +936,16 @@ export const csBatch4 = {
nip05Verified: "NIP-05 ověřeno",
nip05NotVerified: "NIP-05 neověřeno vůči tomuto klíči",
message: "Zpráva",
eventsHosted: "Uspořádané akce",
forSponsors: "Pro sponzory a spolupráce",
sponsorPitch: "Sponzorství, spolupráce, foto/video nebo akce? Začni konverzaci.",
startCollab: "Začít chat o spolupráci",
viewRiderCard: "Zobrazit kartu jezdce",
collabMessage: "Ahoj {{name}}, chtěl bych mluvit o sponzorství nebo spolupráci.",
followers: "Sledující",
following: "Sleduji",
follow: "Sledovat",
unfollow: "Přestat sledovat",
zapsReceived: "přijatých zaps",
memberSince: "Členem od",
badges: "Odznaky",

View file

@ -936,6 +936,16 @@ const deBatch4 = {
nip05Verified: "NIP-05 verifiziert",
nip05NotVerified: "NIP-05 nicht gegen diesen Schlüssel verifiziert",
message: "Nachricht",
eventsHosted: "Organisierte Events",
forSponsors: "Für Sponsoren & Kooperationen",
sponsorPitch: "Sponsoring, Kooperation, Foto/Video oder Event? Starte ein Gespräch.",
startCollab: "Kooperations-Chat starten",
viewRiderCard: "Skateboarder-Karte ansehen",
collabMessage: "Hi {{name}}, ich würde gern über ein Sponsoring oder eine Kooperation sprechen.",
followers: "Follower",
following: "Folge ich",
follow: "Folgen",
unfollow: "Entfolgen",
zapsReceived: "erhaltene Zaps",
memberSince: "Mitglied seit",
badges: "Abzeichen",

View file

@ -936,6 +936,16 @@ const elBatch4 = {
nip05Verified: "Επαληθευμένο NIP-05",
nip05NotVerified: "Το NIP-05 δεν επαληθεύτηκε με αυτό το κλειδί",
message: "Μήνυμα",
eventsHosted: "Εκδηλώσεις που διοργάνωσες",
forSponsors: "Για χορηγούς και συνεργασίες",
sponsorPitch: "Χορηγία, συνεργασία, φωτογραφία/βίντεο ή εκδήλωση; Ξεκίνα μια συζήτηση.",
startCollab: "Έναρξη συνομιλίας συνεργασίας",
viewRiderCard: "Προβολή κάρτας σκέιτερ",
collabMessage: "Γεια σου {{name}}, θα ήθελα να μιλήσουμε για χορηγία ή συνεργασία.",
followers: "Ακόλουθοι",
following: "Ακολουθείς",
follow: "Ακολούθησε",
unfollow: "Σταμάτα να ακολουθείς",
zapsReceived: "zaps που ελήφθησαν",
memberSince: "Μέλος από",
badges: "Badges",

View file

@ -6744,6 +6744,16 @@ walletUi: {
nip05Verified: 'NIP-05 verified',
nip05NotVerified: 'NIP-05 not verified against this key',
message: 'Message',
eventsHosted: 'Events hosted',
forSponsors: 'For sponsors & collabs',
sponsorPitch: 'Sponsorship, collab, photo/video or event? Start a conversation.',
startCollab: 'Start a collab chat',
viewRiderCard: 'View rider card',
collabMessage: "Hi {{name}}, I'd like to talk about a sponsorship or collaboration.",
followers: 'Followers',
following: 'Following',
follow: 'Follow',
unfollow: 'Unfollow',
zapsReceived: 'zaps received',
memberSince: 'Member since',
badges: 'Badges',

View file

@ -6744,6 +6744,16 @@ walletUi: {
nip05Verified: 'NIP-05 verificado',
nip05NotVerified: 'NIP-05 no verificado con esta clave',
message: 'Mensaje',
eventsHosted: 'Eventos organizados',
forSponsors: 'Para patrocinadores y colaboraciones',
sponsorPitch: '¿Patrocinio, colaboración, foto/vídeo o evento? Empieza una conversación.',
startCollab: 'Iniciar chat de colaboración',
viewRiderCard: 'Ver tarjeta de rider',
collabMessage: 'Hola {{name}}, me gustaría hablar sobre un patrocinio o una colaboración.',
followers: 'Seguidores',
following: 'Siguiendo',
follow: 'Seguir',
unfollow: 'Dejar de seguir',
zapsReceived: 'zaps recibidos',
memberSince: 'Miembro desde',
badges: 'Insignias',

View file

@ -962,6 +962,16 @@ const batch6 = {
nip05Verified: "NIP-05 egiaztatua",
nip05NotVerified: "NIP-05 ez da gako honekin egiaztatu",
message: "Mezua",
eventsHosted: "Antolatutako ekitaldiak",
forSponsors: "Babesleentzat eta lankidetzetarako",
sponsorPitch: "Babesa, lankidetza, argazki/bideoa edo ekitaldia? Hasi solasaldia.",
startCollab: "Hasi lankidetza-txata",
viewRiderCard: "Ikusi patinatzailearen txartela",
collabMessage: "Kaixo {{name}}, babes edo lankidetza bati buruz hitz egin nahi nuke.",
followers: "Jarraitzaileak",
following: "Jarraitzen",
follow: "Jarraitu",
unfollow: "Utzi jarraitzeari",
zapsReceived: "jasotako zapak",
memberSince: "Kidea noiztik",
badges: "Dominak",

View file

@ -936,6 +936,16 @@ const frBatch4 = {
nip05Verified: "NIP-05 vérifié",
nip05NotVerified: "NIP-05 non vérifié pour cette clé",
message: "Message",
eventsHosted: "Événements organisés",
forSponsors: "Pour les sponsors et collaborations",
sponsorPitch: "Sponsoring, collaboration, photo/vidéo ou événement ? Lance la conversation.",
startCollab: "Démarrer une discussion de collaboration",
viewRiderCard: "Voir la fiche du skateur",
collabMessage: "Salut {{name}}, j'aimerais parler d'un sponsoring ou d'une collaboration.",
followers: "Abonnés",
following: "Abonnements",
follow: "Suivre",
unfollow: "Ne plus suivre",
zapsReceived: "zaps reçus",
memberSince: "Membre depuis",
badges: "Badges",

View file

@ -962,6 +962,16 @@ const batch6 = {
"nip05Verified": "Verificado por NIP-05",
"nip05NotVerified": "NIP-05 non verificado con esta chave",
"message": "Mensaxe",
"eventsHosted": "Eventos organizados",
"forSponsors": "Para patrocinadores e colaboracións",
"sponsorPitch": "Patrocinio, colaboración, foto/vídeo ou evento? Comeza unha conversa.",
"startCollab": "Iniciar chat de colaboración",
"viewRiderCard": "Ver a ficha do patinador",
"collabMessage": "Ola {{name}}, gustaríame falar dun patrocinio ou dunha colaboración.",
"followers": "Seguidores",
"following": "Seguindo",
"follow": "Seguir",
"unfollow": "Deixar de seguir",
"zapsReceived": "zaps recibidos",
"memberSince": "Membro desde",
"badges": "Insignias",

View file

@ -962,6 +962,16 @@ const batch6 = {
"nip05Verified": "NIP-05 सत्यापित",
"nip05NotVerified": "NIP-05 इस कुंजी से सत्यापित नहीं",
"message": "संदेश",
"eventsHosted": "आयोजित कार्यक्रम",
"forSponsors": "प्रायोजकों और सहयोगों के लिए",
"sponsorPitch": "प्रायोजन, सहयोग, फ़ोटो/वीडियो या कार्यक्रम? बातचीत शुरू करें।",
"startCollab": "सहयोग बातचीत शुरू करें",
"viewRiderCard": "स्केटर कार्ड देखें",
"collabMessage": "नमस्ते {{name}}, क्या हम प्रायोजन या सहयोग पर बात कर सकते हैं?",
"followers": "फ़ॉलोअर",
"following": "फ़ॉलो कर रहे",
"follow": "फ़ॉलो करें",
"unfollow": "फ़ॉलो हटाएँ",
"zapsReceived": "zaps प्राप्त",
"memberSince": "सदस्यता से",
"badges": "बैज",

View file

@ -942,6 +942,16 @@ const idBatch4 = {
nip05Verified: 'Terverifikasi NIP-05',
nip05NotVerified: 'NIP-05 tidak terverifikasi terhadap kunci ini',
message: 'Pesan',
eventsHosted: 'Acara yang diselenggarakan',
forSponsors: 'Untuk sponsor & kolaborasi',
sponsorPitch: 'Sponsorship, kolaborasi, foto/video, atau acara? Mulai percakapan.',
startCollab: 'Mulai chat kolaborasi',
viewRiderCard: 'Lihat kartu peseluncur',
collabMessage: 'Hai {{name}}, saya ingin membahas sponsorship atau kolaborasi.',
followers: 'Pengikut',
following: 'Mengikuti',
follow: 'Ikuti',
unfollow: 'Berhenti Ikuti',
zapsReceived: 'zap diterima',
memberSince: 'Anggota sejak',
badges: 'Lencana',

View file

@ -936,6 +936,16 @@ const itBatch4 = {
nip05Verified: "Verificato NIP-05",
nip05NotVerified: "NIP-05 non verificato con questa chiave",
message: "Messaggio",
eventsHosted: "Eventi organizzati",
forSponsors: "Per sponsor e collaborazioni",
sponsorPitch: "Sponsorizzazione, collaborazione, foto/video o evento? Inizia una conversazione.",
startCollab: "Avvia una chat di collaborazione",
viewRiderCard: "Vedi la scheda dello skater",
collabMessage: "Ciao {{name}}, vorrei parlare di una sponsorizzazione o di una collaborazione.",
followers: "Follower",
following: "Seguiti",
follow: "Segui",
unfollow: "Smetti di seguire",
zapsReceived: "zap ricevuti",
memberSince: "Membro dal",
badges: "Distintivi",

View file

@ -945,6 +945,16 @@ const jaBatch7 = {
nip05Verified: 'NIP-05認証済み',
nip05NotVerified: 'NIP-05がこの鍵で認証されていません',
message: 'メッセージ',
eventsHosted: '主催イベント',
forSponsors: 'スポンサー・コラボ募集',
sponsorPitch: 'スポンサー、コラボ、写真/動画、イベントの依頼はありませんか?まずはご相談ください。',
startCollab: 'コラボチャットを始める',
viewRiderCard: 'ライダーカードを見る',
collabMessage: 'こんにちは{{name}}さん、スポンサーやコラボについてお話ししたいです。',
followers: 'フォロワー',
following: 'フォロー中',
follow: 'フォロー',
unfollow: 'フォロー解除',
zapsReceived: '受信したzaps',
memberSince: '登録日',
badges: 'バッジ',

View file

@ -944,6 +944,16 @@ const koBatch4 = {
nip05Verified: 'NIP-05 인증됨',
nip05NotVerified: '이 키에 대해 NIP-05가 인증되지 않음',
message: '메시지',
eventsHosted: '주최한 이벤트',
forSponsors: '스폰서 및 협업 문의',
sponsorPitch: '스폰서십, 협업, 사진/영상 또는 이벤트? 대화를 시작해 보세요.',
startCollab: '협업 채팅 시작',
viewRiderCard: '라이더 카드 보기',
collabMessage: '안녕하세요 {{name}}님, 스폰서십이나 협업에 대해 이야기하고 싶어요.',
followers: '팔로워',
following: '팔로잉',
follow: '팔로우',
unfollow: '언팔로우',
zapsReceived: '받은 Zap',
memberSince: '가입일',
badges: '배지',

View file

@ -944,6 +944,16 @@ const nlBatch4 = {
nip05Verified: 'NIP-05 geverifieerd',
nip05NotVerified: 'NIP-05 niet geverifieerd tegen deze sleutel',
message: 'Bericht',
eventsHosted: 'Georganiseerde evenementen',
forSponsors: 'Voor sponsors & samenwerkingen',
sponsorPitch: 'Sponsoring, samenwerking, foto/video of evenement? Start een gesprek.',
startCollab: 'Start een samenwerkingschat',
viewRiderCard: 'Bekijk de skaterkaart',
collabMessage: 'Hoi {{name}}, ik wil graag praten over een sponsoring of samenwerking.',
followers: 'Volgers',
following: 'Volgend',
follow: 'Volgen',
unfollow: 'Ontvolgen',
zapsReceived: 'zaps ontvangen',
memberSince: 'Lid sinds',
badges: 'Badges',

View file

@ -936,6 +936,16 @@ const plBatch4 = {
nip05Verified: "Zweryfikowano NIP-05",
nip05NotVerified: "NIP-05 niezweryfikowany względem tego klucza",
message: "Wiadomość",
eventsHosted: "Organizowane wydarzenia",
forSponsors: "Dla sponsorów i współprac",
sponsorPitch: "Sponsoring, współpraca, foto/wideo lub wydarzenie? Rozpocznij rozmowę.",
startCollab: "Rozpocznij czat o współpracy",
viewRiderCard: "Zobacz kartę deskorolkarza",
collabMessage: "Cześć {{name}}, chciałbym porozmawiać o sponsoringu lub współpracy.",
followers: "Obserwujący",
following: "Obserwowani",
follow: "Obserwuj",
unfollow: "Przestań obserwować",
zapsReceived: "otrzymanych zapów",
memberSince: "Członek od",
badges: "Odznaki",

View file

@ -1071,6 +1071,16 @@ const ptBatch4 = {
nip05Verified: 'Verificado por NIP-05',
nip05NotVerified: 'NIP-05 não verificado para esta chave',
message: 'Mensagem',
eventsHosted: 'Eventos organizados',
forSponsors: 'Para patrocinadores e colaborações',
sponsorPitch: 'Patrocínio, colaboração, foto/vídeo ou evento? Comece uma conversa.',
startCollab: 'Iniciar chat de colaboração',
viewRiderCard: 'Ver cartão do skatista',
collabMessage: 'Oi {{name}}, quero falar sobre um patrocínio ou uma colaboração.',
followers: 'Seguidores',
following: 'Seguindo',
follow: 'Seguir',
unfollow: 'Deixar de seguir',
zapsReceived: 'zaps recebidos',
memberSince: 'Membro desde',
badges: 'Selos',

View file

@ -936,6 +936,16 @@ const roBatch4 = {
nip05Verified: 'Verificat NIP-05',
nip05NotVerified: 'NIP-05 neverificat pentru această cheie',
message: 'Mesaj',
eventsHosted: 'Evenimente organizate',
forSponsors: 'Pentru sponsori și colaborări',
sponsorPitch: 'Sponsorizare, colaborare, foto/video sau eveniment? Începe o conversație.',
startCollab: 'Începe un chat de colaborare',
viewRiderCard: 'Vezi cardul riderului',
collabMessage: 'Salut {{name}}, aș vrea să vorbim despre o sponsorizare sau o colaborare.',
followers: 'Urmăritori',
following: 'Urmărești',
follow: 'Urmărește',
unfollow: 'Nu mai urmări',
zapsReceived: 'zap-uri primite',
memberSince: 'Membru din',
badges: 'Insigne',

View file

@ -936,6 +936,16 @@ const ruBatch4 = {
nip05Verified: 'NIP-05 подтверждён',
nip05NotVerified: 'NIP-05 не подтверждён для этого ключа',
message: 'Сообщение',
eventsHosted: 'Проведённые мероприятия',
forSponsors: 'Для спонсоров и коллабораций',
sponsorPitch: 'Спонсорство, коллаборация, фото/видео или мероприятие? Начните разговор.',
startCollab: 'Начать чат о коллаборации',
viewRiderCard: 'Посмотреть карточку райдера',
collabMessage: 'Привет, {{name}}! Хочу обсудить спонсорство или коллаборацию.',
followers: 'Подписчики',
following: 'Подписки',
follow: 'Подписаться',
unfollow: 'Отписаться',
zapsReceived: 'zaps получено',
memberSince: 'Участник с',
badges: 'Значки',

View file

@ -936,6 +936,16 @@ const svBatch4 = {
nip05Verified: "NIP-05 verifierad",
nip05NotVerified: "NIP-05 inte verifierad mot den här nyckeln",
message: "Meddelande",
eventsHosted: "Arrangerade evenemang",
forSponsors: "För sponsorer & samarbeten",
sponsorPitch: "Sponsring, samarbete, foto/video eller evenemang? Starta ett samtal.",
startCollab: "Starta en samarbetschatt",
viewRiderCard: "Visa skejtarkortet",
collabMessage: "Hej {{name}}, jag skulle vilja prata om sponsring eller samarbete.",
followers: "Följare",
following: "Följer",
follow: "Följ",
unfollow: "Sluta följa",
zapsReceived: "zaps mottagna",
memberSince: "Medlem sedan",
badges: "Märken",

View file

@ -1045,6 +1045,16 @@ const thBatch9 = {
nip05Verified: 'ยืนยัน NIP-05 แล้ว',
nip05NotVerified: 'NIP-05 ยังไม่ได้รับการยืนยันกับกุญแจนี้',
message: 'ข้อความ',
eventsHosted: 'กิจกรรมที่จัดขึ้น',
forSponsors: 'สำหรับสปอนเซอร์และความร่วมมือ',
sponsorPitch: 'การสนับสนุน ความร่วมมือ ภาพ/วิดีโอ หรืออีเวนต์? เริ่มคุยกันได้เลย',
startCollab: 'เริ่มแชตความร่วมมือ',
viewRiderCard: 'ดูการ์ดนักสเก็ต',
collabMessage: 'สวัสดี {{name}} ฉันอยากคุยเรื่องการสนับสนุนหรือความร่วมมือ',
followers: 'ผู้ติดตาม',
following: 'กำลังติดตาม',
follow: 'ติดตาม',
unfollow: 'เลิกติดตาม',
zapsReceived: 'Zap ที่ได้รับ',
memberSince: 'สมาชิกตั้งแต่',
badges: 'ป้าย',

View file

@ -936,6 +936,16 @@ const trBatch4 = {
nip05Verified: "NIP-05 doğrulandı",
nip05NotVerified: "NIP-05 bu anahtara karşı doğrulanmadı",
message: "Mesaj",
eventsHosted: "Düzenlenen etkinlikler",
forSponsors: "Sponsorlar ve iş birlikleri için",
sponsorPitch: "Sponsorluk, iş birliği, fotoğraf/video veya etkinlik mi? Hemen konuşmaya başla.",
startCollab: "İş birliği sohbeti başlat",
viewRiderCard: "Rider kartını görüntüle",
collabMessage: "Merhaba {{name}}, bir sponsorluk veya iş birliği hakkında konuşmak istiyorum.",
followers: "Takipçiler",
following: "Takip Edilen",
follow: "Takip Et",
unfollow: "Takibi Bırak",
zapsReceived: "zap alındı",
memberSince: "Üyelik tarihi",
badges: "Rozetler",

View file

@ -936,6 +936,16 @@ const ukBatch4 = {
nip05Verified: "Підтверджено NIP-05",
nip05NotVerified: "NIP-05 не підтверджено для цього ключа",
message: "Повідомлення",
eventsHosted: "Проведені події",
forSponsors: "Для спонсорів і колаборацій",
sponsorPitch: "Спонсорство, колаборація, фото/відео чи подія? Почніть розмову.",
startCollab: "Почати чат про колаборацію",
viewRiderCard: "Переглянути картку райдера",
collabMessage: "Привіт, {{name}}! Хочу обговорити спонсорство або колаборацію.",
followers: "Підписники",
following: "Підписки",
follow: "Підписатися",
unfollow: "Відписатися",
zapsReceived: "отримано zaps",
memberSince: "Учасник з",
badges: "Значки",

View file

@ -944,6 +944,16 @@ const viBatch4 = {
nip05Verified: 'Đã xác minh NIP-05',
nip05NotVerified: 'NIP-05 chưa được xác minh với khóa này',
message: 'Nhắn tin',
eventsHosted: 'Sự kiện đã tổ chức',
forSponsors: 'Dành cho nhà tài trợ & hợp tác',
sponsorPitch: 'Tài trợ, hợp tác, ảnh/video hay sự kiện? Bắt đầu trò chuyện nhé.',
startCollab: 'Bắt đầu trò chuyện hợp tác',
viewRiderCard: 'Xem thẻ người trượt ván',
collabMessage: 'Chào {{name}}, tôi muốn trao đổi về tài trợ hoặc hợp tác.',
followers: 'Người theo dõi',
following: 'Đang theo dõi',
follow: 'Theo dõi',
unfollow: 'Bỏ theo dõi',
zapsReceived: 'zap đã nhận',
memberSince: 'Thành viên từ',
badges: 'Huy hiệu',

View file

@ -941,6 +941,16 @@ const zhCNBatch4 = {
nip05Verified: "NIP-05 已验证",
nip05NotVerified: "NIP-05 与此密钥未通过验证",
message: "私信",
eventsHosted: "主办的活动",
forSponsors: "赞助与合作",
sponsorPitch: "赞助、合作、拍摄照片/视频还是办活动?来聊聊吧。",
startCollab: "发起合作聊天",
viewRiderCard: "查看滑手名片",
collabMessage: "你好 {{name}},我想聊聊赞助或合作的事。",
followers: "粉丝",
following: "关注中",
follow: "关注",
unfollow: "取消关注",
zapsReceived: "收到的 zaps",
memberSince: "加入于",
badges: "徽章",

View file

@ -941,6 +941,16 @@ const zhTWBatch4 = {
nip05Verified: "NIP-05 已驗證",
nip05NotVerified: "NIP-05 與此金鑰未透過驗證",
message: "私信",
eventsHosted: "主辦的活動",
forSponsors: "贊助與合作",
sponsorPitch: "贊助、合作、拍攝照片/影片還是辦活動?來聊聊吧。",
startCollab: "發起合作聊天",
viewRiderCard: "查看滑手名片",
collabMessage: "你好 {{name}},我想聊聊贊助或合作的事。",
followers: "粉絲",
following: "追蹤中",
follow: "關注",
unfollow: "取消關注",
zapsReceived: "收到的 zaps",
memberSince: "加入於",
badges: "徽章",

View file

@ -16,6 +16,7 @@ import { ClinkPayButton } from '@/components/ClinkPayButton';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUserSkateSpots } from '@/hooks/useSkateSpots';
import { useUserEvents } from '@/hooks/useSkateEvents';
import { useUserShops } from '@/hooks/useShops';
import { useUserCrews } from '@/hooks/useCrews';
import { useUserPublicStash } from '@/hooks/useStashShowcase';
@ -28,19 +29,21 @@ import { useCoordinator } from '@/hooks/useCoordinator';
import { coordinatorAddUrl, parseCoordinatorInput, shortNpub, CORDN_RELAY_URL } from '@/lib/coordinator';
import { toast } from '@/hooks/useToast';
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
import { EventCard } from '@/components/EventCard';
import { genUserName } from '@/lib/genUserName';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS, NEWSLETTER_KIND } from '@/lib/types';
import { NewsletterIssueView } from '@/components/NewsletterIssueView';
import NotFound from './NotFound';
import { MapPin, Store, MessageCircle, Globe, Zap, ExternalLink, Copy, Check, ShieldCheck, Instagram, Share2, Users, Crown, Bot, Pencil, Package, Tag, ArrowRightLeft, Bitcoin, Award, MessageSquareLock, PersonStanding, Sparkles, Home, Clock, Server, Plus } from 'lucide-react';
import { MapPin, Store, MessageCircle, Globe, Zap, ExternalLink, Copy, Check, ShieldCheck, ShieldAlert, Loader2, Instagram, Share2, Users, Crown, Bot, Pencil, Package, Tag, ArrowRightLeft, Bitcoin, Award, MessageSquareLock, PersonStanding, Sparkles, Home, Clock, Server, Plus, UserPlus, UserCheck, Handshake } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
import { ShareProfileDialog } from '@/components/ShareProfileDialog';
import { ProfileBadges } from '@/components/ProfileBadges';
import { SKATESPOTS_BADGE, isSkateSpotsNip05, parseBadges } from '@/lib/badges';
import { useRiderStats } from '@/hooks/useRiderStats';
import { useFollowers, useFollowing, useFollowActions, useIsFollowing } from '@/hooks/useFollowing';
import { useTranslation } from '@/contexts/language';
/** Format a "member since" year from a kind-0 event timestamp (seconds). */
@ -122,6 +125,11 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
const { data: shops, isLoading: shopsLoading } = useUserShops(pubkey);
const { memberships: crews, isLoading: crewsLoading } = useUserCrews(pubkey);
const { data: publicStash, isLoading: publicStashLoading } = useUserPublicStash(pubkey);
const { data: events = [], isLoading: eventsLoading } = useUserEvents(pubkey);
const { data: followers = [], isLoading: followersLoading } = useFollowers(pubkey);
const { data: following = [] } = useFollowing(pubkey);
const { follow, unfollow, isFollowing: isFollowPending, isUnfollowing } = useFollowActions();
const isFollowingProfile = useIsFollowing(pubkey);
const { data: skater } = useUserSkaterProfile(pubkey);
const [copied, setCopied] = useState(false);
@ -136,7 +144,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
const lud16 = metadata?.lud16;
const isBot = metadata?.bot === true;
// Soft NIP-05 verification — confirms the handle resolves to THIS pubkey.
const { data: nip05Result } = useCrewNip05(nip05, pubkey);
const { data: nip05Result, isLoading: nip05Loading } = useCrewNip05(nip05, pubkey);
const nip05Verified = nip05Result?.status === 'verified';
// Custom CLINK offer code stored in the kind 0 content JSON.
const raw = metadata as Record<string, unknown> | undefined;
@ -174,6 +182,9 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
!!stanceMeta || !!levelMeta || !!skater.customLevel || skater.styles.length > 0 ||
!!skater.yearsSkating || !!skater.homeSpot || !!skater.location || !!skater.instagram
);
// Only invite sponsor/collab conversations on profiles that actually show
// skate activity — an empty kind-0 shouldn't read as a media kit.
const sponsorReady = hasSkateLayer || crews.length > 0 || (spots?.length ?? 0) > 0 || events.length > 0;
const handleCopyNpub = () => {
navigator.clipboard.writeText(npub);
@ -259,13 +270,23 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
)}
title={nip05Verified ? t('walletUi.profile.nip05Verified') : t('walletUi.profile.nip05NotVerified')}
>
{nip05Verified ? <ShieldCheck className="h-3.5 w-3.5" /> : <Check className="h-3 w-3" />}
{nip05Loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : nip05Verified ? (
<ShieldCheck className="h-3.5 w-3.5" />
) : (
<ShieldAlert className="h-3.5 w-3.5" />
)}
{nip05}
{nip05Verified && (
{nip05Loading ? (
<span className="text-[10px] font-bold uppercase tracking-wide text-gray-500">
{t('auth.verifying')}
</span>
) : nip05Verified ? (
<span className="text-[10px] font-black uppercase tracking-wide bg-[#ccff00]/15 text-[#ccff00] px-1.5 py-0.5 rounded-full ml-0.5">
{t('common.verified')}
</span>
)}
) : null}
</p>
)}
{/* Extra verified handles (separate record) — a skater on several domains. */}
@ -309,7 +330,29 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
{!isOwnProfile && user && (
<Button
variant={isFollowingProfile ? 'outline' : 'default'}
onClick={() => {
if (isFollowingProfile) void unfollow(pubkey);
else void follow(pubkey);
}}
disabled={isFollowPending || isUnfollowing}
className={cn(
'gap-2 font-bold',
isFollowingProfile
? 'border-teal-500/50 text-teal-300 hover:bg-teal-500/10'
: 'bg-[#ccff00] text-black hover:bg-[#ccff00]/90',
)}
aria-label={isFollowingProfile ? t('walletUi.profile.unfollow') : t('walletUi.profile.follow')}
>
{isFollowingProfile ? <UserCheck className="h-4 w-4" /> : <UserPlus className="h-4 w-4" />}
<span className="hidden sm:inline">
{isFollowingProfile ? t('walletUi.profile.unfollow') : t('walletUi.profile.follow')}
</span>
</Button>
)}
{/* Share — available on every profile */}
<Button
variant="outline"
@ -346,11 +389,13 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</div>
{/* ── At-a-glance stats strip ── */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 mb-3">
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2 sm:gap-3 mb-3">
<StatPill value={spots?.length ?? 0} label={t('walletUi.stats.spots')} color="#ff0040" icon={<MapPin className="h-4 w-4" />} loading={spotsLoading} onClick={() => scrollToSection('section-spots')} />
<StatPill value={shops?.length ?? 0} label={t('walletUi.stats.shops')} color="#ccff00" icon={<Store className="h-4 w-4" />} loading={shopsLoading} onClick={() => scrollToSection('section-shops')} />
<StatPill value={crews.length} label={t('walletUi.profile.crews')} color="#2dd4bf" icon={<Users className="h-4 w-4" />} loading={crewsLoading} onClick={() => scrollToSection('section-crews')} />
<StatPill value={events.length} label={t('nav.events')} color="#ccff00" icon={<Clock className="h-4 w-4" />} loading={eventsLoading} onClick={() => scrollToSection('section-events')} />
<StatPill value={publicStash?.length ?? 0} label={t('walletUi.profile.gear')} color="#ff6600" icon={<Package className="h-4 w-4" />} loading={publicStashLoading} onClick={() => scrollToSection('section-stash')} />
<StatPill value={followers.length} label={t('walletUi.profile.followers')} color="#ec4899" icon={<Users className="h-4 w-4" />} loading={followersLoading} />
</div>
{/* Secondary meta line: zaps received + member since */}
@ -368,6 +413,12 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
{t('walletUi.profile.memberSince')} <span className="font-bold text-gray-300">{memberSince}</span>
</span>
)}
{following.length > 0 && (
<span className="flex items-center gap-1.5">
<UserPlus className="h-3.5 w-3.5 text-gray-400" />
<span className="font-black text-white">{following.length}</span> {t('walletUi.profile.following')}
</span>
)}
</div>
)}
@ -622,6 +673,36 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</div>
)}
{/* Events hosted by this skater — sponsor/community signal */}
{(eventsLoading || events.length > 0) && (
<div id="section-events" className="scroll-mt-24">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-black uppercase flex items-center gap-2">
<Clock className="h-5 w-5 text-[#ccff00]" />
{t('walletUi.profile.eventsHosted')}
{events.length > 0 && (
<Badge className="bg-[#ccff00]/20 text-[#ccff00] border-[#ccff00]/50">
{events.length}
</Badge>
)}
</h2>
</div>
{eventsLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-48 rounded-xl" />
))}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{events.slice(0, 4).map((event) => (
<EventCard key={event.id} event={event} compact />
))}
</div>
)}
</div>
)}
{/* Public Stash — gear this skater chose to show publicly */}
{(publicStashLoading || (publicStash && publicStash.length > 0)) && (
<div id="section-stash" className="scroll-mt-24">
@ -657,11 +738,11 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
>
<div className="relative aspect-square bg-gradient-to-br from-[#ff6600]/10 to-[#ff0040]/5 flex items-center justify-center overflow-hidden">
{g.images[0] ? (
<img
<ImageWithFallback
src={g.images[0]}
alt={g.title}
fallback={null}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
) : (
<Package className="h-10 w-10 text-[#ff6600]/40" />
@ -711,6 +792,55 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
<TradeReputationCard pubkey={pubkey} />
<TradeRecommendationsCard pubkey={pubkey} />
{/* Sponsor / collab card — neutral, no assumed opt-in */}
{!isOwnProfile && sponsorReady && (
<Card className="relative overflow-hidden border-2 border-pink-500/30 bg-gradient-to-br from-pink-500/10 via-black to-transparent">
<div className="absolute -top-12 -right-12 h-36 w-36 rounded-full bg-pink-500 opacity-10 blur-3xl pointer-events-none" />
<CardContent className="relative p-4">
<div className="flex items-center gap-2.5">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-pink-500/15 text-pink-300">
<Handshake className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-[10px] font-black uppercase tracking-[0.15em] text-pink-300">
{t('walletUi.profile.forSponsors')}
</p>
<p className="text-xs text-gray-400">{t('walletUi.profile.sponsorPitch')}</p>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
{[
{ label: t('walletUi.profile.followers'), value: followersLoading ? '—' : followers.length },
{ label: t('nav.events'), value: eventsLoading ? '—' : events.length },
{ label: t('walletUi.stats.spots'), value: spotsLoading ? '—' : (spots?.length ?? 0) },
{ label: t('walletUi.profile.zapsReceived'), value: riderStats ? riderStats.zapsReceived : '—' },
].map((stat) => (
<div key={stat.label} className="rounded-lg border border-white/10 bg-black/30 px-2.5 py-2">
<p className="text-lg font-black text-white leading-none">{stat.value}</p>
<p className="mt-0.5 text-[10px] uppercase tracking-wide text-gray-500">{stat.label}</p>
</div>
))}
</div>
<div className="mt-3 grid grid-cols-1 gap-2">
<Button asChild className="w-full justify-center bg-pink-500 font-bold text-black hover:bg-pink-400">
<Link to={`/messages/${npub}?message=${encodeURIComponent(t('walletUi.profile.collabMessage', { name: displayName }))}`}>
<MessageCircle className="mr-2 h-4 w-4" />
{t('walletUi.profile.startCollab')}
</Link>
</Button>
<Button asChild variant="outline" className="w-full justify-center border-white/15">
<Link to={`/card/${npub}`}>
<Award className="mr-2 h-4 w-4" />
{t('walletUi.profile.viewRiderCard')}
</Link>
</Button>
</div>
</CardContent>
</Card>
)}
{/* Quick Info Card */}
<Card className="bg-black/90 border-2 border-gray-800">
<CardHeader className="pb-3">