Add i18n support with English and Spanish translations

- Introduce `svelte-i18n` for internationalization.
- Add English (`en.json`) and Spanish (`es-ES.json`) locale files.
- Implement `setupI18n` function for initializing and managing locales.
- Update project dependencies to include `svelte-i18n`.
main
Pau Costa Ferrer 2026-03-28 17:34:13 +01:00
parent e80984faa1
commit 71404d0ed8
16 changed files with 3725 additions and 60 deletions

View File

@ -4,18 +4,18 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add assets module with file upload and retrieval support &#10;&#10;- Introduce `AssetsModule` with APIs for uploading and retrieving organization logos, building images, and floor plans. &#10;- Implement file validation for type and size (10MB max). &#10;- Add `resolveAssetUrl` utility for generating absolute asset URLs. &#10;- Enhance admin UI components to support image file uploads and previews. &#10;- Update Prisma schema to associate assets with organizations, buildings, and floors. &#10;- Modify existing update logic to clear references when external URLs are used."> <list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add user profile management with avatar upload and update support&#10;&#10;- Create `+page.svelte` for managing user profiles, including name, avatar, and password updates.&#10;- Add `Avatar.svelte` component for rendering user avatars with Gravatar and fallback initials support.&#10;- Implement backend APIs for fetching and updating user profiles (`/users/profile`).&#10;- Add support for avatar uploads with file validation (max size: 5MB).&#10;- Update `FloorplanViewer.svelte` to display user names and avatars in reservations.&#10;- Introduce utilities for Gravatar URLs and user avatar management (`getGravatarUrl`, `getInitials`).">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/auth/auth.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/auth/auth.service.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/api/src/organizations/dto/update-organization.dto.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/organizations/dto/update-organization.dto.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/reservations/reservations.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/reservations/reservations.service.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/users/dto/update-user.dto.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/users/dto/update-user.dto.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/users/users.controller.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/users/users.controller.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/users/users.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/users/users.service.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/api/src/users/users.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/users/users.service.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/package.json" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/web/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/FloorplanViewer.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/FloorplanViewer.svelte" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/BookingModal.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/BookingModal.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/stores/organization.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/stores/organization.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" /> <change beforePath="$PROJECT_DIR$/apps/web/src/routes/admin/organization/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/admin/organization/+page.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/+page.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/dashboard/my-bookings/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/dashboard/my-bookings/+page.svelte" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -115,8 +115,8 @@
</configuration> </configuration>
<recent_temporary> <recent_temporary>
<list> <list>
<item itemvalue="npm.api &gt; start:dev" />
<item itemvalue="npm.web &gt; dev" /> <item itemvalue="npm.web &gt; dev" />
<item itemvalue="npm.api &gt; start:dev" />
<item itemvalue="Docker.docker-compose.yml: Compose Deployment" /> <item itemvalue="Docker.docker-compose.yml: Compose Deployment" />
</list> </list>
</recent_temporary> </recent_temporary>
@ -137,7 +137,7 @@
<updated>1774643312599</updated> <updated>1774643312599</updated>
<workItem from="1774643313611" duration="74000" /> <workItem from="1774643313611" duration="74000" />
<workItem from="1774643422240" duration="5278000" /> <workItem from="1774643422240" duration="5278000" />
<workItem from="1774706361502" duration="8461000" /> <workItem from="1774706361502" duration="9064000" />
</task> </task>
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services &#10;&#10;- Set up SvelteKit for the web interface with Tailwind CSS. &#10;- Build the API using NestJS with Prisma ORM for database interaction. &#10;- Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. &#10;- Create shared data models for consistent typing across services. &#10;- Establish basic routing and layouts for web and API services."> <task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services &#10;&#10;- Set up SvelteKit for the web interface with Tailwind CSS. &#10;- Build the API using NestJS with Prisma ORM for database interaction. &#10;- Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. &#10;- Create shared data models for consistent typing across services. &#10;- Establish basic routing and layouts for web and API services.">
<option name="closed" value="true" /> <option name="closed" value="true" />
@ -259,7 +259,15 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1774714222515</updated> <updated>1774714222515</updated>
</task> </task>
<option name="localTasksCounter" value="16" /> <task id="LOCAL-00016" summary="Add user profile management with avatar upload and update support&#10;&#10;- Create `+page.svelte` for managing user profiles, including name, avatar, and password updates.&#10;- Add `Avatar.svelte` component for rendering user avatars with Gravatar and fallback initials support.&#10;- Implement backend APIs for fetching and updating user profiles (`/users/profile`).&#10;- Add support for avatar uploads with file validation (max size: 5MB).&#10;- Update `FloorplanViewer.svelte` to display user names and avatars in reservations.&#10;- Introduce utilities for Gravatar URLs and user avatar management (`getGravatarUrl`, `getInitials`).">
<option name="closed" value="true" />
<created>1774714977147</created>
<option name="number" value="00016" />
<option name="presentableId" value="LOCAL-00016" />
<option name="project" value="LOCAL" />
<updated>1774714977147</updated>
</task>
<option name="localTasksCounter" value="17" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@ -283,6 +291,7 @@
<MESSAGE value="Add organization management with logo support&#10;&#10;- Introduce `OrganizationsModule` with related logic in `AppModule`.&#10;- Update Prisma schema to include `logoUrl` field for organizations.&#10;- Enhance seeds to include default organization logo.&#10;- Update layout to dynamically display organization details, including logo and name.&#10;- Add UI support for organization-based navigation in admin routes." /> <MESSAGE value="Add organization management with logo support&#10;&#10;- Introduce `OrganizationsModule` with related logic in `AppModule`.&#10;- Update Prisma schema to include `logoUrl` field for organizations.&#10;- Enhance seeds to include default organization logo.&#10;- Update layout to dynamically display organization details, including logo and name.&#10;- Add UI support for organization-based navigation in admin routes." />
<MESSAGE value="Add organization management with logo support&#10;&#10;- Implement organization settings page (`+page.svelte`) for updating organization name and logo.&#10;- Create backend APIs for retrieving and updating organization details with role-based access control.&#10;- Add `OrganizationsModule`, `OrganizationsService`, and DTOs for input validation in NestJS.&#10;- Update Prisma schema to include `logoUrl` field for organizations.&#10;- Enhance navigation to dynamically display organization logo and name." /> <MESSAGE value="Add organization management with logo support&#10;&#10;- Implement organization settings page (`+page.svelte`) for updating organization name and logo.&#10;- Create backend APIs for retrieving and updating organization details with role-based access control.&#10;- Add `OrganizationsModule`, `OrganizationsService`, and DTOs for input validation in NestJS.&#10;- Update Prisma schema to include `logoUrl` field for organizations.&#10;- Enhance navigation to dynamically display organization logo and name." />
<MESSAGE value="Add assets module with file upload and retrieval support &#10;&#10;- Introduce `AssetsModule` with APIs for uploading and retrieving organization logos, building images, and floor plans. &#10;- Implement file validation for type and size (10MB max). &#10;- Add `resolveAssetUrl` utility for generating absolute asset URLs. &#10;- Enhance admin UI components to support image file uploads and previews. &#10;- Update Prisma schema to associate assets with organizations, buildings, and floors. &#10;- Modify existing update logic to clear references when external URLs are used." /> <MESSAGE value="Add assets module with file upload and retrieval support &#10;&#10;- Introduce `AssetsModule` with APIs for uploading and retrieving organization logos, building images, and floor plans. &#10;- Implement file validation for type and size (10MB max). &#10;- Add `resolveAssetUrl` utility for generating absolute asset URLs. &#10;- Enhance admin UI components to support image file uploads and previews. &#10;- Update Prisma schema to associate assets with organizations, buildings, and floors. &#10;- Modify existing update logic to clear references when external URLs are used." />
<option name="LAST_COMMIT_MESSAGE" value="Add assets module with file upload and retrieval support &#10;&#10;- Introduce `AssetsModule` with APIs for uploading and retrieving organization logos, building images, and floor plans. &#10;- Implement file validation for type and size (10MB max). &#10;- Add `resolveAssetUrl` utility for generating absolute asset URLs. &#10;- Enhance admin UI components to support image file uploads and previews. &#10;- Update Prisma schema to associate assets with organizations, buildings, and floors. &#10;- Modify existing update logic to clear references when external URLs are used." /> <MESSAGE value="Add user profile management with avatar upload and update support&#10;&#10;- Create `+page.svelte` for managing user profiles, including name, avatar, and password updates.&#10;- Add `Avatar.svelte` component for rendering user avatars with Gravatar and fallback initials support.&#10;- Implement backend APIs for fetching and updating user profiles (`/users/profile`).&#10;- Add support for avatar uploads with file validation (max size: 5MB).&#10;- Update `FloorplanViewer.svelte` to display user names and avatars in reservations.&#10;- Introduce utilities for Gravatar URLs and user avatar management (`getGravatarUrl`, `getInitials`)." />
<option name="LAST_COMMIT_MESSAGE" value="Add user profile management with avatar upload and update support&#10;&#10;- Create `+page.svelte` for managing user profiles, including name, avatar, and password updates.&#10;- Add `Avatar.svelte` component for rendering user avatars with Gravatar and fallback initials support.&#10;- Implement backend APIs for fetching and updating user profiles (`/users/profile`).&#10;- Add support for avatar uploads with file validation (max size: 5MB).&#10;- Update `FloorplanViewer.svelte` to display user names and avatars in reservations.&#10;- Introduce utilities for Gravatar URLs and user avatar management (`getGravatarUrl`, `getInitials`)." />
</component> </component>
</project> </project>

View File

@ -10,6 +10,7 @@ datasource db {
model Organization { model Organization {
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
language String @default("en")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
logoUrl String? logoUrl String?

View File

@ -9,4 +9,8 @@ export class UpdateOrganizationDto {
// @IsUrl() // @IsUrl()
@IsString() @IsString()
logoUrl?: string | null; logoUrl?: string | null;
@IsOptional()
@IsString()
language?: string;
} }

View File

@ -21,6 +21,7 @@ export class UsersService {
avatarUrl: true, avatarUrl: true,
avatarId: true, avatarId: true,
role: true, role: true,
organizationId: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}, },
@ -38,6 +39,7 @@ export class UsersService {
avatarUrl: true, avatarUrl: true,
avatarId: true, avatarId: true,
role: true, role: true,
organizationId: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}, },
@ -68,6 +70,7 @@ export class UsersService {
avatarUrl: true, avatarUrl: true,
avatarId: true, avatarId: true,
role: true, role: true,
organizationId: true,
updatedAt: true, updatedAt: true,
}, },
}); });

3445
apps/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -31,6 +31,7 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"import-meta-resolve": "^4.2.0", "import-meta-resolve": "^4.2.0",
"interactjs": "^1.10.27", "interactjs": "^1.10.27",
"socket.io-client": "^4.8.3" "socket.io-client": "^4.8.3",
"svelte-i18n": "^4.0.1"
} }
} }

View File

@ -3,6 +3,9 @@
import { user } from '$lib/stores/auth'; import { user } from '$lib/stores/auth';
import { apiFetch } from '$lib/api/client'; import { apiFetch } from '$lib/api/client';
import { X, Calendar, Clock, Loader2, Trash2, AlertTriangle } from 'lucide-svelte'; import { X, Calendar, Clock, Loader2, Trash2, AlertTriangle } from 'lucide-svelte';
import { t, locale } from 'svelte-i18n';
import { format } from 'date-fns';
import { es, enUS } from 'date-fns/locale';
export let space: any; export let space: any;
export let status: any; export let status: any;
@ -13,6 +16,9 @@
let reservations: any[] = []; let reservations: any[] = [];
let loadingReservations = false; let loadingReservations = false;
$: dateLocale = $locale === 'es-ES' ? es : enUS;
$: formattedDate = format(selectedDate, 'PPP', { locale: dateLocale });
$: { $: {
if (selectedDate) { if (selectedDate) {
bookingDate = selectedDate.toISOString().split('T')[0]; bookingDate = selectedDate.toISOString().split('T')[0];
@ -170,7 +176,7 @@
<div class="p-6 border-b flex justify-between items-center bg-gray-50"> <div class="p-6 border-b flex justify-between items-center bg-gray-50">
<div> <div>
<h3 class="text-xl font-bold text-gray-900">{space.name}</h3> <h3 class="text-xl font-bold text-gray-900">{space.name}</h3>
<p class="text-sm text-gray-500 uppercase tracking-wider font-semibold">{space.type.replace('_', ' ')}</p> <p class="text-sm text-gray-500 uppercase tracking-wider font-semibold">{$t(`booking.spaceType.${space.type}`)}</p>
</div> </div>
<button on:click={() => dispatch('close')} class="p-2 hover:bg-gray-200 rounded-full transition-colors text-gray-400"> <button on:click={() => dispatch('close')} class="p-2 hover:bg-gray-200 rounded-full transition-colors text-gray-400">
<X size={24} /> <X size={24} />
@ -180,7 +186,7 @@
<div class="p-6"> <div class="p-6">
{#if space.type === 'DESK' && status.isOccupied} {#if space.type === 'DESK' && status.isOccupied}
<div class="bg-yellow-50 border border-yellow-200 rounded-xl p-4 mb-6"> <div class="bg-yellow-50 border border-yellow-200 rounded-xl p-4 mb-6">
<p class="text-yellow-800 font-bold mb-1">Currently Booked</p> <p class="text-yellow-800 font-bold mb-1">{$t('booking.currentlyBooked')}</p>
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-yellow-200 flex items-center justify-center text-yellow-700 font-bold"> <div class="w-10 h-10 rounded-full bg-yellow-200 flex items-center justify-center text-yellow-700 font-bold">
@ -202,7 +208,7 @@
class="flex items-center gap-2 px-4 py-2 bg-red-50 text-red-600 hover:bg-red-100 rounded-xl transition-colors font-bold text-sm border border-red-100 shadow-sm" class="flex items-center gap-2 px-4 py-2 bg-red-50 text-red-600 hover:bg-red-100 rounded-xl transition-colors font-bold text-sm border border-red-100 shadow-sm"
> >
<Trash2 size={18} /> <Trash2 size={18} />
Delete Booking {$t('booking.delete')}
</button> </button>
</div> </div>
{/if} {/if}
@ -212,27 +218,27 @@
<div class="mt-4 p-3 bg-red-100 border border-red-200 rounded-lg"> <div class="mt-4 p-3 bg-red-100 border border-red-200 rounded-lg">
<div class="flex items-center gap-2 text-red-800 text-sm font-bold mb-2"> <div class="flex items-center gap-2 text-red-800 text-sm font-bold mb-2">
<AlertTriangle size={16} /> <AlertTriangle size={16} />
Warning: Admin Action {$t('booking.adminWarning')}
</div> </div>
<p class="text-xs text-red-700 mb-3">You are about to delete another user's booking. This action cannot be undone.</p> <p class="text-xs text-red-700 mb-3">{$t('booking.adminDeleteInfo')}</p>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
on:click={() => handleDelete(status.reservation.id)} on:click={() => handleDelete(status.reservation.id)}
class="flex-1 py-2 bg-red-600 text-white text-xs font-bold rounded-md hover:bg-red-700" class="flex-1 py-2 bg-red-600 text-white text-xs font-bold rounded-md hover:bg-red-700"
> >
Confirm Delete {$t('booking.confirmDelete')}
</button> </button>
<button <button
on:click={() => confirmDelete = false} on:click={() => confirmDelete = false}
class="flex-1 py-2 bg-white text-gray-700 text-xs font-bold border border-gray-300 rounded-md hover:bg-gray-50" class="flex-1 py-2 bg-white text-gray-700 text-xs font-bold border border-gray-300 rounded-md hover:bg-gray-50"
> >
Cancel {$t('common.cancel')}
</button> </button>
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
<p class="text-center text-gray-500 text-sm">This space is currently unavailable for booking.</p> <p class="text-center text-gray-500 text-sm">{$t('booking.unavailable')}</p>
{:else} {:else}
{#if error} {#if error}
<div class="bg-red-50 text-red-600 p-3 rounded-lg mb-4 text-sm font-medium border border-red-100"> <div class="bg-red-50 text-red-600 p-3 rounded-lg mb-4 text-sm font-medium border border-red-100">
@ -244,7 +250,7 @@
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1 flex items-center gap-2"> <label class="block text-sm font-bold text-gray-700 mb-1 flex items-center gap-2">
<Calendar size={16} /> <Calendar size={16} />
Booking Date {$t('booking.bookingDate')}
</label> </label>
<input <input
type="date" type="date"
@ -258,7 +264,7 @@
<div class="space-y-4"> <div class="space-y-4">
<label class="block text-sm font-bold text-gray-700 mb-1 flex items-center gap-2"> <label class="block text-sm font-bold text-gray-700 mb-1 flex items-center gap-2">
<Clock size={16} /> <Clock size={16} />
Availability for {bookingDate} {$t('booking.availabilityFor', { values: { date: bookingDate } })}
</label> </label>
{#if loadingReservations} {#if loadingReservations}
@ -278,7 +284,7 @@
{reservation ? 'bg-red-100 text-red-700 border-red-200' : {reservation ? 'bg-red-100 text-red-700 border-red-200' :
startTime === time ? 'bg-indigo-600 text-white border-indigo-700 ring-2 ring-indigo-200' : startTime === time ? 'bg-indigo-600 text-white border-indigo-700 ring-2 ring-indigo-200' :
'bg-white text-gray-600 border-gray-200 hover:border-indigo-400'}" 'bg-white text-gray-600 border-gray-200 hover:border-indigo-400'}"
title={reservation ? `Booked by ${reservation.user.email}` : `Available at ${time}`} title={reservation ? $t('booking.bookedBy', { values: { user: reservation.user.email } }) : $t('booking.availableAt', { values: { time: time } })}
> >
{time} {time}
</button> </button>
@ -299,7 +305,7 @@
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1"> <label class="block text-sm font-bold text-gray-700 mb-1">
Start Time {$t('booking.startTime')}
</label> </label>
<select bind:value={startTime} class="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none font-medium"> <select bind:value={startTime} class="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none font-medium">
{#each timeOptions as time} {#each timeOptions as time}
@ -309,7 +315,7 @@
</div> </div>
<div> <div>
<label class="block text-sm font-bold text-gray-700 mb-1"> <label class="block text-sm font-bold text-gray-700 mb-1">
End Time {$t('booking.endTime')}
</label> </label>
<select bind:value={endTime} class="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none font-medium"> <select bind:value={endTime} class="w-full p-3 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 outline-none font-medium">
{#each timeOptions as time} {#each timeOptions as time}

View File

@ -0,0 +1,11 @@
import { register, init, getLocaleFromNavigator } from 'svelte-i18n';
register('en', () => import('./locales/en.json'));
register('es-ES', () => import('./locales/es-ES.json'));
export function setupI18n(initialLocale = 'en') {
init({
fallbackLocale: 'en',
initialLocale: initialLocale || getLocaleFromNavigator() || 'en',
});
}

View File

@ -0,0 +1,69 @@
{
"nav": {
"organization": "Organization",
"users": "Users",
"reports": "Reports",
"dashboard": "Dashboard",
"myBookings": "My Bookings",
"profile": "Profile",
"login": "Login"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"edit": "Edit",
"delete": "Delete",
"actions": "Actions",
"loading": "Loading...",
"language": "Language"
},
"organization": {
"settings": "Organization Settings",
"name": "Organization Name",
"logo": "Organization Logo",
"updateSuccess": "Organization updated successfully"
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome back",
"selectBuilding": "Select a building to see available floors",
"noBuildings": "No buildings found"
},
"booking": {
"book": "Book",
"cancel": "Cancel",
"confirm": "Confirm Booking",
"startTime": "Start Time",
"endTime": "End Time",
"date": "Date",
"delete": "Delete Booking",
"confirmDelete": "Confirm Delete",
"adminWarning": "Warning: Admin Action",
"adminDeleteInfo": "You are about to delete another user's booking. This action cannot be undone.",
"unavailable": "This space is currently unavailable for booking.",
"bookingDate": "Booking Date",
"availabilityFor": "Availability for {date}",
"bookedBy": "Booked by {user}",
"availableAt": "Available at {time}",
"room": "Room",
"desk": "Desk",
"amenity": "Amenity",
"currentlyBooked": "Currently Booked",
"spaceType": {
"DESK": "Desk",
"MEETING_ROOM": "Meeting Room",
"AMENITY": "Amenity"
}
},
"myBookings": {
"title": "My Bookings",
"description": "Manage your upcoming and past reservations.",
"noBookings": "No bookings found",
"noBookingsDesc": "You haven't made any reservations yet.",
"goToDashboard": "Go to dashboard to book a space",
"cancelConfirm": "Are you sure you want to cancel this booking?",
"cancelFailed": "Failed to cancel booking",
"updateFailed": "Failed to update booking",
"loadFailed": "Failed to load bookings"
}
}

View File

@ -0,0 +1,69 @@
{
"nav": {
"organization": "Organización",
"users": "Usuarios",
"reports": "Informes",
"dashboard": "Panel de control",
"myBookings": "Mis reservas",
"profile": "Perfil",
"login": "Iniciar sesión"
},
"common": {
"save": "Guardar",
"cancel": "Cancelar",
"edit": "Editar",
"delete": "Eliminar",
"actions": "Acciones",
"loading": "Cargando...",
"language": "Idioma"
},
"organization": {
"settings": "Configuración de la organización",
"name": "Nombre de la organización",
"logo": "Logotipo de la organización",
"updateSuccess": "Organización actualizada con éxito"
},
"dashboard": {
"title": "Panel de control",
"welcome": "Bienvenido/a de nuevo",
"selectBuilding": "Seleccione un edificio para ver las plantas disponibles",
"noBuildings": "No se han encontrado edificios"
},
"booking": {
"book": "Reservar",
"cancel": "Cancelar",
"confirm": "Confirmar reserva",
"startTime": "Hora de inicio",
"endTime": "Hora de finalización",
"date": "Fecha",
"delete": "Eliminar reserva",
"confirmDelete": "Confirmar eliminación",
"adminWarning": "Advertencia: Acción de administrador",
"adminDeleteInfo": "Está a punto de eliminar la reserva de otro usuario. Esta acción no se puede deshacer.",
"unavailable": "Este espacio no está disponible actualmente para reservar.",
"bookingDate": "Fecha de la reserva",
"availabilityFor": "Disponibilidad para {date}",
"bookedBy": "Reservado por {user}",
"availableAt": "Disponible a las {time}",
"room": "Sala",
"desk": "Puesto",
"amenity": "Servicio",
"currentlyBooked": "Reservado actualmente",
"spaceType": {
"DESK": "Puesto",
"MEETING_ROOM": "Sala de reuniones",
"AMENITY": "Servicio"
}
},
"myBookings": {
"title": "Mis reservas",
"description": "Gestione sus reservas próximas y pasadas.",
"noBookings": "No se han encontrado reservas",
"noBookingsDesc": "Aún no ha realizado ninguna reserva.",
"goToDashboard": "Ir al panel para reservar un espacio",
"cancelConfirm": "¿Está seguro/a de que desea cancelar esta reserva?",
"cancelFailed": "Error al cancelar la reserva",
"updateFailed": "Error al actualizar la reserva",
"loadFailed": "Error al cargar las reservas"
}
}

View File

@ -5,6 +5,7 @@ export const organization = writable<{
id: string; id: string;
name: string; name: string;
logoUrl?: string; logoUrl?: string;
language?: string;
} | null>(null); } | null>(null);
export async function loadOrganization(orgId: string) { export async function loadOrganization(orgId: string) {

View File

@ -5,6 +5,10 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { resolveAssetUrl } from "$lib/utils/url"; import { resolveAssetUrl } from "$lib/utils/url";
import Avatar from "$lib/components/Avatar.svelte"; import Avatar from "$lib/components/Avatar.svelte";
import { setupI18n } from "$lib/i18n";
import { t, locale, waitLocale } from "svelte-i18n";
setupI18n();
onMount(async () => { onMount(async () => {
if ($user?.organizationId) { if ($user?.organizationId) {
@ -12,11 +16,20 @@
} }
}); });
$: if ($organization?.language) {
locale.set($organization.language);
}
$: if ($user?.organizationId && !$organization) { $: if ($user?.organizationId && !$organization) {
loadOrganization($user.organizationId); loadOrganization($user.organizationId);
} }
</script> </script>
{#await waitLocale()}
<div class="min-h-screen flex items-center justify-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
{:then}
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
<nav class="bg-white shadow-sm px-4 py-3 flex justify-between items-center"> <nav class="bg-white shadow-sm px-4 py-3 flex justify-between items-center">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
@ -32,18 +45,18 @@
<div class="space-x-4 flex items-center"> <div class="space-x-4 flex items-center">
{#if $token} {#if $token}
{#if $user?.role === 'ADMIN'} {#if $user?.role === 'ADMIN'}
<a href="/admin/organization" class="text-gray-600 hover:text-indigo-600 font-medium">Organization</a> <a href="/admin/organization" class="text-gray-600 hover:text-indigo-600 font-medium">{$t('nav.organization')}</a>
<a href="/admin/users" class="text-gray-600 hover:text-indigo-600 font-medium">Users</a> <a href="/admin/users" class="text-gray-600 hover:text-indigo-600 font-medium">{$t('nav.users')}</a>
<a href="/admin/reports" class="text-gray-600 hover:text-indigo-600 font-medium">Reports</a> <a href="/admin/reports" class="text-gray-600 hover:text-indigo-600 font-medium">{$t('nav.reports')}</a>
{/if} {/if}
<a href="/dashboard" class="text-gray-600 hover:text-indigo-600 font-medium">Dashboard</a> <a href="/dashboard" class="text-gray-600 hover:text-indigo-600 font-medium">{$t('nav.dashboard')}</a>
<a href="/dashboard/my-bookings" class="text-gray-600 hover:text-indigo-600 font-medium">My Bookings</a> <a href="/dashboard/my-bookings" class="text-gray-600 hover:text-indigo-600 font-medium">{$t('nav.myBookings')}</a>
<a href="/dashboard/profile" class="bg-indigo-50 text-indigo-700 px-3 py-1.5 rounded-md hover:bg-indigo-100 font-medium flex items-center space-x-2 transition-all"> <a href="/dashboard/profile" class="bg-indigo-50 text-indigo-700 px-3 py-1.5 rounded-md hover:bg-indigo-100 font-medium flex items-center space-x-2 transition-all">
<Avatar user={$user} size="sm" /> <Avatar user={$user} size="sm" />
<span>Profile</span> <span>{$t('nav.profile')}</span>
</a> </a>
{:else} {:else}
<a href="/login" class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 font-medium transition-colors">Login</a> <a href="/login" class="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 font-medium transition-colors">{$t('nav.login')}</a>
{/if} {/if}
</div> </div>
</nav> </nav>
@ -52,3 +65,4 @@
<slot /> <slot />
</main> </main>
</div> </div>
{/await}

View File

@ -4,9 +4,11 @@
import { apiFetch } from "$lib/api/client"; import { apiFetch } from "$lib/api/client";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { resolveAssetUrl } from "$lib/utils/url"; import { resolveAssetUrl } from "$lib/utils/url";
import { t } from "svelte-i18n";
let name = ""; let name = "";
let logoUrl = ""; let logoUrl = "";
let language = "en";
let logoFile: File | null = null; let logoFile: File | null = null;
let loading = false; let loading = false;
let success = false; let success = false;
@ -23,11 +25,18 @@
if ($organization) { if ($organization) {
name = $organization.name; name = $organization.name;
logoUrl = $organization.logoUrl || ""; logoUrl = $organization.logoUrl || "";
language = $organization.language || "en";
} }
} }
}); });
async function handleSubmit() { async function handleSubmit() {
const orgId = $user?.organizationId;
if (!orgId) {
error = "User organization ID is missing";
return;
}
loading = true; loading = true;
success = false; success = false;
error = ""; error = "";
@ -36,7 +45,7 @@
if (logoFile) { if (logoFile) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', logoFile); formData.append('file', logoFile);
const assetResponse = await apiFetch<any>(`/assets/organization/${$user?.organizationId}/logo`, { const assetResponse = await apiFetch<any>(`/assets/organization/${orgId}/logo`, {
method: 'POST', method: 'POST',
body: formData, body: formData,
}); });
@ -45,9 +54,9 @@
logoUrl = finalLogoUrl; logoUrl = finalLogoUrl;
} }
const updated = await apiFetch<any>(`/organizations/${$user?.organizationId}`, { const updated = await apiFetch<any>(`/organizations/${orgId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ name, logoUrl: finalLogoUrl || "" }), body: JSON.stringify({ name, logoUrl: finalLogoUrl || "", language }),
}); });
organization.set(updated); organization.set(updated);
success = true; success = true;
@ -95,12 +104,12 @@
</script> </script>
<div class="max-w-2xl mx-auto py-8"> <div class="max-w-2xl mx-auto py-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6">Organization Settings</h2> <h2 class="text-2xl font-bold text-gray-900 mb-6">{$t('organization.settings')}</h2>
<div class="bg-white shadow rounded-lg p-6"> <div class="bg-white shadow rounded-lg p-6">
<form on:submit|preventDefault={handleSubmit} class="space-y-6"> <form on:submit|preventDefault={handleSubmit} class="space-y-6">
<div> <div>
<label for="orgName" class="block text-sm font-medium text-gray-700">Organization Name</label> <label for="orgName" class="block text-sm font-medium text-gray-700">{$t('organization.name')}</label>
<input <input
id="orgName" id="orgName"
type="text" type="text"
@ -112,7 +121,19 @@
</div> </div>
<div> <div>
<label for="logoFile" class="block text-sm font-medium text-gray-700">Logo</label> <label for="orgLanguage" class="block text-sm font-medium text-gray-700">{$t('common.language')}</label>
<select
id="orgLanguage"
bind:value={language}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="en">English</option>
<option value="es-ES">Español (Ibérico)</option>
</select>
</div>
<div>
<label for="logoFile" class="block text-sm font-medium text-gray-700">{$t('organization.logo')}</label>
<div class="mt-1 flex items-center space-x-4"> <div class="mt-1 flex items-center space-x-4">
<input <input
id="logoFile" id="logoFile"
@ -130,7 +151,7 @@
<label for="logoUrl" class="block text-sm font-medium text-gray-700">Logo URL (Optional)</label> <label for="logoUrl" class="block text-sm font-medium text-gray-700">Logo URL (Optional)</label>
<input <input
id="logoUrl" id="logoUrl"
type="url" type="text"
bind:value={logoUrl} bind:value={logoUrl}
on:input={handleUrlChange} on:input={handleUrlChange}
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
@ -162,7 +183,7 @@
{/if} {/if}
{#if success} {#if success}
<div class="text-green-600 text-sm mt-2">Organization settings updated successfully!</div> <div class="text-green-600 text-sm mt-2">{$t('organization.updateSuccess')}</div>
{/if} {/if}
<div class="flex justify-end"> <div class="flex justify-end">
@ -171,7 +192,7 @@
disabled={loading} disabled={loading}
class="bg-indigo-600 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" class="bg-indigo-600 border border-transparent rounded-md shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
> >
{loading ? "Saving..." : "Save Changes"} {loading ? $t('common.loading') : $t('common.save')}
</button> </button>
</div> </div>
</form> </form>

View File

@ -6,6 +6,7 @@
import { Building2, ArrowRight, Plus, Edit2 } from "lucide-svelte"; import { Building2, ArrowRight, Plus, Edit2 } from "lucide-svelte";
import BuildingEditorModal from "$lib/components/admin/BuildingEditorModal.svelte"; import BuildingEditorModal from "$lib/components/admin/BuildingEditorModal.svelte";
import { resolveAssetUrl } from "$lib/utils/url"; import { resolveAssetUrl } from "$lib/utils/url";
import { t } from "svelte-i18n";
let buildings = []; let buildings = [];
let loading = true; let loading = true;
@ -48,8 +49,8 @@
<div class="p-8 max-w-6xl mx-auto"> <div class="p-8 max-w-6xl mx-auto">
<div class="flex justify-between items-center mb-8"> <div class="flex justify-between items-center mb-8">
<div> <div>
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1> <h1 class="text-3xl font-bold text-gray-900">{$t('dashboard.title')}</h1>
<p class="text-gray-500 mt-1">Welcome back, {$user?.email}!</p> <p class="text-gray-500 mt-1">{$t('dashboard.welcome')}, {$user?.email}!</p>
</div> </div>
{#if $user?.role === 'ADMIN'} {#if $user?.role === 'ADMIN'}
<button <button

View File

@ -4,6 +4,7 @@
import { apiFetch } from "$lib/api/client"; import { apiFetch } from "$lib/api/client";
import { Calendar, Clock, MapPin, Trash2, Edit2, ChevronRight, X } from "lucide-svelte"; import { Calendar, Clock, MapPin, Trash2, Edit2, ChevronRight, X } from "lucide-svelte";
import { format } from "date-fns"; import { format } from "date-fns";
import { t } from "svelte-i18n";
let reservations = []; let reservations = [];
let loading = true; let loading = true;
@ -23,7 +24,7 @@
try { try {
reservations = await apiFetch("/reservations/my"); reservations = await apiFetch("/reservations/my");
} catch (e) { } catch (e) {
error = "Failed to load bookings"; error = $t('myBookings.loadFailed');
console.error(e); console.error(e);
} finally { } finally {
loading = false; loading = false;
@ -31,13 +32,13 @@
} }
async function deleteReservation(id) { async function deleteReservation(id) {
if (!confirm("Are you sure you want to cancel this booking?")) return; if (!confirm($t('myBookings.cancelConfirm'))) return;
try { try {
await apiFetch(`/reservations/${id}`, { method: "DELETE" }); await apiFetch(`/reservations/${id}`, { method: "DELETE" });
reservations = reservations.filter(r => r.id !== id); reservations = reservations.filter(r => r.id !== id);
} catch (e) { } catch (e) {
alert("Failed to cancel booking"); alert($t('myBookings.cancelFailed'));
} }
} }
@ -65,26 +66,22 @@
await fetchReservations(); await fetchReservations();
closeEditModal(); closeEditModal();
} catch (e: any) { } catch (e: any) {
alert(e.message || "Failed to update booking"); alert(e.message || $t('myBookings.updateFailed'));
} finally { } finally {
saving = false; saving = false;
} }
} }
function getSpaceTypeLabel(type) { function getSpaceTypeLabel(type) {
switch (type) { return $t(`booking.spaceType.${type}`);
case 'DESK': return 'Desk';
case 'MEETING_ROOM': return 'Meeting Room';
default: return type;
}
} }
</script> </script>
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<div class="flex justify-between items-center mb-8"> <div class="flex justify-between items-center mb-8">
<div> <div>
<h1 class="text-3xl font-bold text-gray-900">My Bookings</h1> <h1 class="text-3xl font-bold text-gray-900">{$t('myBookings.title')}</h1>
<p class="text-gray-500 mt-1">Manage your upcoming and past reservations.</p> <p class="text-gray-500 mt-1">{$t('myBookings.description')}</p>
</div> </div>
</div> </div>
@ -99,10 +96,10 @@
{:else if reservations.length === 0} {:else if reservations.length === 0}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-12 text-center">
<Calendar class="mx-auto text-gray-300 mb-4" size={48} /> <Calendar class="mx-auto text-gray-300 mb-4" size={48} />
<h3 class="text-lg font-medium text-gray-900">No bookings found</h3> <h3 class="text-lg font-medium text-gray-900">{$t('myBookings.noBookings')}</h3>
<p class="text-gray-500 mt-1">You haven't made any reservations yet.</p> <p class="text-gray-500 mt-1">{$t('myBookings.noBookingsDesc')}</p>
<a href="/dashboard" class="mt-6 inline-flex items-center text-indigo-600 font-medium hover:underline"> <a href="/dashboard" class="mt-6 inline-flex items-center text-indigo-600 font-medium hover:underline">
Go to dashboard to book a space <ChevronRight size={16} /> {$t('myBookings.goToDashboard')} <ChevronRight size={16} />
</a> </a>
</div> </div>
{:else} {:else}

View File

@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { user } from '$lib/stores/auth'; import { user, logout } from '$lib/stores/auth';
import { apiFetch } from '$lib/api/client'; import { apiFetch } from '$lib/api/client';
import { goto } from '$app/navigation';
import Avatar from '$lib/components/Avatar.svelte'; import Avatar from '$lib/components/Avatar.svelte';
import { User, Mail, Shield, Briefcase, Save, Key, UserCircle } from 'lucide-svelte'; import { User, Mail, Shield, Briefcase, Save, Key, UserCircle, LogOut } from 'lucide-svelte';
let name = $user?.name || ''; let name = $user?.name || '';
let avatarUrl = $user?.avatarUrl || ''; let avatarUrl = $user?.avatarUrl || '';
@ -84,11 +85,15 @@
} catch (e: any) { } catch (e: any) {
error = e.message || 'Failed to upload avatar'; error = e.message || 'Failed to upload avatar';
} finally { } finally {
uploadingAvatar = false;
// Reset input // Reset input
if (fileInput) fileInput.value = ''; if (fileInput) fileInput.value = '';
} }
} }
function handleLogout() {
logout();
goto('/login');
}
</script> </script>
<div class="max-w-4xl mx-auto p-6"> <div class="max-w-4xl mx-auto p-6">
@ -152,6 +157,14 @@
<UserCircle size={16} class="text-gray-400 shrink-0" /> <UserCircle size={16} class="text-gray-400 shrink-0" />
<span class="truncate">ID: {$user?.id}</span> <span class="truncate">ID: {$user?.id}</span>
</div> </div>
<button
on:click={handleLogout}
class="w-full flex items-center justify-center gap-2 mt-4 py-2 px-4 border border-red-200 text-red-600 rounded-lg font-medium hover:bg-red-50 transition-all"
>
<LogOut size={16} />
Logout
</button>
</div> </div>
</div> </div>
</div> </div>