feat: init
This commit is contained in:
139
app/components/ApiPlayground.vue
Normal file
139
app/components/ApiPlayground.vue
Normal 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>
|
||||
124
app/components/CodeExamples.vue
Normal file
124
app/components/CodeExamples.vue
Normal 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>
|
||||
56
app/components/FeaturesGrid.vue
Normal file
56
app/components/FeaturesGrid.vue
Normal 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>
|
||||
33
app/components/FooterSection.vue
Normal file
33
app/components/FooterSection.vue
Normal 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">— 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>
|
||||
79
app/components/HeroSection.vue
Normal file
79
app/components/HeroSection.vue
Normal 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 & 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
106
app/components/Navbar.vue
Normal 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>
|
||||
125
app/components/VatRateTable.vue
Normal file
125
app/components/VatRateTable.vue
Normal 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">—</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>
|
||||
Reference in New Issue
Block a user