feat: init

This commit is contained in:
2026-02-13 22:02:30 +01:00
commit 8f9ff830fb
16711 changed files with 3307340 additions and 0 deletions

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
const { rates } = useVatRates()
const selectedCode = ref('DE')
const response = ref('')
const statusCode = ref<number | null>(null)
const isLoading = ref(false)
const showAllRates = ref(false)
const endpointUrl = computed(() =>
showAllRates.value
? 'https://vat-api.eu/api/v1/rates'
: `https://vat-api.eu/api/v1/rates/${selectedCode.value}`,
)
function syntaxHighlight(json: string): string {
return json
.replace(/("(?:\\.|[^"\\])*")\s*:/g, '<span style="color:#79c0ff">$1</span>:')
.replace(/:\s*("(?:\\.|[^"\\])*")/g, ': <span style="color:#a5d6ff">$1</span>')
.replace(/:\s*(\d+\.?\d*)/g, ': <span style="color:#79c0ff">$1</span>')
.replace(/(\[|\])/g, '<span style="color:#ff7b72">$1</span>')
.replace(/^(\{|})/gm, '<span style="color:#ff7b72">$1</span>')
}
async function sendRequest() {
isLoading.value = true
response.value = ''
statusCode.value = null
try {
const url = showAllRates.value
? '/api/v1/rates'
: `/api/v1/rates/${selectedCode.value}`
const data = await $fetch(url)
statusCode.value = 200
response.value = syntaxHighlight(JSON.stringify(data, null, 2))
} catch (err: any) {
statusCode.value = err?.statusCode ?? 500
const errorBody = err?.data ?? { error: err?.statusMessage ?? 'Request failed' }
response.value = syntaxHighlight(JSON.stringify(errorBody, null, 2))
} finally {
isLoading.value = false
}
}
// Auto-send on mount
onMounted(() => sendRequest())
</script>
<template>
<section id="playground" class="section-padding bg-surface-soft">
<div class="section-container">
<div class="text-center mb-10 animate-on-scroll">
<h2 class="text-title md:text-display-sm text-ink">
Try it out
</h2>
<p class="mt-3 text-ink-muted max-w-lg mx-auto">
Build your request, send it, and see the response.
</p>
</div>
<div class="animate-on-scroll max-w-3xl mx-auto">
<div class="bg-white rounded-2xl border border-surface-border shadow-card overflow-hidden">
<!-- Controls -->
<div class="p-5 border-b border-surface-border">
<div class="flex flex-wrap gap-3 items-end">
<!-- Endpoint toggle -->
<div class="flex-shrink-0">
<label class="block text-xs font-medium text-ink-muted mb-1.5">Endpoint</label>
<div class="flex rounded-lg border border-surface-border overflow-hidden text-sm">
<button
class="px-3 py-2 font-medium transition-colors"
:class="!showAllRates ? 'bg-eu-blue text-white' : 'bg-white text-ink-secondary hover:bg-surface-soft'"
@click="showAllRates = false"
>
Single
</button>
<button
class="px-3 py-2 font-medium transition-colors"
:class="showAllRates ? 'bg-eu-blue text-white' : 'bg-white text-ink-secondary hover:bg-surface-soft'"
@click="showAllRates = true"
>
All Rates
</button>
</div>
</div>
<!-- Country picker -->
<div v-if="!showAllRates" class="flex-shrink-0">
<label class="block text-xs font-medium text-ink-muted mb-1.5">Country</label>
<select
v-model="selectedCode"
class="px-3 py-2 rounded-lg border border-surface-border bg-white text-sm text-ink focus:outline-none focus:ring-2 focus:ring-eu-blue/20 focus:border-eu-blue/40 transition-all"
>
<option v-for="rate in rates" :key="rate.code" :value="rate.code">
{{ rate.flag }} {{ rate.country }} ({{ rate.code }})
</option>
</select>
</div>
<!-- Send button -->
<button
class="px-5 py-2 rounded-lg bg-eu-gold text-eu-blue-dark font-semibold text-sm hover:bg-eu-gold-dark transition-colors flex items-center gap-2"
@click="sendRequest"
:disabled="isLoading"
>
<svg v-if="isLoading" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span>{{ isLoading ? 'Sending...' : 'Send Request' }}</span>
</button>
</div>
<!-- URL display -->
<div class="mt-3.5 flex items-center gap-2 px-3.5 py-2.5 rounded-lg bg-surface-muted font-mono text-sm overflow-x-auto">
<span class="flex-shrink-0 text-xs font-semibold px-1.5 py-0.5 rounded bg-green-100 text-green-700">GET</span>
<span class="text-ink-secondary">{{ endpointUrl }}</span>
</div>
</div>
<!-- Response -->
<div class="relative">
<div class="flex items-center justify-between px-5 py-3 bg-[#0d1117] border-b border-white/5">
<span class="text-xs text-[#8b949e] font-mono">Response</span>
<span v-if="statusCode === 200" class="text-xs font-mono px-2 py-0.5 rounded bg-green-500/20 text-green-400">200 OK</span>
<span v-else-if="statusCode" class="text-xs font-mono px-2 py-0.5 rounded bg-red-500/20 text-red-400">{{ statusCode }} Error</span>
</div>
<div class="bg-[#0d1117] text-[#e6edf3] font-mono text-sm leading-relaxed p-5 max-h-80 overflow-y-auto">
<pre v-if="response" class="text-[13px] leading-6" v-html="response" />
<p v-else-if="isLoading" class="text-[#8b949e] text-sm">Loading...</p>
<p v-else class="text-[#8b949e] text-sm">Click "Send Request" to see a response.</p>
</div>
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
const tabs = ['cURL', 'JavaScript', 'Python'] as const
type Tab = typeof tabs[number]
const activeTab = ref<Tab>('cURL')
const code: Record<Tab, string> = {
cURL: `# Get all EU VAT rates
curl https://vat-api.eu/api/v1/rates
# Get rates for a specific country
curl https://vat-api.eu/api/v1/rates/DE`,
JavaScript: `const response = await fetch('https://vat-api.eu/api/v1/rates/DE');
const data = await response.json();
console.log(data.standard_rate); // 19`,
Python: `import requests
response = requests.get('https://vat-api.eu/api/v1/rates/DE')
data = response.json()
print(data['standard_rate']) # 19`,
}
const highlighted: Record<Tab, string> = {
cURL: highlightBash(code.cURL),
JavaScript: highlightJS(code.JavaScript),
Python: highlightPython(code.Python),
}
function highlightBash(src: string): string {
return src
.replace(/(#.*)/g, '<span style="color:#8b949e">$1</span>')
.replace(/(curl)\s/g, '<span style="color:#ff7b72">$1</span> ')
.replace(/(https?:\/\/[^\s]+)/g, '<span style="color:#a5d6ff">$1</span>')
}
function highlightJS(src: string): string {
return src
.replace(/(\/\/\s*\d+)/g, '<span style="color:#8b949e">$1</span>')
.replace(/(const|await)\s/g, '<span style="color:#ff7b72">$1</span> ')
.replace(/(fetch|json|log)\(/g, '<span style="color:#d2a8ff">$1</span>(')
.replace(/('https?:\/\/[^']*')/g, '<span style="color:#a5d6ff">$1</span>')
.replace(/(\.standard_rate)/g, '<span style="color:#79c0ff">$1</span>')
.replace(/(console)\./g, '<span style="color:#79c0ff">$1</span>.')
.replace(/(response)\./g, '<span style="color:#79c0ff">$1</span>.')
}
function highlightPython(src: string): string {
return src
.replace(/(#\s*\d+)/g, '<span style="color:#8b949e">$1</span>')
.replace(/(import|from)\s/g, '<span style="color:#ff7b72">$1</span> ')
.replace(/(requests)/g, '<span style="color:#79c0ff">$1</span>')
.replace(/('https?:\/\/[^']*')/g, '<span style="color:#a5d6ff">$1</span>')
.replace(/(\['standard_rate'\])/g, '<span style="color:#79c0ff">$1</span>')
.replace(/(print|get)\(/g, '<span style="color:#d2a8ff">$1</span>(')
.replace(/(response)\./g, '<span style="color:#79c0ff">$1</span>.')
}
const copied = ref(false)
async function copyCode() {
await navigator.clipboard.writeText(code[activeTab.value])
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
</script>
<template>
<section id="examples" class="section-padding">
<div class="section-container">
<div class="text-center mb-10 animate-on-scroll">
<h2 class="text-title md:text-display-sm text-ink">
Quick start
</h2>
<p class="mt-3 text-ink-muted max-w-lg mx-auto">
Integrate EU VAT rates into your project in seconds.
</p>
</div>
<div class="animate-on-scroll max-w-2xl mx-auto">
<div class="rounded-2xl border border-surface-border overflow-hidden shadow-card bg-white">
<!-- Tab bar -->
<div class="flex items-center justify-between border-b border-surface-border bg-surface-soft px-1.5">
<div class="flex">
<button
v-for="tab in tabs"
:key="tab"
class="px-4 py-3 text-sm font-medium transition-colors relative"
:class="activeTab === tab
? 'text-eu-blue'
: 'text-ink-muted hover:text-ink-secondary'"
@click="activeTab = tab"
>
{{ tab }}
<span
v-if="activeTab === tab"
class="absolute bottom-0 left-2 right-2 h-0.5 bg-eu-blue rounded-full"
/>
</button>
</div>
<!-- Copy button -->
<button
class="flex items-center gap-1.5 px-3 py-1.5 mr-1.5 rounded-lg text-xs font-medium text-ink-muted hover:text-ink-secondary hover:bg-surface-muted transition-colors"
@click="copyCode"
>
<svg v-if="!copied" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184" />
</svg>
<svg v-else class="w-3.5 h-3.5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
{{ copied ? 'Copied!' : 'Copy' }}
</button>
</div>
<!-- Code -->
<div class="code-block p-5">
<pre class="text-[13px] leading-6" v-html="highlighted[activeTab]" />
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
const features = [
{
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-6 h-6"><circle cx="12" cy="12" r="10"/><path d="M12 6v2m0 8v2m-4.24-2.76 1.42-1.42m5.64-5.64 1.42-1.42M6 12h2m8 0h2m-2.76 4.24-1.42-1.42m-5.64-5.64L7.76 7.76"/></svg>`,
title: 'All 27 EU Countries',
description: 'Standard, reduced, and special VAT rates for every member state.',
},
{
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"/></svg>`,
title: 'No Authentication',
description: 'No API keys, no sign-up. Just send a GET request.',
},
{
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182"/></svg>`,
title: 'Always Current',
description: 'Rates sourced and synced regularly from official EU data.',
},
{
icon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5"/></svg>`,
title: 'Simple JSON',
description: 'Clean, predictable JSON responses. Easy to integrate anywhere.',
},
]
</script>
<template>
<section id="features" class="section-padding bg-surface-soft">
<div class="section-container">
<div class="text-center mb-14 animate-on-scroll">
<h2 class="text-title md:text-display-sm text-ink">
Built for developers
</h2>
<p class="mt-3 text-ink-muted max-w-lg mx-auto">
A straightforward API that does one thing well delivering EU VAT rates without friction.
</p>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-5">
<div
v-for="(feature, i) in features"
:key="feature.title"
class="animate-on-scroll group relative bg-white rounded-2xl p-6 border border-surface-border hover:border-eu-blue/20 hover:shadow-card transition-all duration-300"
:style="{ transitionDelay: `${i * 80}ms` }"
>
<div class="w-10 h-10 rounded-xl bg-eu-blue-100 text-eu-blue flex items-center justify-center mb-4 group-hover:bg-eu-blue group-hover:text-white transition-colors duration-300" v-html="feature.icon" />
<h3 class="font-heading font-semibold text-ink mb-1.5">
{{ feature.title }}
</h3>
<p class="text-sm text-ink-muted leading-relaxed">
{{ feature.description }}
</p>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,33 @@
<template>
<footer class="border-t border-surface-border bg-white">
<div class="section-container py-10">
<div class="flex flex-col md:flex-row items-center justify-between gap-6 text-sm text-ink-muted">
<!-- Left -->
<div class="flex items-center gap-2">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-md bg-eu-blue text-white font-heading font-bold text-[10px]">V</span>
<span class="font-heading font-semibold text-ink">
vat-api<span class="text-eu-blue">.eu</span>
</span>
<span class="hidden sm:inline text-ink-faint">&mdash; Free EU VAT Rate API</span>
</div>
<!-- Center -->
<p class="text-ink-faint text-xs">
Free EU VAT rate data
</p>
<!-- Right -->
<div class="flex items-center gap-5">
<a href="#" class="hover:text-eu-blue transition-colors">Imprint</a>
<a href="#" class="hover:text-eu-blue transition-colors">Privacy</a>
<a href="#" class="hover:text-eu-blue transition-colors flex items-center gap-1.5">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
GitHub
</a>
</div>
</div>
</div>
</footer>
</template>

View File

@@ -0,0 +1,79 @@
<template>
<section class="relative pt-32 pb-20 md:pt-40 md:pb-28 overflow-hidden">
<!-- Background pattern -->
<div class="absolute inset-0 dot-grid opacity-[0.03]" />
<div class="absolute top-0 right-0 w-[600px] h-[600px] bg-eu-blue/[0.04] rounded-full blur-3xl -translate-y-1/2 translate-x-1/4" />
<div class="section-container relative">
<div class="grid lg:grid-cols-2 gap-12 lg:gap-16 items-center">
<!-- Left: copy -->
<div>
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-eu-blue-100 text-eu-blue text-xs font-semibold tracking-wide uppercase mb-6">
<span class="w-1.5 h-1.5 rounded-full bg-eu-blue animate-pulse" />
Free &amp; Open
</div>
<h1 class="text-display-sm md:text-display text-ink text-balance">
EU VAT Rates
<span class="text-eu-blue">API</span>
</h1>
<p class="mt-4 text-subtitle text-ink-secondary font-heading">
Free. No API key. Always up-to-date.
</p>
<p class="mt-4 text-base text-ink-muted leading-relaxed max-w-lg">
Get current VAT rates for all 27 EU member states with a single API call. No registration, no rate limits, no nonsense.
</p>
<div class="flex flex-wrap gap-3 mt-8">
<a
href="#rates"
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-eu-blue text-white font-semibold text-sm hover:bg-eu-blue-dark transition-all hover:shadow-glow"
>
View Rates
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 13.5 12 21m0 0-7.5-7.5M12 21V3" />
</svg>
</a>
<a
href="#playground"
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg border-2 border-eu-blue/20 text-eu-blue font-semibold text-sm hover:border-eu-blue/40 hover:bg-eu-blue-100 transition-all"
>
Try the API
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
</a>
</div>
</div>
<!-- Right: code snippet -->
<div class="relative">
<div class="absolute -inset-4 bg-eu-blue/[0.04] rounded-3xl blur-2xl" />
<div class="relative code-block p-6 shadow-card">
<!-- Top bar -->
<div class="flex items-center gap-3 mb-4 pb-4 border-b border-white/10">
<div class="flex gap-1.5">
<span class="w-3 h-3 rounded-full bg-[#ff5f57]" />
<span class="w-3 h-3 rounded-full bg-[#febc2e]" />
<span class="w-3 h-3 rounded-full bg-[#28c840]" />
</div>
<span class="text-xs text-[#8b949e] font-mono">GET /api/v1/rates/DE</span>
</div>
<!-- JSON -->
<pre class="text-[13px] leading-6"><span style="color:#8b949e">// Response 200 OK</span>
<span style="color:#ff7b72">{</span>
<span style="color:#79c0ff">"country"</span>: <span style="color:#a5d6ff">"Germany"</span>,
<span style="color:#79c0ff">"country_code"</span>: <span style="color:#a5d6ff">"DE"</span>,
<span style="color:#79c0ff">"standard_rate"</span>: <span style="color:#79c0ff">19</span>,
<span style="color:#79c0ff">"reduced_rates"</span>: <span style="color:#ff7b72">[</span><span style="color:#79c0ff">7</span><span style="color:#ff7b72">]</span>,
<span style="color:#79c0ff">"currency"</span>: <span style="color:#a5d6ff">"EUR"</span>
<span style="color:#ff7b72">}</span></pre>
</div>
</div>
</div>
</div>
</section>
</template>

106
app/components/Navbar.vue Normal file
View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
const scrolled = ref(false)
function onScroll() {
scrolled.value = window.scrollY > 20
}
onMounted(() => {
window.addEventListener('scroll', onScroll, { passive: true })
})
onUnmounted(() => {
window.removeEventListener('scroll', onScroll)
})
const links = [
{ label: 'Features', href: '#features' },
{ label: 'Rates', href: '#rates' },
{ label: 'API Docs', href: '#playground' },
]
const mobileOpen = ref(false)
</script>
<template>
<nav
class="fixed top-0 left-0 right-0 z-50 transition-all duration-300"
:class="scrolled
? 'bg-white/80 backdrop-blur-xl shadow-soft border-b border-surface-border'
: 'bg-transparent'"
>
<div class="section-container flex items-center justify-between h-16">
<!-- Logo -->
<a href="#" class="flex items-center gap-2 group">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-eu-blue text-white font-heading font-bold text-sm transition-transform group-hover:scale-105">
V
</span>
<span class="font-heading font-bold text-lg text-ink">
vat-api<span class="text-eu-blue">.eu</span>
</span>
</a>
<!-- Desktop links -->
<div class="hidden md:flex items-center gap-8">
<a
v-for="link in links"
:key="link.href"
:href="link.href"
class="text-sm font-medium text-ink-secondary hover:text-eu-blue transition-colors"
>
{{ link.label }}
</a>
<a
href="#playground"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-eu-blue text-white text-sm font-semibold hover:bg-eu-blue-dark transition-colors"
>
Try API
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</a>
</div>
<!-- Mobile toggle -->
<button
class="md:hidden p-2 -mr-2 text-ink-secondary hover:text-ink"
@click="mobileOpen = !mobileOpen"
:aria-label="mobileOpen ? 'Close menu' : 'Open menu'"
>
<svg v-if="!mobileOpen" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg v-else class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Mobile menu -->
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div
v-if="mobileOpen"
class="md:hidden bg-white/95 backdrop-blur-xl border-b border-surface-border"
>
<div class="section-container py-4 flex flex-col gap-3">
<a
v-for="link in links"
:key="link.href"
:href="link.href"
class="text-sm font-medium text-ink-secondary hover:text-eu-blue py-2 transition-colors"
@click="mobileOpen = false"
>
{{ link.label }}
</a>
</div>
</div>
</Transition>
</nav>
</template>

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
interface RateRow {
country: string
code: string
flag: string
standard: number
reduced: number[]
}
function codeToFlag(code: string): string {
return [...code.toUpperCase()].map(c => String.fromCodePoint(0x1F1E6 + c.charCodeAt(0) - 65)).join('')
}
const rates = ref<RateRow[]>([])
onMounted(async () => {
try {
const data = await $fetch<any[]>('/api/v1/rates')
rates.value = data.map(r => ({
country: r.country,
code: r.country_code,
flag: codeToFlag(r.country_code),
standard: r.standard_rate,
reduced: r.reduced_rates,
}))
} catch {
// Fallback to composable data if API fails
const { rates: fallback } = useVatRates()
rates.value = fallback.value
}
})
const search = ref('')
const filtered = computed(() => {
const q = search.value.toLowerCase().trim()
if (!q) return rates.value
return rates.value.filter(
r => r.country.toLowerCase().includes(q) || r.code.toLowerCase().includes(q),
)
})
</script>
<template>
<section id="rates" class="section-padding">
<div class="section-container">
<div class="text-center mb-10 animate-on-scroll">
<h2 class="text-title md:text-display-sm text-ink">
VAT Rates across the EU
</h2>
<p class="mt-3 text-ink-muted max-w-lg mx-auto">
Current standard and reduced rates for all 27 member states.
</p>
</div>
<!-- Search -->
<div class="max-w-sm mx-auto mb-8 animate-on-scroll">
<div class="relative">
<svg class="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-ink-faint" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
<input
v-model="search"
type="text"
placeholder="Search countries..."
class="w-full pl-10 pr-4 py-2.5 rounded-xl border border-surface-border bg-white text-sm text-ink placeholder:text-ink-faint focus:outline-none focus:ring-2 focus:ring-eu-blue/20 focus:border-eu-blue/40 transition-all"
/>
</div>
</div>
<!-- Table -->
<div class="animate-on-scroll overflow-hidden rounded-2xl border border-surface-border bg-white shadow-soft">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-surface-soft border-b border-surface-border">
<th class="text-left font-semibold text-ink-secondary px-5 py-3.5 font-heading">Country</th>
<th class="text-left font-semibold text-ink-secondary px-5 py-3.5 font-heading w-20">Code</th>
<th class="text-right font-semibold text-ink-secondary px-5 py-3.5 font-heading w-32">Standard</th>
<th class="text-right font-semibold text-ink-secondary px-5 py-3.5 font-heading w-40">Reduced</th>
</tr>
</thead>
<tbody>
<tr
v-for="rate in filtered"
:key="rate.code"
class="border-b border-surface-border/60 last:border-0 hover:bg-eu-blue-100/40 transition-colors"
>
<td class="px-5 py-3">
<span class="inline-flex items-center gap-2.5">
<span class="text-lg leading-none">{{ rate.flag }}</span>
<span class="font-medium text-ink">{{ rate.country }}</span>
</span>
</td>
<td class="px-5 py-3">
<span class="inline-flex px-2 py-0.5 rounded bg-surface-muted font-mono text-xs font-medium text-ink-secondary">
{{ rate.code }}
</span>
</td>
<td class="px-5 py-3 text-right">
<span class="font-semibold text-ink tabular-nums">{{ rate.standard }}%</span>
</td>
<td class="px-5 py-3 text-right">
<span v-if="rate.reduced.length" class="text-ink-muted tabular-nums">
{{ rate.reduced.map(r => `${r}%`).join(', ') }}
</span>
<span v-else class="text-ink-faint">&mdash;</span>
</td>
</tr>
<tr v-if="!filtered.length">
<td colspan="4" class="px-5 py-10 text-center text-ink-muted">
No countries matching "{{ search }}"
</td>
</tr>
</tbody>
</table>
</div>
</div>
<p class="text-center text-xs text-ink-faint mt-4">
Data updated regularly from official EU sources.
</p>
</div>
</section>
</template>