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:
parent
f440379a89
commit
d899aff199
9 changed files with 173 additions and 251 deletions
44
src/App.vue
44
src/App.vue
|
|
@ -1,47 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
import HeroSection from './components/HeroSection.vue'
|
||||
import BtcmatCard from './components/BtcmatCard.vue'
|
||||
import AtmStatus from './components/AtmStatus.vue'
|
||||
import HeroSection from '@/components/HeroSection.vue'
|
||||
import InfoCard from '@/components/InfoCard.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative min-h-dvh flex items-center justify-center p-8 max-md:p-3 overflow-hidden">
|
||||
<!-- Background image -->
|
||||
<div class="relative min-h-dvh flex flex-col items-center justify-center overflow-hidden">
|
||||
<!-- Full-bleed background -->
|
||||
<img
|
||||
src="/atio_bg.webp"
|
||||
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 -->
|
||||
<div class="relative z-10 text-center animate-fade-in-up">
|
||||
<!-- Page content — vertically centered, generous whitespace -->
|
||||
<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 />
|
||||
|
||||
<div class="mt-12 max-md:mt-6 space-y-6 max-md:space-y-4 max-w-md mx-auto">
|
||||
<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>
|
||||
<InfoCard />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
84
src/components/AtmStatusList.vue
Normal file
84
src/components/AtmStatusList.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
26
src/components/ContactLinks.vue
Normal file
26
src/components/ContactLinks.vue
Normal 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>
|
||||
|
|
@ -1,17 +1,15 @@
|
|||
<template>
|
||||
<div>
|
||||
<header class="text-center space-y-5 max-md:space-y-3 animate-fade-in">
|
||||
<img
|
||||
src="/logo.png"
|
||||
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
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
Coming Soon
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
|
|
|||
16
src/components/InfoCard.vue
Normal file
16
src/components/InfoCard.vue
Normal 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>
|
||||
22
src/components/RatesSection.vue
Normal file
22
src/components/RatesSection.vue
Normal 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>
|
||||
|
|
@ -33,41 +33,21 @@
|
|||
--color-chart-3: oklch(var(--chart-3));
|
||||
--color-chart-4: oklch(var(--chart-4));
|
||||
--color-chart-5: oklch(var(--chart-5));
|
||||
|
||||
/* Bitcoin orange for branding */
|
||||
--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; }
|
||||
}
|
||||
--color-bitcoin: oklch(var(--bitcoin));
|
||||
--color-bitcoin-foreground: oklch(var(--bitcoin-foreground));
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
50% { transform: translateY(-8px); }
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
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-fade-in-up: fade-in-up 1s ease-out;
|
||||
--animate-glow: glow 2s ease-in-out infinite alternate;
|
||||
--animate-fade-in: fade-in 0.8s ease-out both;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
@ -97,7 +77,9 @@
|
|||
--chart-3: 0.85 0.20 90;
|
||||
--chart-4: 0.75 0.20 150;
|
||||
--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 {
|
||||
|
|
@ -126,6 +108,8 @@
|
|||
--chart-3: 0.88 0.18 95;
|
||||
--chart-4: 0.80 0.18 155;
|
||||
--chart-5: 0.70 0.18 190;
|
||||
--bitcoin: 0.75 0.18 55;
|
||||
--bitcoin-foreground: 1.0 0 0;
|
||||
}
|
||||
|
||||
*,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue