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