feat(ui): install shadcn Card; sweep card patterns site-wide

Add the shadcn-vue Card primitive (Card / CardHeader / CardTitle /
CardDescription / CardContent / CardFooter) at src/components/ui/card.

Replace ~20 hand-rolled "rounded-lg border border-border bg-card …"
patterns across nine views with <Card>:

- ConceptView (slow-farming pillars)
- VisionValuesView (philosophy + pillars + team)
- GalleryView (image figure cards)
- EventsView (program cards)
- SymposiumView (included items + apply steps)
- LongStaysView (path cards)
- OpportunitiesView (group cards + apply explainers)
- AccommodationView (rooms + cabins + exterior items)
- ReservationsView (kind cards + contact card)
- MarketplaceView (category cards)
- HomeView (featured events)

For image-bearing cards (events / rooms), use Card + CardContent so
the image stays flush at the top of the card and the inner padding
lives on the content slot. For clickable cards, the RouterLink wraps
the Card so the whole card is the link target.

Variants where the card sits on a tinted section (philosophy items on
bg-card section, cabins on bg-card section) override Card's default
bg-card with bg-background via the class prop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-09 17:01:51 +02:00
commit 4cb0fa14a2
18 changed files with 265 additions and 197 deletions

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="
cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('p-6 pt-0', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<p :class="cn('text-sm text-muted-foreground', props.class)">
<slot />
</p>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('flex items-center p-6 pt-0', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
<slot />
</div>
</template>

View file

@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<h3
:class="
cn('text-2xl font-semibold leading-none tracking-tight', props.class)
"
>
<slot />
</h3>
</template>

View file

@ -0,0 +1,6 @@
export { default as Card } from "./Card.vue"
export { default as CardContent } from "./CardContent.vue"
export { default as CardDescription } from "./CardDescription.vue"
export { default as CardFooter } from "./CardFooter.vue"
export { default as CardHeader } from "./CardHeader.vue"
export { default as CardTitle } from "./CardTitle.vue"

View file

@ -2,6 +2,7 @@
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
const { t, tm, rt } = useI18n()
@ -82,18 +83,15 @@ const exteriorItems = tm('accommodation.exterior.items') as string[]
<p class="mt-3 text-muted-foreground">{{ t('accommodation.rooms.subtitle') }}</p>
</div>
<ul class="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<li
v-for="room in rooms"
:key="room.key"
class="overflow-hidden rounded-lg border border-border bg-card"
>
<li v-for="room in rooms" :key="room.key">
<Card class="overflow-hidden">
<img
:src="room.image"
alt=""
class="aspect-[4/3] w-full object-cover"
loading="lazy"
/>
<div class="p-5">
<CardContent class="p-5 pt-5">
<div class="flex items-baseline justify-between gap-3">
<h3 class="font-serif text-xl font-semibold">
{{ t(`accommodation.rooms.${room.key}.name`) }}
@ -116,7 +114,8 @@ const exteriorItems = tm('accommodation.exterior.items') as string[]
<p class="mt-3 text-sm leading-relaxed text-foreground/85">
{{ t(`accommodation.rooms.${room.key}.summary`) }}
</p>
</div>
</CardContent>
</Card>
</li>
</ul>
</section>
@ -131,11 +130,8 @@ const exteriorItems = tm('accommodation.exterior.items') as string[]
<p class="mt-3 text-muted-foreground">{{ t('accommodation.cabins.subtitle') }}</p>
</div>
<ul class="mt-8 grid gap-6 md:grid-cols-3">
<li
v-for="cabin in cabins"
:key="cabin.key"
class="overflow-hidden rounded-lg border border-border bg-background"
>
<li v-for="cabin in cabins" :key="cabin.key">
<Card class="overflow-hidden bg-background">
<img
:src="cabin.image"
alt=""
@ -152,6 +148,7 @@ const exteriorItems = tm('accommodation.exterior.items') as string[]
{{ t('accommodation.statusComingSoon') }}
</span>
</div>
</Card>
</li>
</ul>
</div>
@ -166,12 +163,10 @@ const exteriorItems = tm('accommodation.exterior.items') as string[]
<p class="mt-3 text-muted-foreground">{{ t('accommodation.exterior.subtitle') }}</p>
</div>
<ul class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<li
v-for="(item, i) in exteriorItems"
:key="i"
class="rounded-lg border border-dashed border-border bg-secondary/20 p-5 text-sm text-foreground/85"
>
<li v-for="(item, i) in exteriorItems" :key="i">
<Card class="border-dashed bg-secondary/20 p-5 text-sm text-foreground/85">
{{ rt(item) }}
</Card>
</li>
</ul>
</section>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import { Card } from '@/components/ui/card'
const { t } = useI18n()
@ -96,18 +97,14 @@ const pillars = [
<p class="mt-3 text-muted-foreground">{{ t('concept.slowFarming.body') }}</p>
</div>
<div class="mt-8 grid gap-6 md:grid-cols-3">
<article
v-for="p in pillars"
:key="p.key"
class="rounded-lg border border-border bg-card p-6"
>
<Card v-for="p in pillars" :key="p.key" class="p-6">
<h3 class="font-serif text-xl font-semibold">
{{ t(`concept.slowFarming.${p.key}Title`) }}
</h3>
<p class="mt-3 text-sm leading-relaxed text-foreground/85">
{{ t(`concept.slowFarming.${p.key}Body`) }}
</p>
</article>
</Card>
</div>
</div>
</section>

View file

@ -2,6 +2,7 @@
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
const { t } = useI18n()
@ -64,15 +65,16 @@ const events = [
<component
:is="e.to ? 'RouterLink' : 'article'"
v-bind="e.to ? { to: e.to } : {}"
class="group block h-full overflow-hidden rounded-lg border border-border bg-card transition hover:shadow-md"
class="group block h-full"
>
<Card class="h-full overflow-hidden transition hover:shadow-md">
<img
:src="e.image"
alt=""
class="aspect-[4/3] w-full object-cover transition group-hover:scale-[1.02]"
loading="lazy"
/>
<div class="p-5">
<CardContent class="p-5 pt-5">
<p class="text-xs uppercase tracking-wider text-accent">
{{ t(`events.${e.key}.date`) }}
</p>
@ -85,7 +87,8 @@ const events = [
<p class="mt-3 text-sm leading-relaxed text-foreground/85">
{{ t(`events.${e.key}.description`) }}
</p>
</div>
</CardContent>
</Card>
</component>
</li>
</ul>

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Card } from '@/components/ui/card'
const { t } = useI18n()
@ -47,11 +48,8 @@ const items = [
<section class="mx-auto max-w-7xl px-4 py-16 lg:px-6">
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<li
v-for="item in items"
:key="item.key"
class="group overflow-hidden rounded-lg border border-border bg-card"
>
<li v-for="item in items" :key="item.key" class="group">
<Card class="overflow-hidden">
<figure>
<img
:src="item.src"
@ -65,6 +63,7 @@ const items = [
{{ t(`gallery.captions.${item.key}`) }}
</figcaption>
</figure>
</Card>
</li>
</ul>
</section>

View file

@ -2,6 +2,7 @@
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import cosmicStag from '@/assets/cosmic-stag.avif'
import heroLandscape from '@/assets/hero-landscape.webp'
import sectionTile from '@/assets/section-tile.webp'
@ -192,15 +193,16 @@ const featuredEvents = [
v-for="e in featuredEvents"
:key="e.key"
:to="e.to"
class="group overflow-hidden rounded-lg border border-border bg-card transition hover:shadow-md"
class="group block"
>
<Card class="overflow-hidden transition hover:shadow-md">
<img
:src="e.image"
alt=""
class="aspect-[4/3] w-full object-cover transition group-hover:scale-[1.02]"
loading="lazy"
/>
<div class="p-5">
<CardContent class="p-5 pt-5">
<p class="text-xs uppercase tracking-wider text-accent">
{{ t(`events.${e.key}.date`) }}
</p>
@ -210,7 +212,8 @@ const featuredEvents = [
<p class="mt-1 text-xs text-foreground/70">
{{ t(`events.${e.key}.location`) }}
</p>
</div>
</CardContent>
</Card>
</RouterLink>
</div>
<div class="mt-10 text-center">

View file

@ -2,6 +2,7 @@
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
const { t } = useI18n()
@ -31,18 +32,14 @@ const paths = ['exchange', 'rental', 'partial', 'funded'] as const
{{ t('longStays.pathsTitle') }}
</h2>
<div class="mt-8 grid gap-6 md:grid-cols-2">
<article
v-for="key in paths"
:key="key"
class="rounded-lg border border-border bg-card p-6"
>
<Card v-for="key in paths" :key="key" class="p-6">
<h3 class="font-serif text-xl font-semibold">
{{ t(`longStays.paths.${key}Title`) }}
</h3>
<p class="mt-3 text-base leading-relaxed text-foreground/85">
{{ t(`longStays.paths.${key}Body`) }}
</p>
</article>
</Card>
</div>
</section>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
const { t } = useI18n()
@ -28,18 +29,14 @@ const categories = ['fresh', 'pantry', 'craft'] as const
{{ t('marketplace.categoriesTitle') }}
</h2>
<div class="mt-8 grid gap-6 md:grid-cols-3">
<article
v-for="key in categories"
:key="key"
class="rounded-lg border border-dashed border-border bg-card p-6"
>
<Card v-for="key in categories" :key="key" class="border-dashed p-6">
<h3 class="font-serif text-xl font-semibold">
{{ t(`marketplace.categories.${key}Title`) }}
</h3>
<p class="mt-3 text-base leading-relaxed text-foreground/85">
{{ t(`marketplace.categories.${key}Body`) }}
</p>
</article>
</Card>
</div>
</section>

View file

@ -2,6 +2,7 @@
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
const { t } = useI18n()
@ -43,17 +44,15 @@ const applyKeys = ['model', 'window', 'open'] as const
<p class="mt-3 text-muted-foreground">{{ t('opportunities.groupsSubtitle') }}</p>
</div>
<ul class="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<li
v-for="key in groups"
:key="key"
class="rounded-lg border border-border bg-card p-6"
>
<li v-for="key in groups" :key="key">
<Card class="p-6">
<h3 class="font-serif text-xl font-semibold">
{{ t(`opportunities.groups.${key}Title`) }}
</h3>
<p class="mt-3 text-sm leading-relaxed text-foreground/85">
{{ t(`opportunities.groups.${key}Positions`) }}
</p>
</Card>
</li>
</ul>
</section>
@ -65,18 +64,14 @@ const applyKeys = ['model', 'window', 'open'] as const
{{ t('opportunities.applyTitle') }}
</h2>
<div class="mt-8 grid gap-6 md:grid-cols-3">
<article
v-for="key in applyKeys"
:key="key"
class="rounded-lg border border-border bg-card p-6"
>
<Card v-for="key in applyKeys" :key="key" class="p-6">
<h3 class="font-serif text-lg font-semibold">
{{ t(`opportunities.apply.${key}Title`) }}
</h3>
<p class="mt-3 text-sm leading-relaxed text-foreground/85">
{{ t(`opportunities.apply.${key}Body`) }}
</p>
</article>
</Card>
</div>
<div class="mt-10 flex flex-wrap items-center gap-3">
<Button as-child>

View file

@ -2,6 +2,7 @@
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
const { t } = useI18n()
@ -31,18 +32,14 @@ const kinds = ['weekend', 'retreat', 'gathering', 'residency'] as const
{{ t('reservations.kindsTitle') }}
</h2>
<div class="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<article
v-for="key in kinds"
:key="key"
class="rounded-lg border border-border bg-card p-6"
>
<Card v-for="key in kinds" :key="key" class="p-6">
<h3 class="font-serif text-xl font-semibold">
{{ t(`reservations.kinds.${key}Title`) }}
</h3>
<p class="mt-3 text-sm leading-relaxed text-foreground/85">
{{ t(`reservations.kinds.${key}Body`) }}
</p>
</article>
</Card>
</div>
</section>
@ -87,7 +84,7 @@ const kinds = ['weekend', 'retreat', 'gathering', 'residency'] as const
</div>
</div>
<aside class="rounded-lg border border-border bg-card p-6 lg:col-span-2">
<Card class="p-6 lg:col-span-2">
<h3 class="font-serif text-xl font-semibold">
{{ t('reservations.contactCard.title') }}
</h3>
@ -118,7 +115,7 @@ const kinds = ['weekend', 'retreat', 'gathering', 'residency'] as const
<dd class="mt-1">{{ t('reservations.contactCard.openingValue') }}</dd>
</div>
</dl>
</aside>
</Card>
</div>
</section>
</div>

View file

@ -2,6 +2,7 @@
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
const { t } = useI18n()
@ -56,18 +57,14 @@ const applySteps = ['stepOne', 'stepTwo', 'stepThree'] as const
{{ t('symposium.includedTitle') }}
</h2>
<div class="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<article
v-for="key in included"
:key="key"
class="rounded-lg border border-border bg-background p-6"
>
<Card v-for="key in included" :key="key" class="bg-background p-6">
<h3 class="font-serif text-lg font-semibold">
{{ t(`symposium.included.${key}Title`) }}
</h3>
<p class="mt-3 text-sm leading-relaxed text-foreground/85">
{{ t(`symposium.included.${key}Body`) }}
</p>
</article>
</Card>
</div>
</div>
</section>
@ -89,17 +86,15 @@ const applySteps = ['stepOne', 'stepTwo', 'stepThree'] as const
{{ t('symposium.applyTitle') }}
</h2>
<ol class="mt-8 grid gap-6 md:grid-cols-3">
<li
v-for="step in applySteps"
:key="step"
class="rounded-lg border border-border bg-card p-6"
>
<li v-for="step in applySteps" :key="step">
<Card class="p-6">
<h3 class="font-serif text-lg font-semibold">
{{ t(`symposium.apply.${step}Title`) }}
</h3>
<p class="mt-3 text-sm leading-relaxed text-foreground/85">
{{ t(`symposium.apply.${step}Body`) }}
</p>
</Card>
</li>
</ol>
<div class="mt-10 flex flex-wrap items-center gap-3">

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { Card } from '@/components/ui/card'
const { t } = useI18n()
@ -55,11 +56,7 @@ const team = ['patrick', 'coco', 'charlie'] as const
<p class="mt-3 text-muted-foreground">{{ t('vision.philosophySubtitle') }}</p>
</div>
<div class="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-5">
<article
v-for="(p, i) in philosophy"
:key="p"
class="rounded-lg border border-border bg-background p-5"
>
<Card v-for="(p, i) in philosophy" :key="p" class="bg-background p-5">
<div class="font-serif text-2xl text-accent">{{ i + 1 }}</div>
<h3 class="mt-2 font-serif text-lg font-semibold">
{{ t(`vision.philosophy.${p}Title`) }}
@ -67,7 +64,7 @@ const team = ['patrick', 'coco', 'charlie'] as const
<p class="mt-2 text-sm leading-relaxed text-foreground/85">
{{ t(`vision.philosophy.${p}Body`) }}
</p>
</article>
</Card>
</div>
</div>
</section>
@ -81,16 +78,12 @@ const team = ['patrick', 'coco', 'charlie'] as const
<p class="mt-3 text-muted-foreground">{{ t('vision.pillarsSubtitle') }}</p>
</div>
<div class="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<article
v-for="p in pillars"
:key="p"
class="rounded-lg border border-border bg-card p-6"
>
<Card v-for="p in pillars" :key="p" class="p-6">
<h3 class="font-serif text-xl font-semibold">{{ t(`vision.pillars.${p}Title`) }}</h3>
<p class="mt-3 text-sm leading-relaxed text-foreground/85">
{{ t(`vision.pillars.${p}Body`) }}
</p>
</article>
</Card>
</div>
</section>
@ -101,11 +94,7 @@ const team = ['patrick', 'coco', 'charlie'] as const
{{ t('vision.teamTitle') }}
</h2>
<div class="mt-8 grid gap-6 md:grid-cols-3">
<article
v-for="m in team"
:key="m"
class="rounded-lg border border-border bg-card p-6"
>
<Card v-for="m in team" :key="m" class="p-6">
<h3 class="font-serif text-xl font-semibold">{{ t(`vision.team.${m}Name`) }}</h3>
<p class="mt-1 text-xs uppercase tracking-wider text-accent">
{{ t(`vision.team.${m}Role`) }}
@ -113,7 +102,7 @@ const team = ['patrick', 'coco', 'charlie'] as const
<p class="mt-3 text-sm leading-relaxed text-foreground/85">
{{ t(`vision.team.${m}Body`) }}
</p>
</article>
</Card>
</div>
</div>
</section>