feat: redesign landing page with shadcn-vue components

Clean component architecture with proper spacing and visual hierarchy:
- HeroSection: logo, gradient heading, subtitle with generous whitespace
- InfoCard: single unified Card with divide-y sections
- RatesSection: side-by-side cash-in/out with vertical separator
- AtmStatusList: live Nostr status with Badge and Skeleton loading
- ContactLinks: lucide Send/Mail icons with ghost Button links
- Catppuccin theme with bitcoin as a proper semantic color token

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-04-01 17:08:47 -04:00
parent f440379a89
commit d899aff199
9 changed files with 173 additions and 251 deletions

View file

@ -1,47 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import HeroSection from './components/HeroSection.vue' import HeroSection from '@/components/HeroSection.vue'
import BtcmatCard from './components/BtcmatCard.vue' import InfoCard from '@/components/InfoCard.vue'
import AtmStatus from './components/AtmStatus.vue'
</script> </script>
<template> <template>
<div class="relative min-h-dvh flex items-center justify-center p-8 max-md:p-3 overflow-hidden"> <div class="relative min-h-dvh flex flex-col items-center justify-center overflow-hidden">
<!-- Background image --> <!-- Full-bleed background -->
<img <img
src="/atio_bg.webp" src="/atio_bg.webp"
alt="" alt=""
class="fixed inset-0 w-full h-full object-cover opacity-15 pointer-events-none" class="fixed inset-0 w-full h-full object-cover opacity-10 pointer-events-none select-none"
/> />
<!-- Content --> <!-- Page content vertically centered, generous whitespace -->
<div class="relative z-10 text-center animate-fade-in-up"> <main class="relative z-10 w-full max-w-xl px-6 py-16 max-md:px-4 max-md:py-10 space-y-12 max-md:space-y-8">
<HeroSection /> <HeroSection />
<InfoCard />
<div class="mt-12 max-md:mt-6 space-y-6 max-md:space-y-4 max-w-md mx-auto"> </main>
<BtcmatCard />
<AtmStatus />
<!-- Contact -->
<div class="rounded-xl bg-card/50 backdrop-blur-md border border-border/50 p-6 max-md:p-4">
<p class="text-muted-foreground mb-3">Contact:</p>
<div class="space-y-2">
<a
href="https://t.me/atitlanio"
target="_blank"
rel="noopener"
class="flex items-center justify-center gap-2 text-bitcoin rounded-lg bg-muted/50 px-3 py-2 transition-shadow hover:shadow-lg hover:shadow-bitcoin/10"
>
<span></span> Telegram @atitlanio
</a>
<a
href="mailto:atitlanio@protonmail.com"
class="flex items-center justify-center gap-2 text-bitcoin rounded-lg bg-muted/50 px-3 py-2 transition-shadow hover:shadow-lg hover:shadow-bitcoin/10"
>
<span></span> atitlanio@protonmail.com
</a>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>

View file

@ -1,165 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const RELAY = 'wss://strfry.atitlan.io'
const OFFLINE_THRESHOLD = 10 * 60 // 10 minutes
interface Atm {
pubkey: string
name: string
location: string
online: boolean
maintenance: boolean
cashIn: boolean
cashOut: boolean
cashLevel: 'none' | 'low' | 'good' | 'full'
lastSeen: number
}
const atms = ref<Record<string, Atm>>({
douro: {
pubkey: 'b22bd8a7759fa32d57f0061935a5af38d8598d11bbb700c6dec0d352bd0b6ade',
name: 'Bitcoinmat',
location: 'Trece Cielos',
online: false,
maintenance: false,
cashIn: false,
cashOut: false,
cashLevel: 'none',
lastSeen: 0,
},
})
let ws: WebSocket | null = null
let refreshInterval: ReturnType<typeof setInterval> | null = null
const now = ref(Math.floor(Date.now() / 1000))
function formatAgo(seconds: number): string {
if (seconds < 60) return 'just now'
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago'
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago'
return Math.floor(seconds / 86400) + 'd ago'
}
function isOnline(atm: Atm): boolean {
return atm.lastSeen > 0 && (now.value - atm.lastSeen) < OFFLINE_THRESHOLD
}
function statusClass(atm: Atm): string {
if (atm.maintenance) return 'bg-chart-3 shadow-[0_0_6px_oklch(var(--chart-3)/0.4)]'
if (isOnline(atm)) return 'bg-chart-4 shadow-[0_0_6px_oklch(var(--chart-4)/0.4)]'
return 'bg-destructive shadow-[0_0_6px_oklch(var(--destructive)/0.4)]'
}
function statusText(atm: Atm): string {
if (atm.maintenance) return 'under service'
if (isOnline(atm)) return formatAgo(now.value - atm.lastSeen)
return 'offline'
}
const levelLabels: Record<string, string> = {
none: 'No cash',
low: 'Low cash',
good: 'Cash available',
full: 'Fully stocked',
}
function levelClass(level: string, online: boolean): string {
if (!online) return 'bg-muted/50 text-muted-foreground'
if (level === 'good' || level === 'full') return 'bg-chart-4/15 text-chart-4'
if (level === 'low') return 'bg-chart-3/15 text-chart-3'
return 'bg-muted/50 text-muted-foreground'
}
function connectRelay() {
ws = new WebSocket(RELAY)
ws.onopen = () => {
const authors = Object.values(atms.value).map((a) => a.pubkey)
ws!.send(JSON.stringify(['REQ', 'atm-status', { kinds: [30078], authors, '#d': ['atm-availability'] }]))
}
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data)
if (data[0] !== 'EVENT') return
const event = data[2]
const content = JSON.parse(event.content)
for (const [id, atm] of Object.entries(atms.value)) {
if (atm.pubkey === event.pubkey) {
atms.value[id] = {
...atm,
cashIn: content.cash_in || false,
cashOut: content.cash_out || false,
cashLevel: content.cash_level || 'none',
maintenance: content.maintenance || false,
online: true,
lastSeen: event.created_at,
}
break
}
}
} catch {
// ignore parse errors
}
}
ws.onclose = () => {
setTimeout(connectRelay, 5000)
}
ws.onerror = () => {
ws?.close()
}
}
onMounted(() => {
connectRelay()
refreshInterval = setInterval(() => {
now.value = Math.floor(Date.now() / 1000)
}, 30000)
})
onUnmounted(() => {
ws?.close()
ws = null
if (refreshInterval) clearInterval(refreshInterval)
})
</script>
<template>
<div class="rounded-xl bg-foreground/5 backdrop-blur-md border border-foreground/10 p-5 max-md:p-4">
<h3 class="mb-3 text-sm uppercase tracking-wider text-muted-foreground">
ATM Status
</h3>
<div v-for="(atm, id) in atms" :key="id" class="flex items-center gap-3 py-2">
<div class="w-2.5 h-2.5 rounded-full shrink-0" :class="statusClass(atm)" />
<div class="flex-1 text-left">
<span class="font-semibold text-sm text-foreground/90">{{ atm.name }}</span>
<span class="block text-xs text-foreground/30">{{ atm.location }}</span>
</div>
<div class="flex gap-1.5 text-[0.7rem]">
<template v-if="atm.maintenance">
<span class="px-1.5 py-0.5 rounded bg-chart-3/15 text-chart-3">Maintenance</span>
</template>
<template v-else>
<span
class="px-1.5 py-0.5 rounded"
:class="atm.cashIn ? 'bg-chart-4/15 text-chart-4' : 'bg-muted/50 text-muted-foreground'"
>
Buy
</span>
<span
class="px-1.5 py-0.5 rounded"
:class="levelClass(atm.cashLevel, isOnline(atm))"
>
{{ isOnline(atm) ? (levelLabels[atm.cashLevel] || 'Sell ₿') : 'Sell ₿' }}
</span>
</template>
</div>
<span class="text-xs text-foreground/40 ml-auto">{{ statusText(atm) }}</span>
</div>
</div>
</template>

View file

@ -0,0 +1,84 @@
<script setup lang="ts">
import { useNostrAtmStatus } from '@/composables/useNostrAtmStatus'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
const { atms, loading, isOnline, statusText } = useNostrAtmStatus()
const cashLabels: Record<string, string> = {
none: 'No cash',
low: 'Low cash',
good: 'Cash OK',
full: 'Stocked',
}
</script>
<template>
<section class="space-y-4">
<h3 class="text-xs font-medium uppercase tracking-widest text-muted-foreground text-center">
ATM Status
</h3>
<!-- Loading skeleton -->
<div v-if="loading" class="space-y-3">
<div v-for="i in atms.size" :key="i" class="flex items-center gap-3">
<Skeleton class="size-2 rounded-full" />
<Skeleton class="h-4 w-28" />
<Skeleton class="ml-auto h-5 w-20" />
</div>
</div>
<!-- ATM rows -->
<div v-else class="space-y-3">
<div
v-for="[id, atm] in atms"
:key="id"
class="flex items-center gap-3"
>
<!-- Status dot -->
<span
class="size-2 rounded-full shrink-0"
:class="{
'bg-chart-3': atm.maintenance,
'bg-chart-4': !atm.maintenance && isOnline(atm),
'bg-destructive': !atm.maintenance && !isOnline(atm),
}"
/>
<!-- Machine info -->
<div class="min-w-0">
<span class="text-sm font-medium text-card-foreground">{{ atm.config.name }}</span>
<span class="text-muted-foreground text-xs ml-1.5">{{ atm.config.location }}</span>
</div>
<!-- Service badges -->
<div class="ml-auto flex items-center gap-1.5">
<Badge v-if="atm.maintenance" variant="secondary" class="text-chart-3 text-xs">
Maintenance
</Badge>
<template v-else>
<Badge
variant="secondary"
class="text-xs"
:class="atm.cashIn ? 'text-chart-4' : 'text-muted-foreground'"
>
Buy
</Badge>
<Badge
variant="secondary"
class="text-xs"
:class="{
'text-chart-4': isOnline(atm) && (atm.cashLevel === 'good' || atm.cashLevel === 'full'),
'text-chart-3': isOnline(atm) && atm.cashLevel === 'low',
'text-muted-foreground': !isOnline(atm) || atm.cashLevel === 'none',
}"
>
{{ isOnline(atm) ? (cashLabels[atm.cashLevel] || 'Sell ₿') : 'Sell ₿' }}
</Badge>
</template>
<span class="text-xs text-muted-foreground ml-1">{{ statusText(atm) }}</span>
</div>
</div>
</div>
</section>
</template>

View file

@ -1,17 +0,0 @@
<template>
<div
class="rounded-xl bg-card/50 backdrop-blur-md border border-border/50 p-8 max-md:p-4 transition-all duration-300 hover:-translate-y-1 hover:shadow-lg hover:shadow-primary/10 hover:border-primary/30"
>
<h2 class="text-3xl max-md:text-xl font-normal mb-6 max-md:mb-3 text-bitcoin">
Bitcoinmat
</h2>
<div class="space-y-2 text-lg max-md:text-sm">
<p class="text-muted-foreground">
Cash-in rate: <strong class="text-bitcoin">3%</strong>
</p>
<p class="text-muted-foreground">
Cash-out rate: <strong class="text-bitcoin">8.75%</strong>
</p>
</div>
</div>
</template>

View file

@ -0,0 +1,26 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Send, Mail } from 'lucide-vue-next'
</script>
<template>
<section class="space-y-3">
<h3 class="text-xs font-medium uppercase tracking-widest text-muted-foreground text-center">
Contact
</h3>
<div class="flex flex-col gap-2">
<Button variant="ghost" class="w-full justify-center gap-2 text-bitcoin hover:text-bitcoin hover:bg-secondary" as-child>
<a href="https://t.me/atitlanio" target="_blank" rel="noopener">
<Send class="size-4" />
<span>Telegram @atitlanio</span>
</a>
</Button>
<Button variant="ghost" class="w-full justify-center gap-2 text-bitcoin hover:text-bitcoin hover:bg-secondary" as-child>
<a href="mailto:atitlanio@protonmail.com">
<Mail class="size-4" />
<span>atitlanio@protonmail.com</span>
</a>
</Button>
</div>
</section>
</template>

View file

@ -1,17 +1,15 @@
<template> <template>
<div> <header class="text-center space-y-5 max-md:space-y-3 animate-fade-in">
<img <img
src="/logo.png" src="/logo.png"
alt="Atitlan.io" alt="Atitlan.io"
class="w-36 max-md:w-20 mx-auto mb-6 max-md:mb-2 drop-shadow-[0_4px_20px_oklch(0.65_0.20_180/0.3)] animate-float" class="w-32 max-md:w-20 mx-auto drop-shadow-lg animate-float"
/> />
<h1 <h1 class="text-7xl max-md:text-4xl font-extralight tracking-tight leading-none pb-1 bg-gradient-to-r from-foreground to-bitcoin bg-clip-text text-transparent">
class="text-6xl max-md:text-3xl font-light mb-4 max-md:mb-2 bg-gradient-to-r from-foreground to-bitcoin bg-clip-text text-transparent animate-glow"
>
Coming Soon Coming Soon
</h1> </h1>
<p class="text-2xl max-md:text-base font-extralight tracking-widest text-primary"> <p class="text-xl max-md:text-base font-light tracking-[0.2em] text-primary">
Atitlan.io Atitlan.io
</p> </p>
</div> </header>
</template> </template>

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import { Card, CardContent } from '@/components/ui/card'
import RatesSection from './RatesSection.vue'
import AtmStatusList from './AtmStatusList.vue'
import ContactLinks from './ContactLinks.vue'
</script>
<template>
<Card class="animate-fade-in [animation-delay:200ms] backdrop-blur-lg bg-card/40 border-border/40 shadow-xl">
<CardContent class="p-0 divide-y divide-border/40">
<RatesSection class="px-8 py-7 max-md:px-5 max-md:py-5" />
<AtmStatusList class="px-8 py-7 max-md:px-5 max-md:py-5" />
<ContactLinks class="px-8 py-7 max-md:px-5 max-md:py-5" />
</CardContent>
</Card>
</template>

View file

@ -0,0 +1,22 @@
<template>
<section class="text-center space-y-4">
<h2 class="text-2xl max-md:text-lg font-medium text-bitcoin">
Bitcoinmat
</h2>
<div class="flex justify-center gap-8 max-md:gap-4 text-sm max-md:text-xs">
<div>
<p class="text-muted-foreground">Cash-in</p>
<p class="text-lg max-md:text-base font-semibold text-bitcoin">3%</p>
</div>
<Separator orientation="vertical" class="h-auto" />
<div>
<p class="text-muted-foreground">Cash-out</p>
<p class="text-lg max-md:text-base font-semibold text-bitcoin">8.75%</p>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { Separator } from '@/components/ui/separator'
</script>

View file

@ -33,41 +33,21 @@
--color-chart-3: oklch(var(--chart-3)); --color-chart-3: oklch(var(--chart-3));
--color-chart-4: oklch(var(--chart-4)); --color-chart-4: oklch(var(--chart-4));
--color-chart-5: oklch(var(--chart-5)); --color-chart-5: oklch(var(--chart-5));
--color-bitcoin: oklch(var(--bitcoin));
/* Bitcoin orange for branding */ --color-bitcoin-foreground: oklch(var(--bitcoin-foreground));
--color-bitcoin: oklch(0.75 0.18 55);
--color-bitcoin-foreground: oklch(1.0 0 0);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
@keyframes accordion-down {
from { height: 0; }
to { height: var(--reka-accordion-content-height); }
}
@keyframes accordion-up {
from { height: var(--reka-accordion-content-height); }
to { height: 0; }
}
@keyframes float { @keyframes float {
0%, 100% { transform: translateY(0); } 0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); } 50% { transform: translateY(-8px); }
} }
@keyframes fade-in-up { @keyframes fade-in {
from { opacity: 0; transform: translateY(20px); } from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
@keyframes glow {
0% { text-shadow: 0 0 20px oklch(0.75 0.18 55 / 0.3); }
100% { text-shadow: 0 0 30px oklch(0.75 0.18 55 / 0.6); }
}
--animate-float: float 4s ease-in-out infinite; --animate-float: float 4s ease-in-out infinite;
--animate-fade-in-up: fade-in-up 1s ease-out; --animate-fade-in: fade-in 0.8s ease-out both;
--animate-glow: glow 2s ease-in-out infinite alternate;
} }
@layer base { @layer base {
@ -97,7 +77,9 @@
--chart-3: 0.85 0.20 90; --chart-3: 0.85 0.20 90;
--chart-4: 0.75 0.20 150; --chart-4: 0.75 0.20 150;
--chart-5: 0.65 0.20 180; --chart-5: 0.65 0.20 180;
--radius: 0.5rem; --bitcoin: 0.75 0.18 55;
--bitcoin-foreground: 1.0 0 0;
--radius: 0.625rem;
} }
.dark { .dark {
@ -126,6 +108,8 @@
--chart-3: 0.88 0.18 95; --chart-3: 0.88 0.18 95;
--chart-4: 0.80 0.18 155; --chart-4: 0.80 0.18 155;
--chart-5: 0.70 0.18 190; --chart-5: 0.70 0.18 190;
--bitcoin: 0.75 0.18 55;
--bitcoin-foreground: 1.0 0 0;
} }
*, *,