bare repo
This commit is contained in:
parent
d73f9bc01e
commit
3d356225cd
31 changed files with 134 additions and 3005 deletions
10
src/App.vue
10
src/App.vue
|
|
@ -1,11 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import Navbar from '@/components/layout/Navbar.vue'
|
||||
import Footer from '@/components/layout/Footer.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const showFooter = computed(() => route.path !== '/support')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -23,11 +19,7 @@ const showFooter = computed(() => route.path !== '/support')
|
|||
<router-view />
|
||||
</main>
|
||||
|
||||
<Footer v-if="showFooter" />
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Remove default styles */
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -3,65 +3,105 @@
|
|||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 227 92% 95%;
|
||||
--foreground: 234 16% 35%;
|
||||
/* Light theme */
|
||||
--background: rgb(231, 236, 254);
|
||||
--background: oklch(0.97 0.02 250);
|
||||
--foreground: rgb(75, 78, 104);
|
||||
--foreground: oklch(0.40 0.03 265);
|
||||
|
||||
--card: 225 23% 92%;
|
||||
--card-foreground: 234 16% 35%;
|
||||
--card: rgb(226, 230, 242);
|
||||
--card: oklch(0.92 0.02 260);
|
||||
--card-foreground: rgb(75, 78, 104);
|
||||
--card-foreground: oklch(0.40 0.03 265);
|
||||
|
||||
--popover: 225 23% 92%;
|
||||
--popover-foreground: 234 16% 35%;
|
||||
--popover: rgb(226, 230, 242);
|
||||
--popover: oklch(0.92 0.02 260);
|
||||
--popover-foreground: rgb(75, 78, 104);
|
||||
--popover-foreground: oklch(0.40 0.03 265);
|
||||
|
||||
--primary: 220 91% 54%;
|
||||
--primary-foreground: 227 92% 95%;
|
||||
--primary: rgb(31, 102, 244);
|
||||
--primary: oklch(0.60 0.20 260);
|
||||
--primary-foreground: rgb(231, 236, 254);
|
||||
--primary-foreground: oklch(0.97 0.02 250);
|
||||
|
||||
--secondary: 227 23% 83%;
|
||||
--secondary-foreground: 234 16% 35%;
|
||||
--secondary: rgb(212, 217, 228);
|
||||
--secondary: oklch(0.87 0.03 255);
|
||||
--secondary-foreground: rgb(75, 78, 104);
|
||||
--secondary-foreground: oklch(0.40 0.03 265);
|
||||
|
||||
--muted: 227 23% 83%;
|
||||
--muted-foreground: 231 11% 47%;
|
||||
--muted: rgb(212, 217, 228);
|
||||
--muted: oklch(0.87 0.03 255);
|
||||
--muted-foreground: rgb(115, 120, 141);
|
||||
--muted-foreground: oklch(0.55 0.03 265);
|
||||
|
||||
--accent: 11 83% 67%;
|
||||
--accent-foreground: 227 92% 95%;
|
||||
--accent: rgb(241, 127, 101);
|
||||
--accent: oklch(0.70 0.15 30);
|
||||
--accent-foreground: rgb(231, 236, 254);
|
||||
--accent-foreground: oklch(0.97 0.02 250);
|
||||
|
||||
--destructive: 347 87% 44%;
|
||||
--destructive-foreground: 227 92% 95%;
|
||||
--destructive: rgb(210, 15, 57);
|
||||
--destructive: oklch(0.50 0.28 15);
|
||||
--destructive-foreground: rgb(231, 236, 254);
|
||||
--destructive-foreground: oklch(0.97 0.02 250);
|
||||
|
||||
--border: 228 17% 77%;
|
||||
--input: 228 17% 77%;
|
||||
--ring: 220 91% 54%;
|
||||
--border: rgb(197, 201, 216);
|
||||
--border: oklch(0.83 0.02 265);
|
||||
--input: rgb(197, 201, 216);
|
||||
--input: oklch(0.83 0.02 265);
|
||||
--ring: rgb(31, 102, 244);
|
||||
--ring: oklch(0.60 0.20 260);
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 233 31% 18%;
|
||||
--foreground: 227 68% 88%;
|
||||
/* Dark theme */
|
||||
--background: rgb(26, 32, 54);
|
||||
--background: oklch(0.25 0.05 265);
|
||||
--foreground: rgb(218, 226, 248);
|
||||
--foreground: oklch(0.90 0.03 260);
|
||||
|
||||
--card: 234 32% 15%;
|
||||
--card-foreground: 227 68% 88%;
|
||||
--card: rgb(22, 27, 45);
|
||||
--card: oklch(0.20 0.05 265);
|
||||
--card-foreground: rgb(218, 226, 248);
|
||||
--card-foreground: oklch(0.90 0.03 260);
|
||||
|
||||
--popover: 234 32% 15%;
|
||||
--popover-foreground: 227 68% 88%;
|
||||
--popover: rgb(22, 27, 45);
|
||||
--popover: oklch(0.20 0.05 265);
|
||||
--popover-foreground: rgb(218, 226, 248);
|
||||
--popover-foreground: oklch(0.90 0.03 260);
|
||||
|
||||
--primary: 220 83% 76%;
|
||||
--primary-foreground: 233 31% 18%;
|
||||
--primary: rgb(127, 167, 249);
|
||||
--primary: oklch(0.75 0.15 260);
|
||||
--primary-foreground: rgb(26, 32, 54);
|
||||
--primary-foreground: oklch(0.25 0.05 265);
|
||||
|
||||
--secondary: 233 25% 26%;
|
||||
--secondary-foreground: 227 68% 88%;
|
||||
--secondary: rgb(38, 46, 72);
|
||||
--secondary: oklch(0.30 0.06 265);
|
||||
--secondary-foreground: rgb(218, 226, 248);
|
||||
--secondary-foreground: oklch(0.90 0.03 260);
|
||||
|
||||
--muted: 233 25% 26%;
|
||||
--muted-foreground: 225 27% 72%;
|
||||
--muted: rgb(38, 46, 72);
|
||||
--muted: oklch(0.30 0.06 265);
|
||||
--muted-foreground: rgb(177, 186, 211);
|
||||
--muted-foreground: oklch(0.78 0.06 265);
|
||||
|
||||
--accent: 11 77% 90%;
|
||||
--accent-foreground: 233 31% 18%;
|
||||
--accent: rgb(255, 179, 164);
|
||||
--accent: oklch(0.83 0.12 30);
|
||||
--accent-foreground: rgb(26, 32, 54);
|
||||
--accent-foreground: oklch(0.25 0.05 265);
|
||||
|
||||
--destructive: 351 74% 76%;
|
||||
--destructive-foreground: 233 31% 18%;
|
||||
--destructive: rgb(247, 130, 150);
|
||||
--destructive: oklch(0.75 0.18 15);
|
||||
--destructive-foreground: rgb(26, 32, 54);
|
||||
--destructive-foreground: oklch(0.25 0.05 265);
|
||||
|
||||
--border: 233 25% 26%;
|
||||
--input: 233 25% 26%;
|
||||
--ring: 220 83% 76%;
|
||||
--border: rgb(38, 46, 72);
|
||||
--border: oklch(0.30 0.06 265);
|
||||
--input: rgb(38, 46, 72);
|
||||
--input: oklch(0.30 0.06 265);
|
||||
--ring: rgb(127, 167, 249);
|
||||
--ring: oklch(0.75 0.15 260);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -78,106 +118,106 @@
|
|||
|
||||
@layer utilities {
|
||||
.bg-background {
|
||||
background-color: hsl(var(--background) / var(--tw-bg-opacity, 1));
|
||||
background-color: var(--background);
|
||||
}
|
||||
.bg-foreground {
|
||||
background-color: hsl(var(--foreground) / var(--tw-bg-opacity, 1));
|
||||
background-color: var(--foreground);
|
||||
}
|
||||
.bg-card {
|
||||
background-color: hsl(var(--card) / var(--tw-bg-opacity, 1));
|
||||
background-color: var(--card);
|
||||
}
|
||||
.bg-card-foreground {
|
||||
background-color: hsl(var(--card-foreground) / var(--tw-bg-opacity, 1));
|
||||
background-color: var(--card-foreground);
|
||||
}
|
||||
.bg-popover {
|
||||
background-color: hsl(var(--popover) / var(--tw-bg-opacity, 1));
|
||||
background-color: var(--popover);
|
||||
}
|
||||
.bg-popover-foreground {
|
||||
background-color: hsl(var(--popover-foreground) / var(--tw-bg-opacity, 1));
|
||||
background-color: var(--popover-foreground);
|
||||
}
|
||||
.bg-primary {
|
||||
background-color: hsl(var(--primary) / var(--tw-bg-opacity, 1));
|
||||
background-color: var(--primary);
|
||||
}
|
||||
.bg-primary-foreground {
|
||||
background-color: hsl(var(--primary-foreground) / var(--tw-bg-opacity, 1));
|
||||
background-color: var(--primary-foreground);
|
||||
}
|
||||
.bg-secondary {
|
||||
background-color: hsl(var(--secondary) / var(--tw-bg-opacity, 1));
|
||||
background-color: var(--secondary);
|
||||
}
|
||||
.bg-secondary-foreground {
|
||||
background-color: hsl(var(--secondary-foreground) / var(--tw-bg-opacity, 1));
|
||||
background-color: var(--secondary-foreground);
|
||||
}
|
||||
.bg-muted {
|
||||
background-color: hsl(var(--muted) / var(--tw-bg-opacity, 1));
|
||||
background-color: var(--muted);
|
||||
}
|
||||
.bg-muted-foreground {
|
||||
background-color: hsl(var(--muted-foreground) / var(--tw-bg-opacity, 1));
|
||||
background-color: var(--muted-foreground);
|
||||
}
|
||||
.bg-accent {
|
||||
background-color: hsl(var(--accent) / var(--tw-bg-opacity, 1));
|
||||
background-color: var(--accent);
|
||||
}
|
||||
.bg-accent-foreground {
|
||||
background-color: hsl(var(--accent-foreground) / var(--tw-bg-opacity, 1));
|
||||
background-color: var(--accent-foreground);
|
||||
}
|
||||
.bg-destructive {
|
||||
background-color: hsl(var(--destructive) / var(--tw-bg-opacity, 1));
|
||||
background-color: var(--destructive);
|
||||
}
|
||||
.bg-destructive-foreground {
|
||||
background-color: hsl(var(--destructive-foreground) / var(--tw-bg-opacity, 1));
|
||||
background-color: var(--destructive-foreground);
|
||||
}
|
||||
|
||||
.text-background {
|
||||
color: hsl(var(--background) / var(--tw-text-opacity, 1));
|
||||
color: var(--background);
|
||||
}
|
||||
.text-foreground {
|
||||
color: hsl(var(--foreground) / var(--tw-text-opacity, 1));
|
||||
color: var(--foreground);
|
||||
}
|
||||
.text-card {
|
||||
color: hsl(var(--card) / var(--tw-text-opacity, 1));
|
||||
color: var(--card);
|
||||
}
|
||||
.text-card-foreground {
|
||||
color: hsl(var(--card-foreground) / var(--tw-text-opacity, 1));
|
||||
color: var(--card-foreground);
|
||||
}
|
||||
.text-popover {
|
||||
color: hsl(var(--popover) / var(--tw-text-opacity, 1));
|
||||
color: var(--popover);
|
||||
}
|
||||
.text-popover-foreground {
|
||||
color: hsl(var(--popover-foreground) / var(--tw-text-opacity, 1));
|
||||
color: var(--popover-foreground);
|
||||
}
|
||||
.text-primary {
|
||||
color: hsl(var(--primary) / var(--tw-text-opacity, 1));
|
||||
color: var(--primary);
|
||||
}
|
||||
.text-primary-foreground {
|
||||
color: hsl(var(--primary-foreground) / var(--tw-text-opacity, 1));
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
.text-secondary {
|
||||
color: hsl(var(--secondary) / var(--tw-text-opacity, 1));
|
||||
color: var(--secondary);
|
||||
}
|
||||
.text-secondary-foreground {
|
||||
color: hsl(var(--secondary-foreground) / var(--tw-text-opacity, 1));
|
||||
color: var(--secondary-foreground);
|
||||
}
|
||||
.text-muted {
|
||||
color: hsl(var(--muted) / var(--tw-text-opacity, 1));
|
||||
color: var(--muted);
|
||||
}
|
||||
.text-muted-foreground {
|
||||
color: hsl(var(--muted-foreground) / var(--tw-text-opacity, 1));
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.text-accent {
|
||||
color: hsl(var(--accent) / var(--tw-text-opacity, 1));
|
||||
color: var(--accent);
|
||||
}
|
||||
.text-accent-foreground {
|
||||
color: hsl(var(--accent-foreground) / var(--tw-text-opacity, 1));
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
.text-destructive {
|
||||
color: hsl(var(--destructive) / var(--tw-text-opacity, 1));
|
||||
color: var(--destructive);
|
||||
}
|
||||
.text-destructive-foreground {
|
||||
color: hsl(var(--destructive-foreground) / var(--tw-text-opacity, 1));
|
||||
color: var(--destructive-foreground);
|
||||
}
|
||||
|
||||
@supports not (backdrop-filter: blur(1px)) {
|
||||
.select-content {
|
||||
background-color: hsl(var(--background));
|
||||
background-color: var(--background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -185,25 +225,25 @@
|
|||
/* Add support for ring colors */
|
||||
@layer utilities {
|
||||
.ring-border {
|
||||
--tw-ring-color: hsl(var(--border) / var(--tw-ring-opacity, 1));
|
||||
--tw-ring-color: var(--border);
|
||||
}
|
||||
.ring-primary {
|
||||
--tw-ring-color: hsl(var(--primary) / var(--tw-ring-opacity, 1));
|
||||
--tw-ring-color: var(--primary);
|
||||
}
|
||||
.ring-background {
|
||||
--tw-ring-color: hsl(var(--background) / var(--tw-ring-opacity, 1));
|
||||
--tw-ring-color: var(--background);
|
||||
}
|
||||
}
|
||||
|
||||
/* Add support for border colors */
|
||||
@layer utilities {
|
||||
.box-border {
|
||||
border-color: hsl(var(--border) / var(--tw-border-opacity, 1));
|
||||
border-color: var(--border);
|
||||
}
|
||||
.border-primary {
|
||||
border-color: hsl(var(--primary) / var(--tw-border-opacity, 1));
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.border-background {
|
||||
border-color: hsl(var(--background) / var(--tw-border-opacity, 1));
|
||||
border-color: var(--background);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,397 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted, watch, onUnmounted } from 'vue'
|
||||
import { useNostrStore } from '@/stores/nostr'
|
||||
import { npubToHex } from '@/lib/nostr'
|
||||
import type { DirectMessage } from '@/types/nostr'
|
||||
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Send } from 'lucide-vue-next'
|
||||
import MessageBubble from '@/components/ui/message-bubble/MessageBubble.vue'
|
||||
import ConnectionStatus from '@/components/ConnectionStatus.vue'
|
||||
|
||||
const nostrStore = useNostrStore()
|
||||
const input = ref('')
|
||||
const isSending = ref(false)
|
||||
const error = ref('')
|
||||
const messagesEndRef = ref<HTMLDivElement | null>(null)
|
||||
const isSubscribing = ref(false)
|
||||
const SUPPORT_NPUB = import.meta.env.VITE_SUPPORT_NPUB
|
||||
|
||||
if (!SUPPORT_NPUB) {
|
||||
error.value = 'Support public key not configured'
|
||||
}
|
||||
|
||||
const inputLength = computed(() => input.value.trim().length)
|
||||
|
||||
// Group messages by sender and time
|
||||
interface MessageGroup {
|
||||
sent: boolean
|
||||
messages: DirectMessage[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const groupedMessages = computed<MessageGroup[]>(() => {
|
||||
const groups: MessageGroup[] = []
|
||||
let currentGroup: MessageGroup | null = null
|
||||
|
||||
// Sort messages by timestamp first
|
||||
const sortedMessages = [...nostrStore.currentMessages].sort((a, b) => a.created_at - b.created_at)
|
||||
|
||||
for (const message of sortedMessages) {
|
||||
// Start a new group if:
|
||||
// 1. No current group
|
||||
// 2. Different sender than last message
|
||||
// 3. More than 2 minutes since last message
|
||||
if (!currentGroup ||
|
||||
currentGroup.sent !== message.sent ||
|
||||
message.created_at - currentGroup.messages[currentGroup.messages.length - 1].created_at > 120) {
|
||||
currentGroup = {
|
||||
sent: message.sent,
|
||||
messages: [],
|
||||
timestamp: message.created_at
|
||||
}
|
||||
groups.push(currentGroup)
|
||||
}
|
||||
currentGroup.messages.push(message)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
// Scroll to bottom when new messages arrive
|
||||
watch(() => nostrStore.currentMessages.length, () => {
|
||||
nextTick(() => {
|
||||
scrollToBottom()
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (!SUPPORT_NPUB) return
|
||||
|
||||
const supportPubkeyHex = npubToHex(SUPPORT_NPUB)
|
||||
nostrStore.activeChat = supportPubkeyHex
|
||||
|
||||
// Only subscribe if not already subscribed
|
||||
if (!nostrStore.hasActiveSubscription) {
|
||||
isSubscribing.value = true
|
||||
nostrStore.subscribeToMessages()
|
||||
.catch(err => {
|
||||
console.debug('Support chat subscription error:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
isSubscribing.value = false
|
||||
})
|
||||
}
|
||||
|
||||
scrollToBottom()
|
||||
} catch (err) {
|
||||
console.debug('Support chat setup error:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// Remove the unsubscribe on unmount since we want to keep the connection
|
||||
onUnmounted(() => {
|
||||
// Only clear active chat
|
||||
nostrStore.activeChat = null
|
||||
})
|
||||
|
||||
// Watch for changes in activeChat
|
||||
watch(() => nostrStore.activeChat, async (newChat) => {
|
||||
if (newChat) {
|
||||
try {
|
||||
isSubscribing.value = true
|
||||
await nostrStore.subscribeToMessages()
|
||||
} catch (err) {
|
||||
console.debug('Chat subscription error:', err)
|
||||
// Continue anyway
|
||||
} finally {
|
||||
isSubscribing.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function scrollToBottom() {
|
||||
if (messagesEndRef.value) {
|
||||
// Get the scroll area element
|
||||
const scrollArea = messagesEndRef.value.closest('.scrollarea-viewport')
|
||||
if (scrollArea) {
|
||||
scrollArea.scrollTop = scrollArea.scrollHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
if (inputLength.value === 0 || !nostrStore.activeChat || isSending.value) return
|
||||
|
||||
try {
|
||||
isSending.value = true
|
||||
await nostrStore.sendMessage(nostrStore.activeChat, input.value)
|
||||
input.value = ''
|
||||
error.value = ''
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err)
|
||||
error.value = 'Failed to send message. Please try again.'
|
||||
} finally {
|
||||
isSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000)
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return 'Today'
|
||||
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||
return 'Yesterday'
|
||||
}
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
const getMessageGroupClasses = (sent: boolean) => {
|
||||
return [
|
||||
'group flex flex-col gap-0.5 animate-in slide-in-from-bottom-2',
|
||||
sent ? 'items-end' : 'items-start'
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card
|
||||
class="flex flex-col h-full bg-gradient-to-b from-card to-background border-border shadow-2xl overflow-hidden relative z-0">
|
||||
<CardHeader
|
||||
class="flex-shrink-0 flex flex-row items-center justify-between px-6 py-4 border-b border-border/50 bg-background/95 backdrop-blur-md relative z-50">
|
||||
<!-- Left side with avatar and name -->
|
||||
<div class="flex items-center gap-5 flex-shrink-0">
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute -inset-0.5 bg-gradient-to-r from-primary to-primary/50 rounded-full opacity-75 group-hover:opacity-100 blur transition duration-200">
|
||||
</div>
|
||||
<Avatar
|
||||
class="relative h-11 w-11 bg-muted ring-2 ring-ring ring-offset-2 ring-offset-background shadow-md transition-all duration-200 hover:shadow-lg group-hover:scale-105">
|
||||
<AvatarFallback class="text-base font-semibold text-foreground">SA</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div class="hidden sm:block">
|
||||
<p class="font-semibold leading-none text-foreground tracking-tight">Support Agent</p>
|
||||
<div class="flex items-center gap-1.5 mt-1.5">
|
||||
<ConnectionStatus />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center badge -->
|
||||
<div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<div
|
||||
class="text-xs font-medium text-foreground bg-gradient-to-r from-muted/80 to-muted/50 backdrop-blur-sm px-4 py-2 rounded-full whitespace-nowrap shadow-lg border border-border/50">
|
||||
Customer Support
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side spacer to maintain layout balance -->
|
||||
<div class="flex items-center gap-5 flex-shrink-0 invisible">
|
||||
<div class="h-11 w-11"></div>
|
||||
<div class="hidden sm:block">
|
||||
<div class="h-5"></div>
|
||||
<div class="h-5 mt-1.5"></div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="flex-1 min-h-0 p-0 bg-gradient-to-b from-card to-background overflow-hidden">
|
||||
<ScrollArea class="h-full" type="hover">
|
||||
<div class="flex flex-col gap-4 px-6 py-4">
|
||||
<template v-for="(group, groupIndex) in groupedMessages" :key="groupIndex">
|
||||
<!-- Date separator -->
|
||||
<div v-if="groupIndex === 0 ||
|
||||
formatDate(group.timestamp) !== formatDate(groupedMessages[groupIndex - 1].timestamp)"
|
||||
class="flex justify-center my-8">
|
||||
<div
|
||||
class="px-4 py-1.5 rounded-full bg-gradient-to-r from-muted/30 to-muted/30 text-xs font-medium text-muted-foreground shadow-lg backdrop-blur-sm border border-border/20">
|
||||
{{ formatDate(group.timestamp) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message group -->
|
||||
<div :class="getMessageGroupClasses(group.sent)" class="w-full">
|
||||
<div class="flex flex-col gap-[3px] w-full" :class="{ 'items-end': group.sent }">
|
||||
<MessageBubble v-for="(message, messageIndex) in group.messages" :key="message.id" :sent="group.sent"
|
||||
:is-first="messageIndex === 0" :is-last="messageIndex === group.messages.length - 1"
|
||||
:content="message.content" :timestamp="message.created_at"
|
||||
:show-timestamp="messageIndex === group.messages.length - 1" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="messagesEndRef" class="h-px" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter class="flex-shrink-0 border-t border-border/50 bg-background/95 backdrop-blur-md p-4 shadow-xl">
|
||||
<form @submit="sendMessage" class="flex w-full items-center gap-4">
|
||||
<Input id="message" v-model="input" placeholder="Type your message..."
|
||||
class="flex-1 bg-card/90 border-border text-foreground placeholder:text-muted-foreground focus:ring-2 focus:ring-ring focus:border-ring transition-all duration-300 shadow-lg hover:border-border/60 rounded-xl h-11"
|
||||
autocomplete="off" />
|
||||
<Button type="submit" size="icon" :disabled="inputLength === 0 || isSending"
|
||||
class="bg-gradient-to-r from-primary to-primary/80 text-primary-foreground hover:brightness-110 active:brightness-90 transition-all duration-300 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:hover:shadow-lg h-11 w-11 rounded-xl flex-shrink-0">
|
||||
<Send v-if="!isSending" class="h-4 w-4" />
|
||||
<div v-else
|
||||
class="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-r-transparent" />
|
||||
<span class="sr-only">{{ isSending ? 'Sending...' : 'Send' }}</span>
|
||||
</Button>
|
||||
</form>
|
||||
</CardFooter>
|
||||
|
||||
<!-- Add loading indicator if needed -->
|
||||
<div v-if="isSubscribing" class="absolute top-4 right-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div class="h-2 w-2 rounded-full bg-primary animate-pulse"></div>
|
||||
Connecting...
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.animate-in {
|
||||
animation: animate-in 0.3s cubic-bezier(0.21, 1.02, 0.73, 1);
|
||||
}
|
||||
|
||||
@keyframes animate-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.98);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.break-words {
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.select-none {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.select-text {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
/* Enhanced scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #45475a;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #585b70;
|
||||
}
|
||||
|
||||
:deep(.scrollarea-viewport) {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Remove any scroll-behavior from parent elements */
|
||||
.scrollarea-viewport {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
/* Ensure proper containment */
|
||||
.card {
|
||||
position: relative;
|
||||
contain: content;
|
||||
}
|
||||
|
||||
/* Improved focus styles */
|
||||
:focus-visible {
|
||||
outline: 2px solid #cba6f7;
|
||||
outline-offset: 2px;
|
||||
transition: outline-offset 0.2s ease;
|
||||
}
|
||||
|
||||
/* Enhanced button hover states */
|
||||
button:not(:disabled):hover,
|
||||
a:hover {
|
||||
transform: translateY(-1px) scale(1.02);
|
||||
}
|
||||
|
||||
button:not(:disabled):active,
|
||||
a:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-property: background-color, border-color, color, fill, stroke, opacity, box-shadow, transform, filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
|
||||
/* Glass morphism effects */
|
||||
.backdrop-blur-md {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* Gradient animations */
|
||||
@keyframes gradient-shift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-gradient-animate {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-shift 15s ease infinite;
|
||||
}
|
||||
|
||||
/* Add these new styles */
|
||||
.translate-center {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* Update flex layout styles */
|
||||
.chat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 2rem);
|
||||
max-height: calc(100vh - 2rem);
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { type DirectoryItem } from '@/types/directory'
|
||||
import {
|
||||
MapPin,
|
||||
Phone,
|
||||
ExternalLink,
|
||||
Zap,
|
||||
MessageCircle,
|
||||
Facebook,
|
||||
Instagram,
|
||||
Twitter,
|
||||
Youtube,
|
||||
UtensilsCrossed,
|
||||
Bed,
|
||||
ShoppingBag,
|
||||
Car,
|
||||
Ship,
|
||||
HelpCircle,
|
||||
Sparkles,
|
||||
} from 'lucide-vue-next'
|
||||
import TukTuk from '@/components/icons/TukTuk.vue'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card'
|
||||
|
||||
defineProps<{
|
||||
item: DirectoryItem
|
||||
}>()
|
||||
|
||||
|
||||
const categoryIcons = {
|
||||
restaurant: UtensilsCrossed,
|
||||
lodging: Bed,
|
||||
goods: ShoppingBag,
|
||||
services: Sparkles,
|
||||
taxi: Car,
|
||||
tuktuk: TukTuk,
|
||||
lancha: Ship,
|
||||
other: HelpCircle,
|
||||
} as const
|
||||
|
||||
const categoryColors = {
|
||||
restaurant: 'text-orange-500',
|
||||
lodging: 'text-purple-500',
|
||||
goods: 'text-green-500',
|
||||
services: 'text-pink-500',
|
||||
taxi: 'text-yellow-500',
|
||||
tuktuk: 'text-amber-500',
|
||||
lancha: 'text-blue-500',
|
||||
other: 'text-gray-500',
|
||||
} as const
|
||||
|
||||
const socials = {
|
||||
facebook: Facebook,
|
||||
instagram: Instagram,
|
||||
twitter: Twitter,
|
||||
youtube: Youtube,
|
||||
}
|
||||
|
||||
const socialColors = {
|
||||
facebook: 'text-blue-600 hover:text-blue-700',
|
||||
instagram: 'text-pink-600 hover:text-pink-700',
|
||||
twitter: 'text-blue-400 hover:text-blue-500',
|
||||
youtube: 'text-red-600 hover:text-red-700',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="hover:shadow-md transition-shadow relative overflow-hidden group cursor-pointer"
|
||||
@click="$router.push(`/directory/${item.id}`)">
|
||||
<!-- Local watermark -->
|
||||
<!-- <div v-if="item.local" class="absolute right-3 bottom-2 text-2xl tracking-widest font-bold text-primary opacity-30"> -->
|
||||
<!-- LOCAL -->
|
||||
<!-- </div> -->
|
||||
|
||||
<CardContent class="p-6 space-y-4 relative z-20">
|
||||
<!-- Image -->
|
||||
<div v-if="item.imageUrl" class="aspect-video w-full overflow-hidden rounded-md">
|
||||
<img :src="item.imageUrl" :alt="item.name" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<component :is="categoryIcons[item.category]" class="h-6 w-6" :class="categoryColors[item.category]"
|
||||
:title="item.category" />
|
||||
<CardTitle>{{ item.name }}</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardDescription>
|
||||
{{ item.description }}
|
||||
</CardDescription>
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div class="space-y-2">
|
||||
<div v-if="item.town || item.address" class="flex items-center text-sm group">
|
||||
<MapPin class="mr-2 h-4 w-4 text-red-400" />
|
||||
<a v-if="item.mapsUrl" :href="item.mapsUrl" target="_blank" rel="noopener noreferrer"
|
||||
class="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors relative"
|
||||
@click.stop>
|
||||
<span>{{ [item.address, item.town].filter(Boolean).join(', ') }}</span>
|
||||
<ExternalLink class="h-3 w-3 ml-1" />
|
||||
</a>
|
||||
<span v-else>{{ [item.address, item.town].filter(Boolean).join(', ') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="item.contact" class="flex items-center text-sm gap-2">
|
||||
<Phone class="h-4 w-4" />
|
||||
<span>{{ item.contact }}</span>
|
||||
<div v-if="item.contactType" class="flex gap-1">
|
||||
<a v-if="item.contactType.includes('whatsapp')" :href="`https://wa.me/${item.contact.replace(/\D/g, '')}`"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
class="bg-green-100 text-green-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 hover:bg-green-200 transition-colors relative"
|
||||
@click.stop>
|
||||
<MessageCircle class="h-3 w-3" />
|
||||
WhatsApp
|
||||
</a>
|
||||
<a v-if="item.contactType.includes('telegram')" :href="`https://t.me/${item.contact.replace(/\D/g, '')}`"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
class="bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 hover:bg-blue-200 transition-colors relative"
|
||||
@click.stop>
|
||||
<MessageCircle class="h-3 w-3" />
|
||||
Telegram
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="item.lightning" class="flex items-center text-sm">
|
||||
<Zap class="mr-2 h-4 w-4 text-amber-500" />
|
||||
<a :href="`lightning:${item.lightning}`"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors" @click.stop>
|
||||
{{ item.lightning }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Website -->
|
||||
<div v-if="item.url" class="flex items-center text-sm">
|
||||
<ExternalLink class="mr-2 h-4 w-4" />
|
||||
<a :href="item.url" target="_blank" rel="noopener noreferrer"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors" @click.stop>
|
||||
{{ item.url.replace(/^https?:\/\//, '').split('/')[0] }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Social Media Links -->
|
||||
<div v-if="item.social" class="flex items-center gap-2 text-sm">
|
||||
<template v-for="(url, platform) in item.social" :key="platform">
|
||||
<a v-if="url" :href="url" target="_blank" rel="noopener noreferrer" :class="[
|
||||
'transition-colors relative',
|
||||
socialColors[platform as keyof typeof socialColors]
|
||||
]" :title="platform.charAt(0).toUpperCase() + platform.slice(1)" @click.stop>
|
||||
<component :is="socials[platform as keyof typeof socials]" class="h-4 w-4" />
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Search } from 'lucide-vue-next'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
UtensilsCrossed,
|
||||
Bed,
|
||||
ShoppingBag,
|
||||
Sparkles,
|
||||
Car,
|
||||
Ship,
|
||||
HelpCircle,
|
||||
} from 'lucide-vue-next'
|
||||
import TukTuk from '@/components/icons/TukTuk.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
category: string
|
||||
search: string
|
||||
town: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:category': [value: string]
|
||||
'update:search': [value: string]
|
||||
'update:town': [value: string]
|
||||
}>()
|
||||
|
||||
const searchValue = ref('')
|
||||
|
||||
// Watch for changes in the search value
|
||||
watch(searchValue, (newValue) => {
|
||||
emit('update:search', newValue)
|
||||
})
|
||||
|
||||
// Watch for prop changes to update local value
|
||||
watch(() => props.search, (newValue) => {
|
||||
searchValue.value = newValue
|
||||
}, { immediate: true })
|
||||
|
||||
const categoryIcons = {
|
||||
tuktuk: TukTuk,
|
||||
restaurant: UtensilsCrossed,
|
||||
lodging: Bed,
|
||||
goods: ShoppingBag,
|
||||
services: Sparkles,
|
||||
taxi: Car,
|
||||
lancha: Ship,
|
||||
other: HelpCircle,
|
||||
} as const
|
||||
|
||||
const categoryColors = {
|
||||
tuktuk: 'text-amber-500',
|
||||
restaurant: 'text-orange-500',
|
||||
lodging: 'text-purple-500',
|
||||
goods: 'text-green-500',
|
||||
services: 'text-pink-500',
|
||||
lancha: 'text-blue-500',
|
||||
taxi: 'text-yellow-500',
|
||||
other: 'text-gray-500',
|
||||
} as const
|
||||
|
||||
const categories = computed(() => [
|
||||
{ id: 'all', label: t('directory.categories.all') },
|
||||
{ id: 'restaurant', label: t('directory.categories.restaurant'), icon: categoryIcons.restaurant },
|
||||
{ id: 'tuktuk', label: t('directory.categories.tuktuk'), icon: categoryIcons.tuktuk },
|
||||
{ id: 'lodging', label: t('directory.categories.lodging'), icon: categoryIcons.lodging },
|
||||
{ id: 'goods', label: t('directory.categories.goods'), icon: categoryIcons.goods },
|
||||
{ id: 'services', label: t('directory.categories.services'), icon: categoryIcons.services },
|
||||
{ id: 'lancha', label: t('directory.categories.lancha'), icon: categoryIcons.lancha },
|
||||
{ id: 'taxi', label: t('directory.categories.taxi'), icon: categoryIcons.taxi },
|
||||
{ id: 'other', label: t('directory.categories.other'), icon: categoryIcons.other },
|
||||
])
|
||||
|
||||
const towns = computed(() => [
|
||||
{ id: 'all', label: t('directory.towns.all') },
|
||||
{ id: 'San Marcos', label: 'San Marcos' },
|
||||
{ id: 'San Pedro', label: 'San Pedro' },
|
||||
{ id: 'Tzununa', label: 'Tzununa' },
|
||||
{ id: 'Jaibalito', label: 'Jaibalito' },
|
||||
{ id: 'San Pablo', label: 'San Pablo' },
|
||||
{ id: 'Panajachel', label: 'Panajachel' },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full space-y-3">
|
||||
<!-- Search Input -->
|
||||
<div class="relative w-full max-w-xl mx-auto">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search class="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<Input v-model="searchValue" type="text" class="pl-10 w-full" :placeholder="t('directory.search')"
|
||||
inputmode="text" enterkeyhint="search" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Town Filter -->
|
||||
<div class="flex justify-start md:justify-center gap-1 overflow-x-auto pb-2 px-2 md:px-0
|
||||
[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<Button v-for="to in towns" :key="to.id" @click="emit('update:town', to.id)"
|
||||
:variant="props.town === to.id ? 'default' : 'secondary'" size="sm"
|
||||
class="rounded-full whitespace-nowrap">
|
||||
{{ to.label }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Category Filter -->
|
||||
<div class="flex justify-start md:justify-center gap-1 overflow-x-auto pb-2 px-2 md:px-0
|
||||
[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<Button v-for="cat in categories" :key="cat.id" @click="emit('update:category', cat.id)"
|
||||
:variant="props.category === cat.id ? 'default' : 'secondary'" size="sm"
|
||||
class="rounded-full whitespace-nowrap">
|
||||
<!-- Show only icon on mobile for non-'all' categories -->
|
||||
<template v-if="cat.id !== 'all'">
|
||||
<component :is="cat.icon" class="h-4 w-4 md:mr-2" :class="[
|
||||
props.category === cat.id ? '' : categoryColors[cat.id as keyof typeof categoryColors],
|
||||
'md:hidden'
|
||||
]" />
|
||||
<component :is="cat.icon" class="h-4 w-4 mr-2 hidden md:inline-block"
|
||||
:class="props.category === cat.id ? '' : categoryColors[cat.id as keyof typeof categoryColors]" />
|
||||
<span class="hidden md:inline">{{ cat.label }}</span>
|
||||
</template>
|
||||
<!-- Always show text for 'all' category -->
|
||||
<template v-else>
|
||||
{{ cat.label }}
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import Fuse from 'fuse.js'
|
||||
import DirectoryCard from './DirectoryCard.vue'
|
||||
import { type DirectoryItem } from '@/types/directory'
|
||||
|
||||
const selectedCategory = ref<string>('all')
|
||||
const selectedTown = ref<string>('all')
|
||||
const searchQuery = ref('')
|
||||
|
||||
const props = defineProps<{
|
||||
items: DirectoryItem[]
|
||||
}>()
|
||||
|
||||
// Configure Fuse.js options
|
||||
const fuseOptions = {
|
||||
keys: [
|
||||
'name',
|
||||
'description',
|
||||
'town',
|
||||
'address',
|
||||
'contact',
|
||||
'lightning'
|
||||
],
|
||||
threshold: 0.3, // Lower threshold means more strict matching
|
||||
ignoreLocation: true,
|
||||
shouldSort: true
|
||||
}
|
||||
|
||||
const fuse = new Fuse(props.items, fuseOptions)
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
let results = props.items
|
||||
|
||||
// Apply search if query exists
|
||||
if (searchQuery.value) {
|
||||
results = fuse.search(searchQuery.value).map((result: { item: any }) => result.item)
|
||||
}
|
||||
|
||||
// Apply category filter
|
||||
if (selectedCategory.value !== 'all') {
|
||||
results = results.filter(item => item.category === selectedCategory.value)
|
||||
}
|
||||
|
||||
// Apply town filter
|
||||
if (selectedTown.value !== 'all') {
|
||||
results = results.filter(item => item.town === selectedTown.value)
|
||||
}
|
||||
|
||||
return results
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-4">
|
||||
<DirectoryCard v-for="item in filteredItems" :key="item.id" :item="item" />
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredItems.length === 0" class="text-center py-12">
|
||||
<p class="text-lg text-muted-foreground">
|
||||
No results found. Try adjusting your filters.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { type DirectoryItem } from '@/types/directory'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
MapPin,
|
||||
Phone,
|
||||
ExternalLink,
|
||||
Zap,
|
||||
MessageCircle,
|
||||
Facebook,
|
||||
Instagram,
|
||||
Twitter,
|
||||
Youtube,
|
||||
UtensilsCrossed,
|
||||
Bed,
|
||||
ShoppingBag,
|
||||
Car,
|
||||
Ship,
|
||||
HelpCircle,
|
||||
Sparkles,
|
||||
} from 'lucide-vue-next'
|
||||
import TukTuk from '@/components/icons/TukTuk.vue'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineProps<{
|
||||
item: DirectoryItem
|
||||
}>()
|
||||
|
||||
const socials = {
|
||||
facebook: Facebook,
|
||||
instagram: Instagram,
|
||||
twitter: Twitter,
|
||||
youtube: Youtube,
|
||||
}
|
||||
|
||||
const categoryIcons = {
|
||||
restaurant: UtensilsCrossed,
|
||||
lodging: Bed,
|
||||
goods: ShoppingBag,
|
||||
services: Sparkles,
|
||||
taxi: Car,
|
||||
tuktuk: TukTuk,
|
||||
lancha: Ship,
|
||||
other: HelpCircle,
|
||||
} as const
|
||||
|
||||
const categoryColors = {
|
||||
restaurant: 'text-orange-500',
|
||||
lodging: 'text-purple-500',
|
||||
goods: 'text-green-500',
|
||||
services: 'text-pink-500',
|
||||
taxi: 'text-yellow-500',
|
||||
tuktuk: 'text-amber-500',
|
||||
lancha: 'text-blue-500',
|
||||
other: 'text-gray-500',
|
||||
} as const
|
||||
|
||||
const socialColors = {
|
||||
facebook: 'text-blue-600 hover:text-blue-700',
|
||||
instagram: 'text-pink-600 hover:text-pink-700',
|
||||
twitter: 'text-blue-400 hover:text-blue-500',
|
||||
youtube: 'text-red-600 hover:text-red-700',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="relative overflow-hidden">
|
||||
<!-- Local badge -->
|
||||
<!-- <div v-if="item.local" -->
|
||||
<!-- class="absolute right-6 top-6 rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary"> -->
|
||||
<!-- LOCAL -->
|
||||
<!-- </div> -->
|
||||
|
||||
<!-- Image Banner -->
|
||||
<div v-if="item.imageUrl" class="w-full h-64 overflow-hidden">
|
||||
<img :src="item.imageUrl" :alt="item.name" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
|
||||
<CardHeader>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<component :is="categoryIcons[item.category]" class="h-8 w-8" :class="categoryColors[item.category]"
|
||||
:title="t(`directory.categories.${item.category}`)" />
|
||||
<CardTitle class="text-3xl">{{ item.name }}</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription v-if="item.description" class="text-base mt-4">
|
||||
{{ item.description }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<!-- Left Column -->
|
||||
<div class="space-y-4">
|
||||
<!-- Location -->
|
||||
<div v-if="item.town || item.address" class="flex items-start gap-3 text-base">
|
||||
<MapPin class="h-5 w-5 text-red-500 mt-0.5" />
|
||||
<div class="space-y-1">
|
||||
<div>{{ [item.address, item.town].filter(Boolean).join(', ') }}</div>
|
||||
<a v-if="item.mapsUrl" :href="item.mapsUrl" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
{{ t('directory.viewMap') }}
|
||||
<ExternalLink class="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phone -->
|
||||
<div v-if="item.contact" class="flex items-start gap-3 text-base">
|
||||
<Phone class="h-5 w-5 mt-0.5" />
|
||||
<div class="space-y-2">
|
||||
<div>{{ item.contact }}</div>
|
||||
<div v-if="item.contactType" class="flex gap-2">
|
||||
<a v-if="item.contactType.includes('whatsapp')"
|
||||
:href="`https://wa.me/${item.contact.replace(/\D/g, '')}`" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-sm font-medium text-green-800 hover:bg-green-200 transition-colors">
|
||||
<MessageCircle class="h-4 w-4" />
|
||||
WhatsApp
|
||||
</a>
|
||||
<a v-if="item.contactType.includes('telegram')"
|
||||
:href="`https://t.me/${item.contact.replace(/\D/g, '')}`" target="_blank" rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800 hover:bg-blue-200 transition-colors">
|
||||
<MessageCircle class="h-4 w-4" />
|
||||
Telegram
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightning Address -->
|
||||
<div v-if="item.lightning" class="flex items-center gap-3 text-base">
|
||||
<Zap class="h-5 w-5 text-amber-500" />
|
||||
<a :href="`lightning:${item.lightning}`"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors">
|
||||
{{ item.lightning }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-4">
|
||||
<!-- Website -->
|
||||
<div v-if="item.url" class="flex items-center gap-3 text-base">
|
||||
<ExternalLink class="h-5 w-5" />
|
||||
<a :href="item.url" target="_blank" rel="noopener noreferrer"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors">
|
||||
{{ item.url.replace(/^https?:\/\//, '') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Social Media -->
|
||||
<div v-if="item.social" class="space-y-2">
|
||||
<div v-for="(url, platform) in item.social" :key="platform" class="flex items-center gap-3">
|
||||
<component :is="socials[platform as keyof typeof socials]" class="h-5 w-5"
|
||||
:class="socialColors[platform as keyof typeof socialColors]" />
|
||||
<a :href="url" target="_blank" rel="noopener noreferrer"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors">
|
||||
{{ platform.charAt(0).toUpperCase() + platform.slice(1) }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<template>
|
||||
<svg
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<path fill="currentColor" d="M55.467,315.733C24.892,315.733,0,340.617,0,371.2c0,30.583,24.892,55.467,55.467,55.467s55.467-24.883,55.467-55.467
|
||||
C110.933,340.617,86.05,315.733,55.467,315.733z M55.467,409.6c-21.171,0-38.4-17.229-38.4-38.4s17.229-38.4,38.4-38.4
|
||||
s38.4,17.229,38.4,38.4S76.646,409.6,55.467,409.6z"/>
|
||||
<path fill="currentColor" d="M503.467,256h-8.533v-93.867c0-38.05-27.819-69.734-64.188-75.768c-1.212-0.657-2.603-1.033-4.079-1.033H153.6
|
||||
c-0.29,0-0.572,0.009-0.862,0.043c-54.443,5.513-88.055,98.987-99.388,136.491H42.667c-4.71,0-8.533,3.823-8.533,8.533v34.133
|
||||
c0,4.71,3.823,8.533,8.533,8.533h25.6v8.96c-2.807-0.282-5.649-0.427-8.533-0.427c-21.035,0-41.242,7.714-56.892,21.734
|
||||
c-3.516,3.14-3.806,8.533-0.666,12.049c3.149,3.516,8.533,3.814,12.049,0.666c12.518-11.204,28.689-17.382,45.508-17.382
|
||||
c37.641,0,68.267,30.626,68.267,68.267c0,4.71,3.823,8.533,8.533,8.533h222.549c4.164,28.902,29.022,51.2,59.051,51.2
|
||||
c30.029,0,54.886-22.298,59.051-51.2h9.216c4.71,0,8.533-3.823,8.533-8.533V307.2h8.533c4.71,0,8.533-3.823,8.533-8.533v-34.133
|
||||
C512,259.823,508.186,256,503.467,256z M154.061,102.4h264.073c32.939,0,59.733,26.795,59.733,59.733v85.333H358.4V128
|
||||
c0-4.71-3.823-8.533-8.533-8.533H147.652l-13.406-8.943C140.629,106.044,147.243,103.168,154.061,102.4z M341.333,136.533v115.934
|
||||
L309.7,284.1c-1.596,1.596-2.5,3.772-2.5,6.033v34.133h-59.733V136.533H341.333z M230.4,136.533v85.333H128.316l23.27-85.333
|
||||
H230.4z M121.122,122.291l14.097,9.404l-23.287,85.393l-33.434-16.717C89.762,169.694,104.346,140.706,121.122,122.291z
|
||||
M418.133,409.6c-20.599,0-37.837-14.686-41.805-34.133h83.61C455.979,394.914,438.741,409.6,418.133,409.6z M494.933,290.133
|
||||
H486.4c-4.71,0-8.533,3.823-8.533,8.533V358.4H144.64c-3.43-34.372-27.332-62.805-59.307-72.883v-20.983
|
||||
c0-4.71-3.823-8.533-8.533-8.533H51.2v-17.067h8.533c3.806,0,7.159-2.526,8.201-6.187c1.527-5.342,3.2-10.718,4.949-16.094
|
||||
l42.769,21.385c1.186,0.589,2.492,0.896,3.814,0.896H230.4V332.8c0,4.71,3.823,8.533,8.533,8.533h76.8
|
||||
c4.71,0,8.533-3.823,8.533-8.533v-39.134l29.141-29.133h124.459c0,4.71,3.823,8.533,8.533,8.533h8.533V290.133z"/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
|
@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'
|
|||
import { Menu, X, Sun, Moon, Zap, MessageSquareText, LogIn } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { useNostrStore } from '@/stores/nostr'
|
||||
import { useRouter } from 'vue-router'
|
||||
import LogoutDialog from '@/components/ui/logout-dialog/LogoutDialog.vue'
|
||||
import Login from '@/components/Login.vue'
|
||||
|
|
@ -13,15 +12,13 @@ import ConnectionStatus from '@/components/ConnectionStatus.vue'
|
|||
|
||||
const { t, locale } = useI18n()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const nostrStore = useNostrStore()
|
||||
// const nostrStore = useNostrStore()
|
||||
const router = useRouter()
|
||||
const isOpen = ref(false)
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
const navigation = computed(() => [
|
||||
{ name: t('nav.home'), href: '/' },
|
||||
{ name: t('nav.directory'), href: '/directory' },
|
||||
{ name: t('nav.faq'), href: '/faq' },
|
||||
{ name: t('nav.support'), href: '/support', icon: MessageSquareText },
|
||||
])
|
||||
|
||||
|
|
@ -42,7 +39,7 @@ const toggleLocale = () => {
|
|||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await nostrStore.logout()
|
||||
// await nostrStore.logout()
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
|
|
@ -82,18 +79,20 @@ const openLogin = () => {
|
|||
</Button>
|
||||
|
||||
<!-- Hide language toggle on mobile -->
|
||||
<Button variant="ghost"
|
||||
<Button variant="ghost"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors hidden sm:inline-flex"
|
||||
@click="toggleLocale">
|
||||
{{ locale === 'en' ? '🇪🇸 ES' : '🇺🇸 EN' }}
|
||||
</Button>
|
||||
|
||||
<ConnectionStatus v-if="nostrStore.isLoggedIn" />
|
||||
<!-- <ConnectionStatus v-if="nostrStore.isLoggedIn" /> -->
|
||||
<ConnectionStatus v-if="true" />
|
||||
|
||||
<template v-if="nostrStore.isLoggedIn">
|
||||
<!-- <template v-if="nostrStore.isLoggedIn"> -->
|
||||
<template v-if="true">
|
||||
<LogoutDialog :onLogout="handleLogout" />
|
||||
</template>
|
||||
<Button v-else variant="ghost" size="icon" @click="openLogin"
|
||||
<Button v-else variant="ghost" size="icon" @click="openLogin"
|
||||
class="text-muted-foreground hover:text-foreground">
|
||||
<LogIn class="h-5 w-5" />
|
||||
</Button>
|
||||
|
|
@ -118,8 +117,7 @@ const openLogin = () => {
|
|||
<Sun v-if="theme === 'dark'" class="h-5 w-5" />
|
||||
<Moon v-else class="h-5 w-5" />
|
||||
</Button>
|
||||
<Button variant="ghost"
|
||||
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||
<Button variant="ghost" class="text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="toggleLocale">
|
||||
{{ locale === 'en' ? '🇪🇸 ES' : '🇺🇸 EN' }}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ type Theme = 'dark' | 'light' | 'system'
|
|||
const useTheme = () => {
|
||||
const theme = ref<Theme>('dark')
|
||||
const systemTheme = ref<'dark' | 'light'>('light')
|
||||
const currentTown = ref(localStorage.getItem('current-town') || 'all')
|
||||
|
||||
const updateSystemTheme = () => {
|
||||
systemTheme.value = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
|
|
@ -28,7 +27,7 @@ const useTheme = () => {
|
|||
if (stored && (stored === 'dark' || stored === 'light' || stored === 'system')) {
|
||||
theme.value = stored as Theme
|
||||
}
|
||||
|
||||
|
||||
updateSystemTheme()
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateSystemTheme)
|
||||
applyTheme()
|
||||
|
|
@ -43,19 +42,12 @@ const useTheme = () => {
|
|||
localStorage.setItem('ui-theme', newTheme)
|
||||
}
|
||||
|
||||
const setCurrentTown = (town: string) => {
|
||||
currentTown.value = town
|
||||
localStorage.setItem('current-town', town)
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
setTheme,
|
||||
systemTheme,
|
||||
currentTheme,
|
||||
currentTown,
|
||||
setCurrentTown
|
||||
}
|
||||
}
|
||||
|
||||
export { useTheme }
|
||||
export { useTheme }
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
import { computed } from 'vue'
|
||||
import { useMessageStore } from '@/stores/messages'
|
||||
import { useNostrStore } from '@/stores/nostr'
|
||||
|
||||
export function useChat(pubkey: string) {
|
||||
const messageStore = useMessageStore()
|
||||
const nostrStore = useNostrStore()
|
||||
|
||||
const messages = computed(() =>
|
||||
messageStore.messages.get(pubkey) || []
|
||||
)
|
||||
|
||||
const sendMessage = async (content: string) => {
|
||||
if (!content.trim()) return
|
||||
await nostrStore.sendMessage(pubkey, content)
|
||||
}
|
||||
|
||||
const loadHistory = async () => {
|
||||
await nostrStore.subscribeToMessages()
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
sendMessage,
|
||||
loadHistory
|
||||
}
|
||||
}
|
||||
|
|
@ -1,288 +0,0 @@
|
|||
import { type DirectoryItem } from '@/types/directory'
|
||||
|
||||
export const mockDirectoryItems: DirectoryItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Ixchel Cafe & Bakery',
|
||||
category: 'restaurant',
|
||||
description: 'A cozy cafe serving great coffee and accepting Bitcoin Lightning payments.',
|
||||
url: 'https://ixchel.atitlan.io',
|
||||
town: 'San Marcos',
|
||||
mapsUrl: 'https://maps.app.goo.gl/sbjmvqP8U4SB4FS29',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/emporium.atitlan'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Axel',
|
||||
category: 'tuktuk',
|
||||
town: 'San Marcos',
|
||||
contact: '+502 3846 1220',
|
||||
contactType: ['whatsapp', 'telegram'],
|
||||
lightning: 'axel@atitlan.io'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Atitlan Muay Thai',
|
||||
category: 'services',
|
||||
town: 'San Pedro',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/muaythaiatitlan'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Zoe Nails & Spa',
|
||||
category: 'services',
|
||||
town: 'Panajachel',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/zoenailsyspapanajachel'
|
||||
},
|
||||
local: true
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Full Print',
|
||||
category: 'services',
|
||||
town: 'San Pedro',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/profile.php?id=100057645572968'
|
||||
},
|
||||
local: true
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'Multiservicios Yaxon',
|
||||
category: 'services',
|
||||
town: 'Tzununa',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/Multiservicios-Yaxón-299907600397260'
|
||||
},
|
||||
local: true
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
name: 'Utz Kab',
|
||||
category: 'goods',
|
||||
town: 'San Pablo',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/UTZ-KAB-534232490075686'
|
||||
},
|
||||
local: true
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
name: 'Utz Color Fashion',
|
||||
category: 'goods',
|
||||
town: 'San Pedro',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/UtzColorFashion'
|
||||
},
|
||||
local: true
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
name: 'Do Bau',
|
||||
category: 'goods',
|
||||
town: 'San Pedro',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/Adrianamatrioshka'
|
||||
},
|
||||
local: true
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
name: 'Caffé Kitsch',
|
||||
category: 'restaurant',
|
||||
town: 'Panajachel',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/Kitschers'
|
||||
},
|
||||
local: true
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
name: 'Hotel Corazon del Mundo Fresh',
|
||||
category: 'lodging',
|
||||
town: 'Jaibalito',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/corazondelmundofresh'
|
||||
},
|
||||
local: true
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
name: 'Hostel Fe San Marcos',
|
||||
category: 'lodging',
|
||||
town: 'San Marcos',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/HostelFeSanMarcos'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '13',
|
||||
name: 'Hotel Casa Maya',
|
||||
category: 'lodging',
|
||||
town: 'San Marcos',
|
||||
mapsUrl: 'https://www.google.com/maps/place/Hotel+Casa+Maya'
|
||||
},
|
||||
{
|
||||
id: '15',
|
||||
name: 'Artesanias San Marcos La Laguna',
|
||||
category: 'goods',
|
||||
town: 'San Marcos',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/Artesanias-San-Marcos-La-Laguna-102826589071628/'
|
||||
},
|
||||
local: true
|
||||
},
|
||||
{
|
||||
id: '16',
|
||||
name: 'Textiles Felix San Marcos La Laguna',
|
||||
category: 'goods',
|
||||
town: 'San Marcos',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/Textiles-Felix-San-Marcos-La-Laguna-102732085750559'
|
||||
},
|
||||
local: true
|
||||
},
|
||||
{
|
||||
id: '17',
|
||||
name: 'Health Food Store San Jose',
|
||||
category: 'goods',
|
||||
town: 'San Marcos',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/Health-Food-Store-San-Jose-719299235179691'
|
||||
},
|
||||
local: true
|
||||
},
|
||||
{
|
||||
id: '18',
|
||||
name: 'Tienda San Jose 2',
|
||||
category: 'goods',
|
||||
town: 'San Marcos',
|
||||
mapsUrl: 'https://www.google.com/maps/place/Tienda+San+José+%232',
|
||||
local: true
|
||||
},
|
||||
{
|
||||
id: '19',
|
||||
name: 'Sound Temple San Marcos',
|
||||
category: 'services',
|
||||
town: 'San Marcos',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/SoundTempleSanMarcos'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '21',
|
||||
name: 'Veda',
|
||||
category: 'restaurant',
|
||||
town: 'Tzununa',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/vedafoodismedicine'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '22',
|
||||
name: 'Bambu Guest House',
|
||||
category: 'lodging',
|
||||
town: 'Tzununa',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/bambuguesthousegt'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '23',
|
||||
name: 'Atitlan Organics',
|
||||
category: 'goods',
|
||||
town: 'Tzununa',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/atitlanorganics'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '24',
|
||||
name: 'Holy Wow Cacao',
|
||||
category: 'goods',
|
||||
town: 'Tzununa',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/HolyWowCacao'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '25',
|
||||
name: 'Bitcoin Lake Lancha (fake)',
|
||||
category: 'lancha',
|
||||
address: 'Pier 21, Harbor Front',
|
||||
contact: '+1 234-567-8902'
|
||||
},
|
||||
{
|
||||
id: '26',
|
||||
name: 'Tor\'s Drums',
|
||||
category: 'goods',
|
||||
town: 'San Marcos',
|
||||
contact: '+502 4900 1279',
|
||||
contactType: ['whatsapp'],
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/share/1DcBdJhuFH/'
|
||||
},
|
||||
lightning: 'tor@atitlan.io'
|
||||
},
|
||||
{
|
||||
id: '27',
|
||||
name: 'Jade Maya',
|
||||
category: 'goods',
|
||||
local: true,
|
||||
town: 'San Marcos',
|
||||
mapsUrl: 'https://maps.app.goo.gl/kZiKdM2FFAw1TQMN8',
|
||||
lightning: 'osman@atitlan.io',
|
||||
},
|
||||
{
|
||||
id: '28',
|
||||
name: 'La Sala del Lago',
|
||||
category: 'restaurant',
|
||||
town: 'San Marcos',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/La-Sala-Del-Lago-100220539146301'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '29',
|
||||
name: 'Nectar',
|
||||
category: 'restaurant',
|
||||
town: 'San Marcos',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/lovenectar'
|
||||
},
|
||||
local: true
|
||||
},
|
||||
{
|
||||
id: '30',
|
||||
name: 'Arati Cafe',
|
||||
category: 'restaurant',
|
||||
town: 'San Marcos',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/Arati-Cafe-105767784719695'
|
||||
},
|
||||
local: true
|
||||
},
|
||||
{
|
||||
id: '31',
|
||||
name: 'Fe Restaurant',
|
||||
category: 'restaurant',
|
||||
town: 'San Marcos',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/fesanmarcos'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '32',
|
||||
name: 'Hostal del Lago',
|
||||
category: 'lodging',
|
||||
town: 'San Marcos',
|
||||
social: {
|
||||
facebook: 'https://www.facebook.com/Hostel-Del-Lago-605530306467708'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
export class NostrEncryption {
|
||||
static async encrypt(privkey: string, pubkey: string, content: string) {
|
||||
return await window.NostrTools.nip04.encrypt(privkey, pubkey, content)
|
||||
}
|
||||
|
||||
static async decrypt(privkey: string, pubkey: string, content: string) {
|
||||
return await window.NostrTools.nip04.decrypt(privkey, pubkey, content)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
export class NostrError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public context?: any
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'NostrError'
|
||||
}
|
||||
}
|
||||
|
||||
export function handleNostrError(error: unknown) {
|
||||
if (error instanceof NostrError) {
|
||||
switch (error.code) {
|
||||
case 'CONNECTION_FAILED':
|
||||
return 'Failed to connect to relay. Please check your connection.'
|
||||
case 'DECRYPT_FAILED':
|
||||
return 'Failed to decrypt message.'
|
||||
default:
|
||||
return error.message
|
||||
}
|
||||
}
|
||||
return 'An unexpected error occurred'
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import type { DirectMessage } from '@/types/nostr'
|
||||
|
||||
export class MessageManager {
|
||||
private messages = new Map<string, DirectMessage[]>()
|
||||
private processedIds = new Set<string>()
|
||||
|
||||
constructor() {
|
||||
this.loadFromStorage()
|
||||
}
|
||||
|
||||
addMessage(pubkey: string, message: DirectMessage) {
|
||||
if (this.processedIds.has(message.id)) return false
|
||||
if (this.isDuplicate(pubkey, message)) return false
|
||||
|
||||
this.processedIds.add(message.id)
|
||||
const messages = [...(this.messages.get(pubkey) || []), message]
|
||||
messages.sort((a, b) => a.created_at - b.created_at)
|
||||
this.messages.set(pubkey, messages)
|
||||
this.saveToStorage()
|
||||
return true
|
||||
}
|
||||
|
||||
private isDuplicate(pubkey: string, message: DirectMessage) {
|
||||
const existing = this.messages.get(pubkey) || []
|
||||
return existing.some(msg =>
|
||||
msg.content === message.content &&
|
||||
Math.abs(msg.created_at - message.created_at) < 1
|
||||
)
|
||||
}
|
||||
|
||||
private loadFromStorage() {
|
||||
try {
|
||||
const stored = localStorage.getItem('nostr_messages')
|
||||
if (stored) {
|
||||
this.messages = new Map(JSON.parse(stored))
|
||||
this.messages.forEach(msgs =>
|
||||
msgs.forEach(msg => this.processedIds.add(msg.id))
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load messages:', err)
|
||||
}
|
||||
}
|
||||
|
||||
private saveToStorage() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'nostr_messages',
|
||||
JSON.stringify(Array.from(this.messages.entries()))
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to save messages:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import {
|
||||
getPublicKey,
|
||||
generateSecretKey,
|
||||
nip04,
|
||||
getEventHash,
|
||||
finalizeEvent,
|
||||
validateEvent,
|
||||
nip19,
|
||||
SimplePool,
|
||||
type Event,
|
||||
type Filter
|
||||
} from 'nostr-tools';
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
||||
|
||||
// Expose NostrTools to the window object
|
||||
(window as any).NostrTools = {
|
||||
getPublicKey,
|
||||
generatePrivateKey: () => bytesToHex(generateSecretKey()),
|
||||
nip04,
|
||||
getEventHash,
|
||||
getSignature: (event: Event, privateKey: string) => {
|
||||
const signedEvent = finalizeEvent(event, hexToBytes(privateKey));
|
||||
return signedEvent.sig;
|
||||
},
|
||||
signEvent: (event: Event, privateKey: string) => {
|
||||
const signedEvent = finalizeEvent(event, hexToBytes(privateKey));
|
||||
return signedEvent.sig;
|
||||
},
|
||||
verifySignature: validateEvent,
|
||||
nip19,
|
||||
relayInit: (url: string) => {
|
||||
const pool = new SimplePool();
|
||||
return {
|
||||
connect: async () => {
|
||||
await pool.ensureRelay(url);
|
||||
return true;
|
||||
},
|
||||
sub: (filters: Filter[]) => {
|
||||
return {
|
||||
on: (type: string, callback: (event: Event) => void) => {
|
||||
if (type === 'event') {
|
||||
void pool.subscribeMany(
|
||||
[url],
|
||||
filters,
|
||||
{ onevent: callback }
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
publish: (event: Event) => {
|
||||
return {
|
||||
on: (type: string, cb: (msg?: string) => void) => {
|
||||
if (type === 'ok') {
|
||||
Promise.all(pool.publish([url], event))
|
||||
.then(() => cb())
|
||||
.catch((err: Error) => cb(err.message));
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
close: () => {
|
||||
pool.close([url]);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
123
src/lib/nostr.ts
123
src/lib/nostr.ts
|
|
@ -1,123 +0,0 @@
|
|||
import type { NostrEvent, NostrRelayConfig } from '../types/nostr'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
NostrTools: {
|
||||
getPublicKey: (privkey: string) => string
|
||||
generatePrivateKey: () => string
|
||||
nip04: {
|
||||
encrypt: (privkey: string, pubkey: string, content: string) => Promise<string>
|
||||
decrypt: (privkey: string, pubkey: string, content: string) => Promise<string>
|
||||
}
|
||||
getEventHash: (event: NostrEvent) => string
|
||||
signEvent: (event: NostrEvent, privkey: string) => Promise<string>
|
||||
getSignature: (event: NostrEvent, privkey: string) => string
|
||||
verifySignature: (event: NostrEvent) => boolean
|
||||
nip19: {
|
||||
decode: (str: string) => { type: string; data: string }
|
||||
npubEncode: (hex: string) => string
|
||||
}
|
||||
relayInit: (url: string) => {
|
||||
connect: () => Promise<void>
|
||||
sub: (filters: any[]) => {
|
||||
on: (event: string, callback: (event: NostrEvent) => void) => void
|
||||
}
|
||||
publish: (event: NostrEvent) => {
|
||||
on: (type: 'ok' | 'failed', cb: (msg?: string) => void) => void
|
||||
}
|
||||
close: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectToRelay(url: string) {
|
||||
const relay = window.NostrTools.relayInit(url)
|
||||
try {
|
||||
await relay.connect()
|
||||
return relay
|
||||
} catch (err) {
|
||||
console.error(`Failed to connect to ${url}:`, err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishEvent(event: NostrEvent, relays: NostrRelayConfig[]) {
|
||||
const connectedRelays = await Promise.all(
|
||||
relays.map(relay => connectToRelay(relay.url))
|
||||
)
|
||||
|
||||
const activeRelays = connectedRelays.filter(relay => relay !== null)
|
||||
|
||||
return Promise.all(
|
||||
activeRelays.map(relay =>
|
||||
new Promise((resolve) => {
|
||||
const pub = relay.publish(event)
|
||||
pub.on('ok', () => {
|
||||
resolve(true)
|
||||
})
|
||||
pub.on('failed', () => {
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export async function encryptMessage(privkey: string, pubkey: string, content: string): Promise<string> {
|
||||
return await window.NostrTools.nip04.encrypt(privkey, pubkey, content)
|
||||
}
|
||||
|
||||
export async function decryptMessage(privkey: string, pubkey: string, content: string): Promise<string> {
|
||||
return await window.NostrTools.nip04.decrypt(privkey, pubkey, content)
|
||||
}
|
||||
|
||||
export function generatePrivateKey(): string {
|
||||
return window.NostrTools.generatePrivateKey()
|
||||
}
|
||||
|
||||
export function getPublicKey(privateKey: string): string {
|
||||
return window.NostrTools.getPublicKey(privateKey)
|
||||
}
|
||||
|
||||
export function getEventHash(event: NostrEvent): string {
|
||||
return window.NostrTools.getEventHash(event)
|
||||
}
|
||||
|
||||
export async function signEvent(event: NostrEvent, privateKey: string): Promise<string> {
|
||||
return window.NostrTools.getSignature(event, privateKey)
|
||||
}
|
||||
|
||||
export function verifySignature(event: NostrEvent): boolean {
|
||||
return window.NostrTools.verifySignature(event)
|
||||
}
|
||||
|
||||
export function npubToHex(npub: string): string {
|
||||
try {
|
||||
const { type, data } = window.NostrTools.nip19.decode(npub)
|
||||
if (type !== 'npub') throw new Error('Invalid npub')
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('Failed to decode npub:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function hexToNpub(hex: string): string {
|
||||
return window.NostrTools.nip19.npubEncode(hex)
|
||||
}
|
||||
|
||||
export function isValidPrivateKey(key: string): boolean {
|
||||
try {
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(key)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPrivateKey(key: string): string {
|
||||
return key.trim().toLowerCase()
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import type { DirectMessage } from '@/types/nostr'
|
||||
|
||||
export class MessageStorage {
|
||||
static saveMessages(pubkey: string, messages: DirectMessage[]) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`messages_${pubkey}`,
|
||||
JSON.stringify(messages)
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to save messages:', err)
|
||||
}
|
||||
}
|
||||
|
||||
static loadMessages(pubkey: string): DirectMessage[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(`messages_${pubkey}`)
|
||||
return stored ? JSON.parse(stored) : []
|
||||
} catch (err) {
|
||||
console.error('Failed to load messages:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import type { NostrEvent } from '@/types/nostr'
|
||||
|
||||
export class SubscriptionManager {
|
||||
private currentSubs: any[] = []
|
||||
private isActive = false
|
||||
|
||||
async subscribe(relay: any, filters: any[], handlers: {
|
||||
onEvent: (event: NostrEvent) => void,
|
||||
onEose?: () => void
|
||||
}) {
|
||||
if (this.isActive) return
|
||||
|
||||
this.isActive = true
|
||||
const sub = relay.sub(filters)
|
||||
|
||||
sub.on('event', handlers.onEvent)
|
||||
if (handlers.onEose) {
|
||||
sub.on('eose', handlers.onEose)
|
||||
}
|
||||
|
||||
this.currentSubs.push(sub)
|
||||
return sub
|
||||
}
|
||||
|
||||
unsubscribe() {
|
||||
this.currentSubs.forEach(sub => {
|
||||
try {
|
||||
if (sub?.unsub) sub.unsub()
|
||||
} catch (err) {
|
||||
console.error('Failed to unsubscribe:', err)
|
||||
}
|
||||
})
|
||||
this.currentSubs = []
|
||||
this.isActive = false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { withTimeout } from '@/lib/utils'
|
||||
import type { NostrEvent } from '@/types/nostr'
|
||||
|
||||
// Create a new WebSocket manager class
|
||||
export class NostrWebSocketManager {
|
||||
private relayPool: any[] = []
|
||||
private subscriptions = new Map<string, any>()
|
||||
|
||||
async connect(relays: { url: string }[]) {
|
||||
// Close existing connections
|
||||
await this.disconnect()
|
||||
|
||||
// Connect to all relays
|
||||
this.relayPool = (await Promise.all(
|
||||
relays.map(relay => this.connectToRelay(relay.url))
|
||||
)).filter((relay): relay is any => relay !== null)
|
||||
|
||||
return this.relayPool.length > 0
|
||||
}
|
||||
|
||||
private async connectToRelay(url: string) {
|
||||
const relay = window.NostrTools.relayInit(url)
|
||||
try {
|
||||
await withTimeout(relay.connect())
|
||||
return relay
|
||||
} catch (err) {
|
||||
console.error(`Failed to connect to ${url}:`, err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async publish(event: NostrEvent, relays: { url: string }[]) {
|
||||
return Promise.all(
|
||||
relays.map(({ url }) => this.publishToRelay(event, url))
|
||||
)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.relayPool.forEach(relay => relay.close())
|
||||
this.relayPool = []
|
||||
this.subscriptions.clear()
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
return this.relayPool.length > 0
|
||||
}
|
||||
|
||||
private async publishToRelay(event: NostrEvent, url: string) {
|
||||
const relay = window.NostrTools.relayInit(url)
|
||||
try {
|
||||
await relay.connect()
|
||||
return new Promise((resolve, reject) => {
|
||||
const pub = relay.publish(event)
|
||||
pub.on('ok', () => resolve(true))
|
||||
pub.on('failed', reject)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`Failed to publish to ${url}:`, err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import router from './router'
|
|||
import { i18n } from './i18n'
|
||||
import './assets/index.css'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import './lib/nostr-bundle'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import DirectoryGrid from '@/components/directory/DirectoryGrid.vue'
|
||||
import DirectoryFilter from '@/components/directory/DirectoryFilter.vue'
|
||||
import { mockDirectoryItems } from '@/data/directory'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { currentTown } = useTheme()
|
||||
|
||||
// Use the imported mock data
|
||||
const items = mockDirectoryItems
|
||||
|
||||
const category = ref('all')
|
||||
const search = ref('')
|
||||
const town = ref(currentTown.value)
|
||||
|
||||
// Watch for route changes to update filters
|
||||
watch(() => route.query, (newQuery) => {
|
||||
category.value = newQuery.category?.toString() || 'all'
|
||||
search.value = newQuery.search?.toString() || ''
|
||||
town.value = newQuery.town === undefined ? currentTown.value : (newQuery.town?.toString() || 'all')
|
||||
}, { immediate: true })
|
||||
|
||||
// Watch for filter changes and update URL
|
||||
watch([category, search, town], ([newCategory, newSearch, newTown]) => {
|
||||
// Don't update URL if it matches current query params
|
||||
if (
|
||||
newCategory === route.query.category &&
|
||||
newSearch === route.query.search &&
|
||||
newTown === route.query.town
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const query = {
|
||||
...(newCategory !== 'all' && { category: newCategory }),
|
||||
...(newSearch && { search: newSearch }),
|
||||
...(newTown !== currentTown.value && { town: newTown })
|
||||
}
|
||||
router.replace({ query })
|
||||
})
|
||||
|
||||
// Filter items based on category, search, and town
|
||||
const filteredItems = computed(() => {
|
||||
return items.filter(item => {
|
||||
const matchesCategory = category.value === 'all' || item.category === category.value
|
||||
const matchesSearch = !search.value ||
|
||||
item.name.toLowerCase().includes(search.value.toLowerCase()) ||
|
||||
item.description?.toLowerCase().includes(search.value.toLowerCase())
|
||||
const matchesTown = town.value === 'all' || item.town === town.value
|
||||
return matchesCategory && matchesSearch && matchesTown
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-0 md:container md:px-4 py-2 md:py-8">
|
||||
<div class="space-y-4 md:space-y-6">
|
||||
<!-- Directory Header -->
|
||||
<div class="text-center space-y-2 mb-2 md:mb-6">
|
||||
<h1 class="md:hidden text-lg font-semibold tracking-tight px-4">
|
||||
{{ t('directory.title') }}
|
||||
</h1>
|
||||
<div class="hidden md:block space-y-2">
|
||||
<h1 class="text-2xl font-bold tracking-tight sm:text-3xl">
|
||||
{{ t('directory.title') }}
|
||||
</h1>
|
||||
<p class="text-sm sm:text-base text-muted-foreground">
|
||||
{{ t('directory.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky Container for Filter -->
|
||||
<div class="sticky top-14 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b md:border-b-0 w-full shadow-sm transition-all duration-200">
|
||||
<div class="w-full">
|
||||
<DirectoryFilter v-model:category="category" v-model:search="search" v-model:town="town" class="py-3 px-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Directory Grid -->
|
||||
<div class="pt-4 px-4 md:px-0">
|
||||
<DirectoryGrid :items="filteredItems" class="transition-all duration-300" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-move,
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.fade-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Share2, Copy, Check } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import DirectoryItemDetail from '@/components/directory/DirectoryItemDetail.vue'
|
||||
import { type DirectoryItem } from '@/types/directory'
|
||||
import { mockDirectoryItems } from '@/data/directory'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
}>()
|
||||
|
||||
const item = ref<DirectoryItem | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const justCopied = ref(false)
|
||||
|
||||
// Check if Web Share API is available
|
||||
const canShare = computed(() => typeof navigator !== 'undefined' && !!navigator.share)
|
||||
|
||||
// Share functionality
|
||||
const shareItem = async () => {
|
||||
if (!item.value) return
|
||||
|
||||
const shareData = {
|
||||
title: item.value.name,
|
||||
text: item.value.description || `Check out ${item.value.name} on Atitlan Directory`,
|
||||
url: window.location.href
|
||||
}
|
||||
|
||||
if (canShare.value) {
|
||||
try {
|
||||
await navigator.share(shareData)
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name !== 'AbortError') {
|
||||
console.error('Error sharing:', err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to copying the URL
|
||||
await copyToClipboard()
|
||||
}
|
||||
}
|
||||
|
||||
// Copy to clipboard functionality
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
justCopied.value = true
|
||||
setTimeout(() => {
|
||||
justCopied.value = false
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const found = mockDirectoryItems.find(i => i.id === props.id)
|
||||
if (!found) {
|
||||
error.value = true
|
||||
return
|
||||
}
|
||||
|
||||
item.value = found
|
||||
} catch (e) {
|
||||
error.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-pulse">
|
||||
<div class="h-8 bg-muted rounded w-3/4 mx-auto mb-4"></div>
|
||||
<div class="h-4 bg-muted rounded w-1/2 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-12">
|
||||
<h2 class="text-xl font-semibold mb-2">{{ t('directory.itemNotFound') }}</h2>
|
||||
<p class="text-muted-foreground mb-4">{{ t('directory.itemNotFoundDesc') }}</p>
|
||||
<router-link to="/directory"
|
||||
class="inline-flex items-center justify-center rounded-md bg-primary px-6 py-3 text-sm font-medium text-primary-foreground hover:bg-primary/90">
|
||||
{{ t('directory.backToDirectory') }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Directory Item -->
|
||||
<template v-else-if="item">
|
||||
<div class="mb-6 flex justify-between items-center">
|
||||
<router-link to="/directory"
|
||||
class="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
← {{ t('directory.backToDirectory') }}
|
||||
</router-link>
|
||||
|
||||
<!-- Share Button -->
|
||||
<Button variant="outline" size="sm" @click="shareItem">
|
||||
<template v-if="canShare">
|
||||
<Share2 class="h-4 w-4 mr-2" />
|
||||
{{ t('directory.share') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<Copy v-if="!justCopied" class="h-4 w-4 mr-2" />
|
||||
<Check v-else class="h-4 w-4 mr-2" />
|
||||
{{ justCopied ? t('directory.linkCopied') : t('directory.copyLink') }}
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<DirectoryItemDetail :item="item" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const faqs = computed(() => [
|
||||
{
|
||||
question: t('faq.items.0.question'),
|
||||
answer: t('faq.items.0.answer')
|
||||
},
|
||||
{
|
||||
question: t('faq.items.1.question'),
|
||||
answer: t('faq.items.1.answer')
|
||||
},
|
||||
{
|
||||
question: t('faq.items.2.question'),
|
||||
answer: t('faq.items.2.answer')
|
||||
},
|
||||
{
|
||||
question: t('faq.items.3.question'),
|
||||
answer: t('faq.items.3.answer')
|
||||
}
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<h1 class="text-3xl font-bold text-center mb-8">
|
||||
{{ t('faq.title') }}
|
||||
</h1>
|
||||
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem v-for="(faq, index) in faqs" :key="index" :value="'item-' + index">
|
||||
<AccordionTrigger>{{ faq.question }}</AccordionTrigger>
|
||||
<AccordionContent>{{ faq.answer }}</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,137 +1,10 @@
|
|||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
UtensilsCrossed,
|
||||
Bed,
|
||||
ShoppingBag,
|
||||
Sparkles,
|
||||
Car,
|
||||
Ship,
|
||||
HelpCircle,
|
||||
} from 'lucide-vue-next'
|
||||
import TukTuk from '@/components/icons/TukTuk.vue'
|
||||
import { type DirectoryItem } from '@/types/directory'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { currentTown, setCurrentTown } = useTheme()
|
||||
|
||||
type CategoryType = DirectoryItem['category']
|
||||
|
||||
const categories: CategoryType[] = ['tuktuk', 'restaurant', 'services', 'goods', 'lodging', 'taxi', 'lancha']
|
||||
|
||||
const categoryIcons = {
|
||||
restaurant: UtensilsCrossed,
|
||||
lodging: Bed,
|
||||
goods: ShoppingBag,
|
||||
services: Sparkles,
|
||||
tuktuk: TukTuk,
|
||||
taxi: Car,
|
||||
lancha: Ship,
|
||||
other: HelpCircle,
|
||||
} as const
|
||||
|
||||
const categoryColors = {
|
||||
tuktuk: 'text-amber-500',
|
||||
restaurant: 'text-orange-500',
|
||||
lodging: 'text-purple-500',
|
||||
goods: 'text-green-500',
|
||||
services: 'text-pink-500',
|
||||
taxi: 'text-yellow-500',
|
||||
lancha: 'text-blue-500',
|
||||
other: 'text-gray-500',
|
||||
} as const
|
||||
|
||||
const towns = computed(() => [
|
||||
{ id: 'all', label: t('directory.towns.all') },
|
||||
{ id: 'San Marcos', label: 'San Marcos' },
|
||||
{ id: 'San Pedro', label: 'San Pedro' },
|
||||
{ id: 'Tzununa', label: 'Tzununa' },
|
||||
{ id: 'Jaibalito', label: 'Jaibalito' },
|
||||
{ id: 'San Pablo', label: 'San Pablo' },
|
||||
{ id: 'Panajachel', label: 'Panajachel' },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8 sm:py-12">
|
||||
<div class="max-w-3xl mx-auto text-center space-y-8 sm:space-y-12">
|
||||
<!-- Hero Section -->
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-4xl font-bold tracking-tight sm:text-6xl text-foreground animate-in fade-in slide-in-from-bottom-4 duration-1000 fill-mode-both">
|
||||
{{ t('home.title') }}
|
||||
</h1>
|
||||
|
||||
<p class="text-base sm:text-lg text-muted-foreground max-w-2xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-150 fill-mode-both">
|
||||
{{ t('home.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Town Selector -->
|
||||
<div class="flex flex-col items-center gap-3 animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-300 fill-mode-both">
|
||||
<h2 class="text-base font-medium text-muted-foreground">{{ t('home.selectTown') }}</h2>
|
||||
<Select :model-value="currentTown" @update:model-value="setCurrentTown">
|
||||
<SelectTrigger class="w-[240px] h-11">
|
||||
<SelectValue :placeholder="t('home.selectTownPlaceholder')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="town in towns" :key="town.id" :value="town.id">
|
||||
{{ town.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Category Buttons -->
|
||||
<div class="relative mx-auto max-w-2xl pt-4 animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-500 fill-mode-both">
|
||||
<!-- Gradient Fade Edges -->
|
||||
<div class="absolute left-0 top-0 bottom-0 w-4 bg-gradient-to-r from-background to-transparent z-10 sm:hidden"></div>
|
||||
<div class="absolute right-0 top-0 bottom-0 w-4 bg-gradient-to-l from-background to-transparent z-10 sm:hidden"></div>
|
||||
|
||||
<!-- Scrollable Container -->
|
||||
<div class="flex overflow-x-auto gap-2 px-4 pb-4
|
||||
[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]
|
||||
sm:flex-wrap sm:justify-center sm:gap-4 sm:px-0">
|
||||
<router-link v-for="category in categories" :key="category"
|
||||
:to="`/directory?category=${category}&town=${currentTown}`"
|
||||
class="flex-shrink-0 w-[100px] sm:w-[110px]">
|
||||
<Button variant="ghost" size="default"
|
||||
class="w-full h-20 sm:h-24 flex flex-col items-center justify-center gap-1.5 sm:gap-2.5
|
||||
hover:bg-accent hover:scale-105 active:scale-100 transition-all duration-200 group">
|
||||
<component :is="categoryIcons[category]"
|
||||
class="h-6 w-6 sm:h-7 sm:w-7 transition-transform duration-200 group-hover:scale-110"
|
||||
:class="categoryColors[category]" />
|
||||
<span class="text-xs sm:text-sm font-medium text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{{ t(`directory.categories.${category}`) }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center pt-4 animate-in fade-in slide-in-from-bottom-4 duration-1000 delay-700 fill-mode-both">
|
||||
<router-link to="/directory" class="sm:w-[160px]">
|
||||
<Button variant="default" size="lg" class="w-full font-medium">
|
||||
{{ t('home.browse') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/faq" class="sm:w-[160px]">
|
||||
<Button variant="outline" size="lg" class="w-full font-medium">
|
||||
{{ t('home.learnMore') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useNostrStore } from '@/stores/nostr'
|
||||
import SupportChat from '@/components/SupportChat.vue'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import Login from '@/components/Login.vue'
|
||||
|
||||
const nostrStore = useNostrStore()
|
||||
const showLoginDialog = ref(!nostrStore.isLoggedIn)
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
showLoginDialog.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container max-w-4xl mx-auto h-[calc(100vh-4rem)] py-4 px-4 sm:px-6 lg:px-8">
|
||||
<div class="h-full">
|
||||
<div class="h-full animate-in fade-in-50 slide-in-from-bottom-3">
|
||||
<SupportChat v-if="nostrStore.isLoggedIn" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Dialog -->
|
||||
<Dialog v-model:open="showLoginDialog">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<Login @success="handleLoginSuccess" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.animate-in {
|
||||
animation-duration: 0.3s;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.fade-in-50 {
|
||||
animation-name: fade-in-50;
|
||||
}
|
||||
|
||||
.slide-in-from-bottom-3 {
|
||||
animation-name: slide-in-from-bottom-3;
|
||||
}
|
||||
|
||||
@keyframes fade-in-50 {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-from-bottom-3 {
|
||||
from {
|
||||
transform: translateY(3px);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,8 +1,5 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Home from '@/pages/Home.vue'
|
||||
import Directory from '@/pages/Directory.vue'
|
||||
import FAQ from '@/pages/FAQ.vue'
|
||||
import Support from '@/pages/Support.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
|
|
@ -12,28 +9,7 @@ const router = createRouter({
|
|||
name: 'home',
|
||||
component: Home
|
||||
},
|
||||
{
|
||||
path: '/directory',
|
||||
name: 'directory',
|
||||
component: Directory
|
||||
},
|
||||
{
|
||||
path: '/faq',
|
||||
name: 'faq',
|
||||
component: FAQ
|
||||
},
|
||||
{
|
||||
path: '/directory/:id',
|
||||
name: 'directory-item',
|
||||
component: () => import('@/pages/DirectoryItem.vue'),
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/support',
|
||||
name: 'support',
|
||||
component: Support
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
||||
export default router
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { DirectMessage } from '@/types/nostr'
|
||||
|
||||
// Separate message handling into its own store
|
||||
export const useMessageStore = defineStore('messages', () => {
|
||||
const messages = ref<Map<string, DirectMessage[]>>(new Map())
|
||||
const processedIds = ref(new Set<string>())
|
||||
|
||||
const addMessage = async (pubkey: string, message: DirectMessage) => {
|
||||
if (processedIds.value.has(message.id)) return
|
||||
|
||||
processedIds.value.add(message.id)
|
||||
const userMessages = messages.value.get(pubkey) || []
|
||||
messages.value.set(pubkey, [...userMessages, message].sort((a, b) =>
|
||||
a.created_at - b.created_at
|
||||
))
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
processedIds,
|
||||
addMessage
|
||||
}
|
||||
})
|
||||
|
|
@ -1,621 +0,0 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { NostrEvent, NostrProfile, NostrAccount, DirectMessage } from '../types/nostr'
|
||||
import { isValidPrivateKey, formatPrivateKey } from '@/lib/nostr'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
NostrTools: {
|
||||
getPublicKey: (privkey: string) => string
|
||||
generatePrivateKey: () => string
|
||||
nip04: {
|
||||
encrypt: (privkey: string, pubkey: string, content: string) => Promise<string>
|
||||
decrypt: (privkey: string, pubkey: string, content: string) => Promise<string>
|
||||
}
|
||||
getEventHash: (event: NostrEvent) => string
|
||||
signEvent: (event: NostrEvent, privkey: string) => Promise<string>
|
||||
getSignature: (event: NostrEvent, privkey: string) => string
|
||||
verifySignature: (event: NostrEvent) => boolean
|
||||
nip19: {
|
||||
decode: (str: string) => { type: string; data: string }
|
||||
npubEncode: (hex: string) => string
|
||||
}
|
||||
relayInit: (url: string) => {
|
||||
connect: () => Promise<void>
|
||||
sub: (filters: any[]) => {
|
||||
on: (event: string, callback: (event: NostrEvent) => void) => void
|
||||
}
|
||||
publish: (event: NostrEvent) => {
|
||||
on: (type: 'ok' | 'failed', cb: (msg?: string) => void) => void
|
||||
}
|
||||
close: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_RELAYS = [
|
||||
'wss://nostr.atitlan.io'
|
||||
]
|
||||
|
||||
// Helper functions
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number = 10000): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Operation timed out')), timeoutMs)
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
// Add to state
|
||||
const connectionStatus = ref<'connected' | 'connecting' | 'disconnected'>('disconnected')
|
||||
const hasUnreadMessages = ref(false)
|
||||
const viewedMessageIds = ref<Set<string>>(new Set(
|
||||
JSON.parse(localStorage.getItem('nostr_viewed_messages') || '[]')
|
||||
))
|
||||
|
||||
// Update in connect function
|
||||
async function connectToRelay(url: string) {
|
||||
connectionStatus.value = 'connecting'
|
||||
console.log(`Attempting to connect to relay: ${url}`)
|
||||
const relay = window.NostrTools.relayInit(url)
|
||||
try {
|
||||
console.log(`Initializing connection to ${url}...`)
|
||||
await withTimeout(relay.connect())
|
||||
console.log(`Successfully connected to ${url}`)
|
||||
connectionStatus.value = 'connected'
|
||||
return relay
|
||||
} catch (err) {
|
||||
console.error(`Failed to connect to ${url}:`, err)
|
||||
connectionStatus.value = 'disconnected'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function publishEvent(event: NostrEvent, relays: { url: string }[]) {
|
||||
const promises = relays.map(async ({ url }) => {
|
||||
const relay = window.NostrTools.relayInit(url)
|
||||
try {
|
||||
await relay.connect()
|
||||
const pub = relay.publish(event)
|
||||
return new Promise((resolve, reject) => {
|
||||
pub.on('ok', () => resolve(true))
|
||||
pub.on('failed', reject)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`Failed to publish to ${url}:`, err)
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
}
|
||||
|
||||
export const useNostrStore = defineStore('nostr', () => {
|
||||
// State
|
||||
const account = ref<NostrAccount | null>(JSON.parse(localStorage.getItem('nostr_account') || 'null'))
|
||||
const profiles = ref<Map<string, NostrProfile>>(new Map())
|
||||
const messages = ref<Map<string, DirectMessage[]>>(new Map())
|
||||
const activeChat = ref<string | null>(null)
|
||||
const relayPool = ref<any[]>([])
|
||||
const processedMessageIds = ref(new Set<string>())
|
||||
const currentSubscription = ref<any | null>(null)
|
||||
const hasActiveSubscription = ref(false)
|
||||
|
||||
// Load stored messages and IDs on initialization
|
||||
const initializeFromStorage = () => {
|
||||
try {
|
||||
const messageMap = new Map<string, DirectMessage[]>(
|
||||
JSON.parse(localStorage.getItem('nostr_messages') || '[]')
|
||||
)
|
||||
|
||||
messageMap.forEach((msgs: DirectMessage[]) => {
|
||||
msgs.forEach(msg => {
|
||||
processedMessageIds.value.add(msg.id)
|
||||
})
|
||||
})
|
||||
|
||||
messages.value = messageMap
|
||||
} catch (err) {
|
||||
console.error('Failed to load stored messages:', err)
|
||||
localStorage.removeItem('nostr_messages')
|
||||
}
|
||||
}
|
||||
|
||||
// Call initialization
|
||||
initializeFromStorage()
|
||||
|
||||
// Watch account changes and persist to localStorage
|
||||
watch(account, (newAccount) => {
|
||||
if (newAccount) {
|
||||
localStorage.setItem('nostr_account', JSON.stringify(newAccount))
|
||||
} else {
|
||||
localStorage.removeItem('nostr_account')
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Watch messages for changes and persist
|
||||
watch(messages, (newMessages) => {
|
||||
try {
|
||||
localStorage.setItem('nostr_messages',
|
||||
JSON.stringify(Array.from(newMessages.entries()))
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to save messages:', err)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Initialize store if account exists in localStorage
|
||||
if (account.value) {
|
||||
console.log('Found existing account, initializing connection...')
|
||||
init()
|
||||
}
|
||||
|
||||
// Computed
|
||||
const isLoggedIn = computed(() => !!account.value)
|
||||
const currentMessages = computed(() =>
|
||||
activeChat.value ? messages.value.get(activeChat.value) || [] : []
|
||||
)
|
||||
|
||||
// Initialize connection if account exists
|
||||
async function init() {
|
||||
if (!account.value) return
|
||||
|
||||
try {
|
||||
// Only clear profiles and processed IDs
|
||||
profiles.value.clear()
|
||||
processedMessageIds.value.clear()
|
||||
|
||||
// Connect to relays
|
||||
relayPool.value = (await Promise.all(
|
||||
account.value.relays.map(async relay => {
|
||||
console.log('Connecting to relay:', relay.url)
|
||||
const connection = await connectToRelay(relay.url)
|
||||
if (!connection) {
|
||||
console.error('Failed to connect to relay:', relay.url)
|
||||
}
|
||||
return connection
|
||||
})
|
||||
)).filter((relay): relay is any => relay !== null)
|
||||
|
||||
if (relayPool.value.length === 0) {
|
||||
throw new Error('Failed to connect to any relays')
|
||||
}
|
||||
|
||||
// Setup visibility change handler
|
||||
setupVisibilityHandler()
|
||||
|
||||
// Subscribe to messages in the background
|
||||
subscribeToMessages().catch(err => {
|
||||
console.error('Background subscription failed:', err)
|
||||
})
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
async function login(privkey: string) {
|
||||
if (!isValidPrivateKey(privkey)) {
|
||||
throw new Error('Invalid private key')
|
||||
}
|
||||
|
||||
const formattedKey = formatPrivateKey(privkey)
|
||||
const pubkey = window.NostrTools.getPublicKey(formattedKey)
|
||||
|
||||
account.value = {
|
||||
pubkey,
|
||||
privkey: formattedKey,
|
||||
relays: DEFAULT_RELAYS.map(url => ({ url, read: true, write: true }))
|
||||
}
|
||||
|
||||
// Initialize connection in the background
|
||||
init().catch(err => {
|
||||
console.error('Background initialization failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
account.value = null
|
||||
relayPool.value.forEach(relay => relay.close())
|
||||
relayPool.value = []
|
||||
messages.value.clear()
|
||||
profiles.value.clear()
|
||||
processedMessageIds.value.clear()
|
||||
activeChat.value = null
|
||||
localStorage.removeItem('nostr_messages')
|
||||
localStorage.removeItem('nostr_account')
|
||||
hasUnreadMessages.value = false
|
||||
localStorage.removeItem('nostr_unread_messages')
|
||||
viewedMessageIds.value.clear()
|
||||
localStorage.removeItem('nostr_viewed_messages')
|
||||
}
|
||||
|
||||
const addMessage = async (pubkey: string, message: DirectMessage) => {
|
||||
// Skip if we've already processed this message
|
||||
if (processedMessageIds.value.has(message.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
processedMessageIds.value.add(message.id)
|
||||
const userMessages = messages.value.get(pubkey) || []
|
||||
|
||||
// Check for duplicates
|
||||
const isDuplicate = userMessages.some(msg =>
|
||||
msg.content === message.content &&
|
||||
Math.abs(msg.created_at - message.created_at) < 1
|
||||
)
|
||||
|
||||
if (!isDuplicate) {
|
||||
messages.value.set(pubkey, [...userMessages, message].sort((a, b) =>
|
||||
a.created_at - b.created_at
|
||||
))
|
||||
|
||||
// Only set unread if:
|
||||
// 1. Message came from websocket (not storage)
|
||||
// 2. Not from current chat
|
||||
// 3. Not sent by us
|
||||
if (!message.fromStorage && pubkey !== activeChat.value && !message.sent) {
|
||||
console.log('New unread message received:', { pubkey, messageId: message.id })
|
||||
hasUnreadMessages.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(to: string, content: string) {
|
||||
if (!account.value) return
|
||||
|
||||
const encrypted = await window.NostrTools.nip04.encrypt(account.value.privkey, to, content)
|
||||
const event: NostrEvent = {
|
||||
kind: 4,
|
||||
pubkey: account.value.pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', to]],
|
||||
content: encrypted,
|
||||
id: '',
|
||||
sig: ''
|
||||
}
|
||||
|
||||
event.id = window.NostrTools.getEventHash(event)
|
||||
event.sig = await window.NostrTools.signEvent(event, account.value.privkey)
|
||||
|
||||
// Add to local messages first
|
||||
const dm: DirectMessage = {
|
||||
id: event.id,
|
||||
pubkey: to,
|
||||
content,
|
||||
created_at: event.created_at,
|
||||
sent: true
|
||||
}
|
||||
|
||||
await addMessage(to, dm)
|
||||
|
||||
// Then publish to relays
|
||||
await publishEvent(event, account.value.relays)
|
||||
}
|
||||
|
||||
async function subscribeToMessages() {
|
||||
if (!account.value || hasActiveSubscription.value) return
|
||||
|
||||
hasActiveSubscription.value = true
|
||||
// Cleanup existing subscription
|
||||
unsubscribeFromMessages()
|
||||
|
||||
// Get timestamp from 24 hours ago
|
||||
const since = Math.floor(Date.now() / 1000) - (24 * 60 * 60)
|
||||
let hasReceivedMessages = false
|
||||
|
||||
try {
|
||||
const subscribeToRelay = (relay: any) => {
|
||||
return new Promise((resolve) => {
|
||||
const subs: any[] = []
|
||||
|
||||
try {
|
||||
console.log('Setting up subscriptions for relay...')
|
||||
|
||||
// Subscribe to received messages
|
||||
const receivedSub = relay.sub([{
|
||||
kinds: [4],
|
||||
'#p': [account.value!.pubkey],
|
||||
since,
|
||||
limit: 100 // Add limit to ensure we get historical messages
|
||||
}])
|
||||
subs.push(receivedSub)
|
||||
|
||||
// Subscribe to sent messages
|
||||
const sentSub = relay.sub([{
|
||||
kinds: [4],
|
||||
authors: [account.value!.pubkey],
|
||||
since,
|
||||
limit: 100 // Add limit to ensure we get historical messages
|
||||
}])
|
||||
subs.push(sentSub)
|
||||
|
||||
// Handle received messages
|
||||
receivedSub.on('event', async (event: NostrEvent) => {
|
||||
hasReceivedMessages = true
|
||||
try {
|
||||
if (processedMessageIds.value.has(event.id)) return
|
||||
|
||||
const decrypted = await window.NostrTools.nip04.decrypt(
|
||||
account.value!.privkey,
|
||||
event.pubkey,
|
||||
event.content
|
||||
)
|
||||
|
||||
const dm: DirectMessage = {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
content: decrypted,
|
||||
created_at: event.created_at,
|
||||
sent: false,
|
||||
fromStorage: false // Mark as not from storage
|
||||
}
|
||||
|
||||
await addMessage(event.pubkey, dm)
|
||||
} catch (err) {
|
||||
console.error('Failed to decrypt received message:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle sent messages
|
||||
sentSub.on('event', async (event: NostrEvent) => {
|
||||
hasReceivedMessages = true
|
||||
try {
|
||||
if (processedMessageIds.value.has(event.id)) return
|
||||
|
||||
const targetPubkey = event.tags.find(tag => tag[0] === 'p')?.[1]
|
||||
if (!targetPubkey) return
|
||||
|
||||
const decrypted = await window.NostrTools.nip04.decrypt(
|
||||
account.value!.privkey,
|
||||
targetPubkey,
|
||||
event.content
|
||||
)
|
||||
|
||||
const dm: DirectMessage = {
|
||||
id: event.id,
|
||||
pubkey: targetPubkey,
|
||||
content: decrypted,
|
||||
created_at: event.created_at,
|
||||
sent: true
|
||||
}
|
||||
|
||||
await addMessage(targetPubkey, dm)
|
||||
} catch (err) {
|
||||
console.error('Failed to decrypt sent message:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle EOSE (End of Stored Events)
|
||||
receivedSub.on('eose', () => {
|
||||
console.log('Received EOSE for received messages')
|
||||
if (!hasReceivedMessages) {
|
||||
console.log('No messages received yet, keeping subscription open')
|
||||
}
|
||||
})
|
||||
|
||||
sentSub.on('eose', () => {
|
||||
console.log('Received EOSE for sent messages')
|
||||
if (!hasReceivedMessages) {
|
||||
console.log('No messages received yet, keeping subscription open')
|
||||
}
|
||||
})
|
||||
|
||||
// Store subscriptions for cleanup
|
||||
currentSubscription.value = {
|
||||
unsub: () => {
|
||||
subs.forEach(sub => {
|
||||
try {
|
||||
if (sub && typeof sub.unsub === 'function') {
|
||||
sub.unsub()
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug('Failed to unsubscribe:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Keep subscription open
|
||||
resolve(true)
|
||||
|
||||
} catch (err) {
|
||||
console.debug('Error in subscription setup:', err)
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for all relays to set up subscriptions
|
||||
const results = await Promise.all(
|
||||
relayPool.value.map(relay => subscribeToRelay(relay))
|
||||
)
|
||||
|
||||
// Consider success if at least one relay worked
|
||||
return results.some(result => result)
|
||||
} catch (err) {
|
||||
console.debug('Subscription process failed:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function unsubscribeFromMessages() {
|
||||
if (currentSubscription.value && typeof currentSubscription.value.unsub === 'function') {
|
||||
try {
|
||||
currentSubscription.value.unsub()
|
||||
} catch (err) {
|
||||
console.error('Failed to unsubscribe:', err)
|
||||
}
|
||||
currentSubscription.value = null
|
||||
}
|
||||
hasActiveSubscription.value = false
|
||||
}
|
||||
|
||||
async function loadProfiles() {
|
||||
if (!account.value) return
|
||||
|
||||
const pubkeysToLoad = new Set<string>()
|
||||
|
||||
// Collect all unique pubkeys from messages
|
||||
for (const [pubkey] of messages.value.entries()) {
|
||||
if (!profiles.value.has(pubkey)) {
|
||||
pubkeysToLoad.add(pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
if (pubkeysToLoad.size === 0) return
|
||||
|
||||
try {
|
||||
const filter = {
|
||||
kinds: [0],
|
||||
authors: Array.from(pubkeysToLoad)
|
||||
}
|
||||
|
||||
const loadFromRelay = (relay: any) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
const sub = relay.sub([filter])
|
||||
|
||||
sub.on('event', (event: NostrEvent) => {
|
||||
try {
|
||||
const profile = JSON.parse(event.content)
|
||||
profiles.value.set(event.pubkey, {
|
||||
pubkey: event.pubkey,
|
||||
name: profile.name,
|
||||
picture: profile.picture,
|
||||
about: profile.about,
|
||||
nip05: profile.nip05
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to parse profile:', err)
|
||||
}
|
||||
})
|
||||
|
||||
// Resolve after receiving EOSE (End of Stored Events)
|
||||
sub.on('eose', () => {
|
||||
resolve()
|
||||
})
|
||||
|
||||
// Set a timeout in case EOSE is not received
|
||||
setTimeout(() => {
|
||||
resolve()
|
||||
}, 5000)
|
||||
})
|
||||
}
|
||||
|
||||
// Load profiles from all relays concurrently
|
||||
await Promise.all(relayPool.value.map(relay => loadFromRelay(relay)))
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to load profiles:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add a reconnection function
|
||||
async function reconnectToRelays() {
|
||||
if (!account.value) return
|
||||
|
||||
console.log('Attempting to reconnect to relays...')
|
||||
|
||||
// Close existing connections
|
||||
relayPool.value.forEach(relay => {
|
||||
try {
|
||||
relay.close()
|
||||
} catch (err) {
|
||||
console.error('Error closing relay:', err)
|
||||
}
|
||||
})
|
||||
relayPool.value = []
|
||||
|
||||
// Reconnect
|
||||
relayPool.value = (await Promise.all(
|
||||
account.value.relays.map(async relay => {
|
||||
console.log('Reconnecting to relay:', relay.url)
|
||||
const connection = await connectToRelay(relay.url)
|
||||
if (!connection) {
|
||||
console.error('Failed to reconnect to relay:', relay.url)
|
||||
}
|
||||
return connection
|
||||
})
|
||||
)).filter((relay): relay is any => relay !== null)
|
||||
|
||||
if (relayPool.value.length === 0) {
|
||||
throw new Error('Failed to connect to any relays')
|
||||
}
|
||||
|
||||
// Resubscribe to messages
|
||||
await subscribeToMessages()
|
||||
}
|
||||
|
||||
// Update visibility handler
|
||||
function setupVisibilityHandler() {
|
||||
const handleVisibilityChange = async () => {
|
||||
if (document.visibilityState === 'visible' && account.value) {
|
||||
console.log('Page became visible, checking connection...')
|
||||
try {
|
||||
// Only reconnect if we don't have active connections
|
||||
if (relayPool.value.length === 0 || !hasActiveSubscription.value) {
|
||||
await reconnectToRelays()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to reconnect:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any existing handler
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
// Add focus handler for mobile
|
||||
window.addEventListener('focus', handleVisibilityChange)
|
||||
}
|
||||
|
||||
// Add function to clear unread state
|
||||
function clearUnreadMessages() {
|
||||
hasUnreadMessages.value = false
|
||||
localStorage.setItem('nostr_unread_messages', 'false')
|
||||
|
||||
// Mark all current chat messages as viewed
|
||||
if (activeChat.value) {
|
||||
const chatMessages = messages.value.get(activeChat.value) || []
|
||||
chatMessages.forEach(msg => {
|
||||
viewedMessageIds.value.add(msg.id)
|
||||
})
|
||||
// Persist viewed message IDs
|
||||
localStorage.setItem('nostr_viewed_messages',
|
||||
JSON.stringify(Array.from(viewedMessageIds.value))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Add to watch section
|
||||
watch(activeChat, () => {
|
||||
// Clear unread messages when changing to a chat
|
||||
clearUnreadMessages()
|
||||
})
|
||||
|
||||
return {
|
||||
account,
|
||||
profiles,
|
||||
messages,
|
||||
activeChat,
|
||||
isLoggedIn,
|
||||
currentMessages,
|
||||
init,
|
||||
login,
|
||||
logout,
|
||||
sendMessage,
|
||||
subscribeToMessages,
|
||||
unsubscribeFromMessages,
|
||||
loadProfiles,
|
||||
connectionStatus,
|
||||
hasActiveSubscription,
|
||||
hasUnreadMessages,
|
||||
clearUnreadMessages,
|
||||
reconnectToRelays,
|
||||
}
|
||||
})
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
export interface DirectoryItem {
|
||||
id: string
|
||||
name: string
|
||||
category: 'restaurant' | 'lodging' | 'goods' | 'services' | 'tuktuk' | 'taxi' | 'lancha' | 'other'
|
||||
local?: boolean
|
||||
description?: string
|
||||
url?: string
|
||||
address?: string
|
||||
town?: string
|
||||
mapsUrl?: string
|
||||
contact?: string
|
||||
contactType?: ('whatsapp' | 'telegram')[]
|
||||
lightning?: string
|
||||
coordinates?: {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
imageUrl?: string
|
||||
social?: {
|
||||
facebook?: string
|
||||
instagram?: string
|
||||
twitter?: string
|
||||
youtube?: string
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
export interface NostrEvent {
|
||||
kind: number
|
||||
pubkey: string
|
||||
content: string
|
||||
tags: string[][]
|
||||
created_at: number
|
||||
id: string
|
||||
sig: string
|
||||
}
|
||||
|
||||
export interface NostrProfile {
|
||||
pubkey: string
|
||||
name?: string
|
||||
picture?: string
|
||||
about?: string
|
||||
nip05?: string
|
||||
}
|
||||
|
||||
export interface NostrRelayConfig {
|
||||
url: string
|
||||
read?: boolean
|
||||
write?: boolean
|
||||
}
|
||||
|
||||
export interface NostrAccount {
|
||||
pubkey: string
|
||||
privkey: string
|
||||
relays: NostrRelayConfig[]
|
||||
}
|
||||
|
||||
export interface DirectMessage {
|
||||
id: string
|
||||
pubkey: string
|
||||
content: string
|
||||
created_at: number
|
||||
sent: boolean
|
||||
fromStorage?: boolean
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue