Internationalize all UI strings and localize date formatting

All hardcoded English strings now use t() with i18n keys: bottom nav
tabs, search placeholder, empty states, favorites page, settings page.

Added useDateLocale composable that maps the current i18n language to
the corresponding date-fns locale (fr/es/enUS). All date formatting
across ActivityCard, ActivityDetailPage, DatePickerStrip, calendar
view, and search overlay now passes the locale to date-fns format().
Month names, day names, and date labels change with language switch.

Added nav, search, favorites, and settings i18n sections to all three
locales (EN/FR/ES).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-04-21 07:24:43 +02:00
commit 93c2a73e21
14 changed files with 209 additions and 42 deletions

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Toaster } from '@/components/ui/sonner'
import LoginDialog from '@/components/auth/LoginDialog.vue'
import { useTheme } from '@/components/theme-provider'
@ -11,19 +12,20 @@ import {
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
useTheme()
const showLoginDialog = ref(false)
// Bottom navigation tabs (p'a semana style)
const bottomTabs = [
{ name: 'Feed', icon: Search, path: '/activities' },
{ name: 'Calendar', icon: CalendarDays, path: '/activities/calendar' },
{ name: 'Map', icon: Map, path: '/activities/map' },
{ name: 'Favorites', icon: Heart, path: '/activities/favorites' },
{ name: 'Settings', icon: Settings, path: '/settings' },
]
const bottomTabs = computed(() => [
{ name: t('activities.nav.feed'), icon: Search, path: '/activities' },
{ name: t('activities.nav.calendar'), icon: CalendarDays, path: '/activities/calendar' },
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
{ name: t('activities.nav.favorites'), icon: Heart, path: '/activities/favorites' },
{ name: t('activities.nav.settings'), icon: Settings, path: '/settings' },
])
const isLoginPage = computed(() => route.path === '/login')

View file

@ -9,7 +9,7 @@ import { useI18n } from 'vue-i18n'
import { Sun, Moon, LogIn, LogOut } from 'lucide-vue-next'
const { theme, setTheme } = useTheme()
const { locale } = useI18n()
const { t, locale } = useI18n()
const languages: { code: AvailableLocale; label: string }[] = [
{ code: 'fr', label: 'Français' },
@ -35,27 +35,27 @@ async function handleLogout() {
<template>
<div class="container mx-auto px-4 py-6 max-w-lg">
<h1 class="text-2xl font-bold text-foreground mb-6">Settings</h1>
<h1 class="text-2xl font-bold text-foreground mb-6">{{ t('activities.settings.title') }}</h1>
<!-- Account -->
<div class="space-y-4">
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">Account</h2>
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('activities.settings.account') }}</h2>
<div v-if="isAuthenticated" class="bg-muted/50 rounded-lg p-4 space-y-3">
<p class="text-sm text-foreground font-mono truncate">
{{ userPubkey }}
</p>
<Button variant="outline" size="sm" class="w-full gap-2" @click="handleLogout">
<LogOut class="w-4 h-4" />
Log out
{{ t('activities.settings.logOut') }}
</Button>
</div>
<div v-else class="bg-muted/50 rounded-lg p-4">
<p class="text-sm text-muted-foreground mb-3">
Log in to bookmark activities, RSVP, and purchase tickets.
{{ t('activities.settings.loginPrompt') }}
</p>
<Button size="sm" class="w-full gap-2" @click="$router.push('/login')">
<LogIn class="w-4 h-4" />
Log in
{{ t('activities.settings.logIn') }}
</Button>
</div>
</div>
@ -64,9 +64,9 @@ async function handleLogout() {
<!-- Appearance -->
<div class="space-y-4">
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">Appearance</h2>
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('activities.settings.appearance') }}</h2>
<div class="flex items-center justify-between bg-muted/50 rounded-lg p-4">
<span class="text-sm text-foreground">Theme</span>
<span class="text-sm text-foreground">{{ t('activities.settings.theme') }}</span>
<Button variant="outline" size="icon" class="h-8 w-8" @click="toggleTheme">
<Sun v-if="theme === 'dark'" class="w-4 h-4" />
<Moon v-else class="w-4 h-4" />
@ -78,7 +78,7 @@ async function handleLogout() {
<!-- Language -->
<div class="space-y-4">
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">Language</h2>
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('activities.settings.language') }}</h2>
<div class="flex gap-2">
<Button
v-for="lang in languages"

View file

@ -90,6 +90,34 @@ const messages: LocaleMessages = {
pending: 'Pending',
registered: 'Registered',
},
nav: {
feed: 'Feed',
calendar: 'Calendar',
map: 'Map',
favorites: 'Favorites',
settings: 'Settings',
},
search: {
placeholder: 'Search activities...',
noResults: 'No activities found',
},
favorites: {
title: 'Favorites',
loginPrompt: 'Log in to save your favorite activities',
empty: 'No favorites yet',
emptyHint: 'Tap the heart icon on any activity to save it here',
logIn: 'Log in',
},
settings: {
title: 'Settings',
account: 'Account',
loginPrompt: 'Log in to bookmark activities, RSVP, and purchase tickets.',
logIn: 'Log in',
logOut: 'Log out',
appearance: 'Appearance',
theme: 'Theme',
language: 'Language',
},
},
dateTimeFormats: {
short: {

View file

@ -90,6 +90,34 @@ const messages: LocaleMessages = {
pending: 'Pendiente',
registered: 'Registrado',
},
nav: {
feed: 'Inicio',
calendar: 'Calendario',
map: 'Mapa',
favorites: 'Favoritos',
settings: 'Ajustes',
},
search: {
placeholder: 'Buscar actividades...',
noResults: 'No se encontraron actividades',
},
favorites: {
title: 'Favoritos',
loginPrompt: 'Inicia sesión para guardar tus actividades favoritas',
empty: 'Aún no tienes favoritos',
emptyHint: 'Toca el corazón en cualquier actividad para guardarla aquí',
logIn: 'Iniciar sesión',
},
settings: {
title: 'Ajustes',
account: 'Cuenta',
loginPrompt: 'Inicia sesión para guardar actividades, confirmar asistencia y comprar boletos.',
logIn: 'Iniciar sesión',
logOut: 'Cerrar sesión',
appearance: 'Apariencia',
theme: 'Tema',
language: 'Idioma',
},
},
dateTimeFormats: {
short: {

View file

@ -90,6 +90,34 @@ const messages: LocaleMessages = {
pending: 'En attente',
registered: 'Enregistré',
},
nav: {
feed: 'Fil',
calendar: 'Calendrier',
map: 'Carte',
favorites: 'Favoris',
settings: 'Réglages',
},
search: {
placeholder: 'Rechercher des activités...',
noResults: 'Aucune activité trouvée',
},
favorites: {
title: 'Favoris',
loginPrompt: 'Connectez-vous pour sauvegarder vos activités préférées',
empty: 'Pas encore de favoris',
emptyHint: "Appuyez sur le cœur d'une activité pour la sauvegarder ici",
logIn: 'Se connecter',
},
settings: {
title: 'Réglages',
account: 'Compte',
loginPrompt: 'Connectez-vous pour sauvegarder des activités, confirmer votre présence et acheter des billets.',
logIn: 'Se connecter',
logOut: 'Se déconnecter',
appearance: 'Apparence',
theme: 'Thème',
language: 'Langue',
},
},
dateTimeFormats: {
short: {

View file

@ -65,6 +65,34 @@ export interface LocaleMessages {
pending: string
registered: string
}
nav: {
feed: string
calendar: string
map: string
favorites: string
settings: string
}
search: {
placeholder: string
noResults: string
}
favorites: {
title: string
loginPrompt: string
empty: string
emptyHint: string
logIn: string
}
settings: {
title: string
account: string
loginPrompt: string
logIn: string
logOut: string
appearance: string
theme: string
language: string
}
}
// Add date/time formats
dateTimeFormats: {

View file

@ -7,6 +7,7 @@ import {
} from 'date-fns'
import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { useDateLocale } from '../composables/useDateLocale'
import type { Activity } from '../types/activity'
const props = defineProps<{
@ -18,11 +19,24 @@ const emit = defineEmits<{
selectActivity: [activity: Activity]
}>()
const { dateLocale } = useDateLocale()
const currentMonth = ref(new Date())
const monthLabel = computed(() => format(currentMonth.value, 'MMMM yyyy'))
const monthLabel = computed(() => format(currentMonth.value, 'MMMM yyyy', { locale: dateLocale.value }))
const weekDays = ['M', 'T', 'W', 'T', 'F', 'S', 'S']
const weekDays = computed(() => {
// Generate localized single-letter day names starting from Monday
const days: string[] = []
// Start from a known Monday (2024-01-01 is a Monday)
const monday = new Date(2024, 0, 1)
for (let i = 0; i < 7; i++) {
const d = new Date(monday)
d.setDate(d.getDate() + i)
days.push(format(d, 'EEEEE', { locale: dateLocale.value }))
}
return days
})
const calendarDays = computed(() => {
const monthStart = startOfMonth(currentMonth.value)
@ -134,7 +148,7 @@ function nextMonth() {
<!-- Selected day activities -->
<div v-if="selectedDay" class="border-t pt-4 space-y-2">
<h3 class="text-sm font-medium text-muted-foreground">
{{ format(selectedDay, 'EEEE, MMMM d') }}
{{ format(selectedDay, 'EEEE, MMMM d', { locale: dateLocale }) }}
<span v-if="selectedDayActivities.length > 0" class="ml-1">
({{ selectedDayActivities.length }})
</span>

View file

@ -6,6 +6,7 @@ import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { MapPin, Calendar, Ticket } from 'lucide-vue-next'
import BookmarkButton from './BookmarkButton.vue'
import { useDateLocale } from '../composables/useDateLocale'
import type { Activity } from '../types/activity'
const props = defineProps<{
@ -17,16 +18,18 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { dateLocale } = useDateLocale()
const dateDisplay = computed(() => {
const a = props.activity
if (!a.startDate || isNaN(a.startDate.getTime())) return ''
try {
const opts = { locale: dateLocale.value }
if (a.type === 'date') {
return format(a.startDate, 'EEE, MMM d')
return format(a.startDate, 'EEE, MMM d', opts)
}
const date = format(a.startDate, 'EEE, MMM d')
const time = format(a.startDate, 'HH:mm')
const date = format(a.startDate, 'EEE, MMM d', opts)
const time = format(a.startDate, 'HH:mm', opts)
return `${date} \u2022 ${time}`
} catch {
return ''

View file

@ -43,7 +43,7 @@ const { t } = useI18n()
{{ t('activities.noActivities') }}
</h3>
<p class="text-sm text-muted-foreground">
Try adjusting your filters or check back later
{{ t('activities.search.noResults') }}
</p>
</div>

View file

@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { useDateLocale } from '../composables/useDateLocale'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Search, X, MapPin, Calendar } from 'lucide-vue-next'
@ -15,6 +17,8 @@ const emit = defineEmits<{
select: [activity: Activity]
}>()
const { t } = useI18n()
const { dateLocale } = useDateLocale()
const isOpen = ref(false)
const inputRef = ref<HTMLInputElement | null>(null)
@ -51,8 +55,9 @@ const showNoResults = computed(() => isOpen.value && isSearching.value && filter
function formatDate(activity: Activity): string {
if (!activity.startDate || isNaN(activity.startDate.getTime())) return ''
try {
if (activity.type === 'date') return format(activity.startDate, 'MMM d')
return format(activity.startDate, 'MMM d · HH:mm')
const opts = { locale: dateLocale.value }
if (activity.type === 'date') return format(activity.startDate, 'MMM d', opts)
return format(activity.startDate, 'MMM d · HH:mm', opts)
} catch {
return ''
}
@ -105,7 +110,7 @@ watch(isOpen, (open) => {
:model-value="searchQuery"
@update:model-value="handleInput"
@focus="handleFocus"
placeholder="Search activities..."
:placeholder="t('activities.search.placeholder')"
class="pl-9 pr-9"
/>
<Button
@ -126,7 +131,7 @@ watch(isOpen, (open) => {
>
<!-- No results -->
<div v-if="showNoResults" class="p-4 text-sm text-muted-foreground text-center">
No activities found
{{ t('activities.search.noResults') }}
</div>
<!-- Result items -->

View file

@ -3,6 +3,7 @@ import { ref, computed } from 'vue'
import { addDays, format, isSameDay, startOfWeek } from 'date-fns'
import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { useDateLocale } from '../composables/useDateLocale'
const props = defineProps<{
/** Currently selected date (if any) */
@ -13,6 +14,8 @@ const emit = defineEmits<{
select: [date: Date]
}>()
const { dateLocale } = useDateLocale()
/** Start of the visible week */
const weekStart = ref(startOfWeek(new Date(), { weekStartsOn: 1 }))
@ -55,7 +58,7 @@ function nextWeek() {
<span class="text-[10px] font-medium uppercase leading-none"
:class="isSelected(day) ? 'text-primary-foreground/70' : 'text-muted-foreground'"
>
{{ format(day, 'EEEEE') }}
{{ format(day, 'EEEEE', { locale: dateLocale }) }}
</span>
<span class="text-sm font-semibold leading-tight mt-0.5">
{{ format(day, 'd') }}

View file

@ -0,0 +1,23 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { fr, es, enUS } from 'date-fns/locale'
import type { Locale } from 'date-fns'
const localeMap: Record<string, Locale> = {
fr,
es,
en: enUS,
}
/**
* Returns a reactive date-fns Locale based on the current i18n language.
*/
export function useDateLocale() {
const { locale } = useI18n()
const dateLocale = computed<Locale>(() => {
return localeMap[locale.value] ?? enUS
})
return { dateLocale }
}

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Heart } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { toast } from 'vue-sonner'
@ -11,6 +12,7 @@ import ActivityList from '../components/ActivityList.vue'
import type { Activity } from '../types/activity'
const router = useRouter()
const { t } = useI18n()
const { isAuthenticated } = useAuth()
const { isBookmarkedByDTag, isLoaded } = useBookmarks()
const store = useActivitiesStore()
@ -25,9 +27,9 @@ function handleSelect(activity: Activity) {
onMounted(() => {
if (!isAuthenticated.value) {
toast.info('Log in to save favorites', {
toast.info(t('activities.favorites.loginPrompt'), {
action: {
label: 'Log in',
label: t('activities.favorites.logIn'),
onClick: () => router.push('/login'),
},
})
@ -37,14 +39,14 @@ onMounted(() => {
<template>
<div class="container mx-auto px-4 py-6">
<h1 class="text-2xl font-bold text-foreground mb-4">Favorites</h1>
<h1 class="text-2xl font-bold text-foreground mb-4">{{ t('activities.favorites.title') }}</h1>
<!-- Not authenticated -->
<div v-if="!isAuthenticated" class="flex flex-col items-center justify-center py-16 text-center">
<Heart class="w-16 h-16 text-muted-foreground/30 mb-4" />
<p class="text-muted-foreground mb-3">Log in to save your favorite activities</p>
<Heart class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
<p class="text-muted-foreground mb-3">{{ t('activities.favorites.loginPrompt') }}</p>
<Button variant="outline" size="sm" @click="router.push('/login')">
Log in
{{ t('activities.favorites.logIn') }}
</Button>
</div>
@ -55,9 +57,9 @@ onMounted(() => {
<!-- Empty -->
<div v-else-if="favoriteActivities.length === 0" class="flex flex-col items-center justify-center py-16 text-center">
<Heart class="w-16 h-16 text-muted-foreground/30 mb-4" />
<p class="text-muted-foreground">No favorites yet</p>
<p class="text-sm text-muted-foreground/70 mt-1">Tap the heart icon on any activity to save it here</p>
<Heart class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
<p class="text-muted-foreground">{{ t('activities.favorites.empty') }}</p>
<p class="text-sm text-muted-foreground/70 mt-1">{{ t('activities.favorites.emptyHint') }}</p>
</div>
<!-- Favorites list -->

View file

@ -3,6 +3,7 @@ import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { useDateLocale } from '../composables/useDateLocale'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
@ -20,20 +21,22 @@ const { t } = useI18n()
const activityId = route.params.id as string
const { activity, isLoading, error, reload } = useActivityDetail(activityId)
const { dateLocale } = useDateLocale()
const dateDisplay = computed(() => {
if (!activity.value) return ''
const a = activity.value
const opts = { locale: dateLocale.value }
if (a.type === 'date') {
const start = format(a.startDate, 'EEEE, MMMM d, yyyy')
const start = format(a.startDate, 'EEEE, MMMM d, yyyy', opts)
if (a.endDate) {
return `${start}${format(a.endDate, 'EEEE, MMMM d, yyyy')}`
return `${start}${format(a.endDate, 'EEEE, MMMM d, yyyy', opts)}`
}
return start
}
const start = format(a.startDate, 'EEEE, MMMM d, yyyy \u2022 HH:mm')
const start = format(a.startDate, 'EEEE, MMMM d, yyyy \u2022 HH:mm', opts)
if (a.endDate) {
return `${start}${format(a.endDate, 'HH:mm')}`
return `${start}${format(a.endDate, 'HH:mm', opts)}`
}
return start
})