bare repo

This commit is contained in:
padreug 2025-03-09 12:28:49 +01:00
parent d73f9bc01e
commit 3d356225cd
31 changed files with 134 additions and 3005 deletions

View file

@ -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>

View file

@ -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);
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 }

View file

@ -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
}
}

View file

@ -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'
}
}
]

View file

@ -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)
}
}

View file

@ -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'
}

View file

@ -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)
}
}
}

View file

@ -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]);
}
};
}
};

View file

@ -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()
}

View file

@ -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 []
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}
}

View file

@ -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()

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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
}
})

View file

@ -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,
}
})

View file

@ -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
}
}

View file

@ -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
}