diff --git a/LOCALIZATION.md b/LOCALIZATION.md new file mode 100644 index 00000000..362b0a09 --- /dev/null +++ b/LOCALIZATION.md @@ -0,0 +1,273 @@ +# Frontend Localization Guide + +This document describes the localization system for the Whisper Money React frontend and the automated tools available. + +## Overview + +The application now supports multilingual UI text using a simple translation system: + +- **Hook**: `useTranslations()` (aliased as `__()`) +- **Translation Files**: `lang/es.json` (Spanish), with English as fallback +- **Coverage**: 118+ React components have been localized +- **Total Strings**: 730+ translatable strings + +## How It Works + +### Backend (Laravel) + +The `HandleInertiaRequests` middleware loads the appropriate translation file (`lang/{locale}.json`) and shares it with all Inertia page props: + +```php +protected function getTranslations(): array +{ + $locale = app()->getLocale(); + $translationFile = lang_path("{$locale}.json"); + return json_decode(file_get_contents($translationFile), true) ?? []; +} +``` + +### Frontend (React) + +Components use the `__()` function directly to translate strings: + +```tsx +import { __ } from '@/utils/i18n'; + +export default function MyComponent() { + return ( +
+

{__('Welcome to Whisper Money')}

+ +
+ ); +} +``` + +**Note**: The `__()` function uses React's `usePage()` hook internally, so it must be called within React components. + +### Frontend (React) + +Components use the `__()` hook to translate strings: + +```tsx +import { __ } from '@/utils/i18n'; + +export default function MyComponent() { + const t = __(); + + return ( +
+

{t('Welcome to Whisper Money')}

+ +
+ ); +} +``` + +## Automated Localization Tools + +### Main Localization Script (Local Development Only) + +**File**: `scripts/localize-frontend.mjs` (not committed to repo) + +This script automatically wraps hardcoded strings with the `t()` translation function across all React components. + +**What it does**: + +- Scans all `.tsx` and `.ts` files in `resources/js/` +- Wraps JSX text content and specific props (title, placeholder, etc.) with `t()` +- Auto-imports the `__()` hook and adds `const t = __()` to components +- Extracts all unique strings to `lang/es.json` +- Generates a detailed report of changes + +**What it skips**: + +- Technical identifiers (class names, data-test attributes, etc.) +- URLs, email addresses +- Currency codes (USD, EUR, etc.) +- Brand names (Whisper Money, Discord, etc.) +- Single-character strings +- Code-like patterns + +**Usage** (local development only): + +```bash +# Run the localization script (safe to run multiple times) +node scripts/localize-frontend.mjs + +# Format the modified files +bun run format + +# Review changes +git diff resources/js/ +git diff lang/es.json +cat localization-report.txt +``` + +**Note**: The script is for local development only and not committed to the repository. Translations must be added manually to `lang/es.json`. + +## Current Status + +### Completed (✅) + +- ✅ 118 React components localized +- ✅ 730 strings extracted and wrapped with `t()` +- ✅ Translation infrastructure in place +- ✅ Automated extraction tool created +- ✅ English fallbacks for all strings + +### Pending (⏳) + +- ⏳ Spanish translations (currently English placeholders - manual translation needed) +- ⏳ Some files may need manual review for complex JSX patterns + +## Adding New Translatable Strings + +### Option 1: Manual (Recommended for new code) + +```tsx +import { __ } from '@/utils/i18n'; + +export default function NewComponent() { + return ( +
+

{__('My New Title')}

+ +
+ ); +} +``` + +Then add the translations to `lang/es.json`: + +```json +{ + "My New Title": "Mi Nuevo Título", + "Enter your name": "Ingresa tu nombre" +} +``` + +Then add the translations to `lang/es.json`: + +```json +{ + "My New Title": "Mi Nuevo Título", + "Enter your name": "Ingresa tu nombre" +} +``` + +### Option 2: Automated (For bulk updates - local development) + +1. Write your component with hardcoded English strings +2. Run `node scripts/localize-frontend.mjs` (if available locally) +3. Run `bun run format` +4. Manually translate new entries in `lang/es.json` + +## Translation with Placeholders + +Support for dynamic values: + +```tsx +// In your component +const message = __('Hello :name, you have :count unread messages', { + name: user.name, + count: unreadCount +}); + +// In lang/es.json +{ + "Hello :name, you have :count unread messages": "Hola :name, tienes :count mensajes sin leer" +} +``` + +## Testing Translations + +1. **Change language**: Go to Settings → Language → Select "Español" +2. **Test UI**: Navigate through the app and verify translations +3. **Check console**: Look for missing translation warnings (if implemented) + +## Translation Guidelines + +### For Spanish (es) + +- Use informal "tú" form (not formal "usted") +- Financial terms should be accurate and professional +- Preserve brand names in English +- Keep the friendly, approachable tone +- Use sentence case, not title case + +### Examples + +- ✅ "Guardar cambios" (Save changes) +- ❌ "Guardar Cambios" (wrong capitalization) +- ✅ "Iniciar sesión" (Log in) +- ❌ "Entrar" (too casual) + +## CI/CD Integration + +Consider adding translation validation to your CI pipeline in the future to ensure all strings are translated. + +## Troubleshooting + +### Issue: Translations not showing + +**Solution**: Check that: + +1. The string exists in `lang/es.json` +2. User's language setting is "Español" or auto-detected to Spanish +3. Browser cache is cleared + +### Issue: String wrapped incorrectly by script + +**Solution**: + +1. Revert the file: `git checkout ` +2. Manually wrap the string +3. Add the string pattern to skip list in `scripts/localize-frontend.mjs` + +### Issue: Script breaks complex JSX + +**Solution**: The script has limitations with very complex nested JSX. For these files: + +1. Revert the auto-changes +2. Manually localize +3. Or simplify the JSX structure + +## File Structure + +``` +lang/ +├── es.json # Spanish translations +├── es/ # Laravel backend translations (PHP) +│ ├── auth.php +│ ├── pagination.php +│ └── ... +└── en/ # English backend translations + +scripts/ +├── localize-frontend.mjs # Main localization tool +└── translate-to-spanish.mjs # AI translation helper + +resources/js/ +├── utils/i18n.ts # Translation hook +└── types/index.d.ts # SharedData type (includes translations) +``` + +## Next Steps + +1. **Manual translation**: Translate English placeholders in `lang/es.json` to Spanish +2. **Review translations**: Check accuracy, tone, and proper Spanish conventions +3. **Test thoroughly**: Test the entire app in Spanish +4. **Add more languages**: Create `lang/fr.json`, `lang/de.json`, etc. + +## Resources + +- Translation hook: `resources/js/utils/i18n.ts` +- Example localized component: `resources/js/pages/auth/login.tsx` +- Example localized settings: `resources/js/pages/settings/account.tsx` +- Translation middleware: `app/Http/Middleware/HandleInertiaRequests.php` +- Spanish translations: `lang/es.json` + +## Development Scripts + +The localization automation script (`scripts/localize-frontend.mjs`) is available for local development only and is not committed to the repository. It uses Babel to parse and transform React components automatically. If you need to regenerate it, refer to the project's localization implementation history. diff --git a/app/Actions/CreateDefaultCategories.php b/app/Actions/CreateDefaultCategories.php index b762de6f..3f69323b 100644 --- a/app/Actions/CreateDefaultCategories.php +++ b/app/Actions/CreateDefaultCategories.php @@ -11,7 +11,8 @@ class CreateDefaultCategories */ public function handle(User $user): void { - $defaultCategories = self::getDefaultCategories(); + $locale = $user->locale ?? app()->getLocale(); + $defaultCategories = self::getDefaultCategories($locale); foreach ($defaultCategories as $category) { $user->categories()->firstOrCreate( @@ -22,11 +23,33 @@ class CreateDefaultCategories } /** - * Get the default categories configuration. + * Get the default categories configuration for a given locale. * * @return array */ - public static function getDefaultCategories(): array + public static function getDefaultCategories(string $locale = 'en'): array + { + $categories = self::getBaseCategories(); + + if ($locale === 'es') { + $translations = self::getSpanishTranslations(); + + return array_map(function (array $category) use ($translations) { + $category['name'] = $translations[$category['name']] ?? $category['name']; + + return $category; + }, $categories); + } + + return $categories; + } + + /** + * Get the base (English) categories configuration. + * + * @return array + */ + private static function getBaseCategories(): array { return [ [ @@ -409,4 +432,78 @@ class CreateDefaultCategories ], ]; } + + /** + * Get the Spanish translations for category names. + * + * @return array + */ + private static function getSpanishTranslations(): array + { + return [ + 'Food' => 'Alimentación', + 'Cafes, restaurants, bars' => 'Cafeterías, restaurantes, bares', + 'Groceries' => 'Supermercado', + 'Tobacco and alcohol' => 'Tabaco y alcohol', + 'Other groceries' => 'Otras compras de alimentación', + 'Food delivery' => 'Comida a domicilio', + 'Utility services' => 'Servicios del hogar', + 'Electricity' => 'Electricidad', + 'Natural gas' => 'Gas natural', + 'Rent and maintanence' => 'Alquiler y mantenimiento', + 'Telephone, internet, TV, computer' => 'Teléfono, internet, TV, ordenador', + 'Water' => 'Agua', + 'Other utility expenses' => 'Otros gastos del hogar', + 'Household goods' => 'Artículos del hogar', + 'Transportation' => 'Transporte', + 'Parking' => 'Aparcamiento', + 'Fuel' => 'Combustible', + 'Transportation expenses' => 'Gastos de transporte', + 'Vehicle purchase, maintenance' => 'Compra y mantenimiento de vehículo', + 'Clothing and shoes' => 'Ropa y calzado', + 'Leisure activities, traveling' => 'Ocio y viajes', + 'Gifts' => 'Regalos', + 'Books, newspapers, magazines' => 'Libros, periódicos, revistas', + 'Accommodation, travel expenses' => 'Alojamiento y gastos de viaje', + 'Sport and sports goods' => 'Deporte y artículos deportivos', + 'Theatre, music, cinema' => 'Teatro, música, cine', + 'Hobbies and other leisure time activites' => 'Hobbies y otras actividades de ocio', + 'Education, health and beauty' => 'Educación, salud y belleza', + 'Education and courses' => 'Educación y cursos', + 'Beauty, cosmetics' => 'Belleza y cosmética', + 'Health and pharmaceuticals' => 'Salud y farmacia', + 'Online transactions' => 'Transacciones en línea', + 'Online services' => 'Servicios en línea', + 'Insurance' => 'Seguros', + 'Investments' => 'Inversiones', + 'Savings' => 'Ahorros', + 'Other investments' => 'Otras inversiones', + 'Financial services and commission' => 'Servicios financieros y comisiones', + 'Fines' => 'Multas', + 'Mortgage' => 'Hipoteca', + 'Credit card repayment' => 'Pago de tarjeta de crédito', + 'Cash withdrawal' => 'Retiro de efectivo', + 'Gambling' => 'Apuestas', + 'Lottery' => 'Lotería', + 'Taxes and government fees' => 'Impuestos y tasas', + 'Invoices' => 'Facturas', + 'Personal transfers' => 'Transferencias personales', + 'Other personal transfers' => 'Otras transferencias personales', + 'Administrative violations' => 'Infracciones administrativas', + 'Other transfers' => 'Otras transferencias', + 'Other payments' => 'Otros pagos', + 'Salary' => 'Salario', + 'Regular income' => 'Ingresos regulares', + 'Work on demand' => 'Trabajo por encargo', + 'Income from rent' => 'Ingresos por alquiler', + 'Unemployment benefit' => 'Prestación por desempleo', + 'Tax return' => 'Devolución de impuestos', + 'Return debit' => 'Devolución de débito', + 'Own account' => 'Cuenta propia', + 'From account of relatives' => 'Desde cuenta de familiares', + 'Returned payments' => 'Pagos devueltos', + 'Credit cards' => 'Tarjetas de crédito', + 'Other incoming payments' => 'Otros ingresos', + ]; + } } diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index e18b759c..611b3f1d 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -34,6 +34,7 @@ class CreateNewUser implements CreatesNewUsers 'name' => $input['name'], 'email' => $input['email'], 'password' => $input['password'], + 'locale' => $this->detectLocaleFromRequest(), ]); if (! config('mail.email_verification_enabled')) { @@ -42,4 +43,19 @@ class CreateNewUser implements CreatesNewUsers return $user; } + + /** + * Detect locale from Accept-Language header. + */ + protected function detectLocaleFromRequest(): string + { + $acceptLanguage = request()->header('Accept-Language', ''); + + // Check if Spanish is preferred + if (preg_match('/^es(-|,|;)/i', $acceptLanguage) || $acceptLanguage === 'es') { + return 'es'; + } + + return 'en'; + } } diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php index e2c6eb1f..efb296ac 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -45,7 +45,9 @@ class ProfileController extends Controller */ public function update(ProfileUpdateRequest $request): RedirectResponse { - $request->user()->fill($request->validated()); + $data = $request->validated(); + + $request->user()->fill($data); if ($request->user()->isDirty('email')) { $request->user()->email_verified_at = null; diff --git a/app/Http/Middleware/BlockDemoAccountActions.php b/app/Http/Middleware/BlockDemoAccountActions.php index 119b0074..58acf939 100644 --- a/app/Http/Middleware/BlockDemoAccountActions.php +++ b/app/Http/Middleware/BlockDemoAccountActions.php @@ -18,7 +18,7 @@ class BlockDemoAccountActions public function handle(Request $request, Closure $next, ?string $mode = null): Response { - if (! $request->user()?->isDemoAccount()) { + if (! $request->user()?->isDemoAccount() || app()->environment('local')) { return $next($request); } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index c6ac1b6b..ad850346 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -40,7 +40,7 @@ class HandleInertiaRequests extends Middleware [$message, $author] = str(Inspiring::quotes()->random())->explode('-'); $user = $request->user(); - $isDemoAccount = $user?->isDemoAccount() ?? false; + $isDemoAccount = $user?->isDemoAccount() && ! app()->environment('local') ?? false; $isDemoQuery = $request->query('demo') === '1'; return [ @@ -94,6 +94,25 @@ class HandleInertiaRequests extends Middleware 'labels' => fn () => $user ? $user->labels() ->orderBy('name') ->get(['id', 'name', 'color']) : [], + 'locale' => app()->getLocale(), + 'translations' => $this->getTranslations(), ]; } + + /** + * Get all translations for the current locale as a flat key-value array. + * + * @return array + */ + protected function getTranslations(): array + { + $locale = app()->getLocale(); + $translationFile = lang_path("{$locale}.json"); + + if (! file_exists($translationFile)) { + return []; + } + + return json_decode(file_get_contents($translationFile), true) ?? []; + } } diff --git a/app/Http/Middleware/SetLocale.php b/app/Http/Middleware/SetLocale.php new file mode 100644 index 00000000..0c8de860 --- /dev/null +++ b/app/Http/Middleware/SetLocale.php @@ -0,0 +1,86 @@ +determineLocale($request); + + App::setLocale($locale); + + return $next($request); + } + + /** + * Determine the locale for the current request. + */ + protected function determineLocale(Request $request): string + { + // Priority 1: Check for lang query parameter (user override on welcome page) + if ($request->has('lang') && in_array($request->get('lang'), ['en', 'es'])) { + $locale = $request->get('lang'); + // Store in session so subsequent requests remember this choice + $request->session()->put('locale', $locale); + + return $locale; + } + + // Priority 2: Check authenticated user's locale preference + if ($request->user() && $request->user()->locale) { + return $request->user()->locale; + } + + // Priority 2b: Authenticated user without locale — detect and persist + if ($request->user()) { + $detected = $this->detectLocaleFromHeader($request); + + if (in_array($request->session()->get('locale'), ['en', 'es'])) { + $detected = $request->session()->get('locale'); + } + + $request->user()->update(['locale' => $detected]); + + return $detected; + } + + // Priority 3: Check session for previously detected locale + if ($request->session()->has('locale')) { + return $request->session()->get('locale'); + } + + // Priority 4: Detect from Accept-Language header + $detected = $this->detectLocaleFromHeader($request); + + // Store in session for subsequent requests + $request->session()->put('locale', $detected); + + return $detected; + } + + /** + * Detect locale from Accept-Language header. + */ + protected function detectLocaleFromHeader(Request $request): string + { + $acceptLanguage = $request->header('Accept-Language', ''); + + // Check if Spanish is preferred + if (preg_match('/^es(-|,|;)/i', $acceptLanguage) || $acceptLanguage === 'es') { + return 'es'; + } + + return 'en'; + } +} diff --git a/app/Http/Requests/Settings/ProfileUpdateRequest.php b/app/Http/Requests/Settings/ProfileUpdateRequest.php index 880e0aa7..8c977ab0 100644 --- a/app/Http/Requests/Settings/ProfileUpdateRequest.php +++ b/app/Http/Requests/Settings/ProfileUpdateRequest.php @@ -28,6 +28,7 @@ class ProfileUpdateRequest extends FormRequest Rule::unique(User::class)->ignore($this->user()->id), ], 'currency_code' => ['required', 'string', 'max:3', Rule::in(['USD', 'EUR', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD', 'CNY', 'INR', 'MXN'])], + 'locale' => ['nullable', 'string', Rule::in(['en', 'es'])], ]; } } diff --git a/app/Mail/Drip/FeedbackEmail.php b/app/Mail/Drip/FeedbackEmail.php index 255a0274..aaad53e0 100644 --- a/app/Mail/Drip/FeedbackEmail.php +++ b/app/Mail/Drip/FeedbackEmail.php @@ -37,7 +37,7 @@ class FeedbackEmail extends Mailable implements ShouldQueue public function envelope(): Envelope { return new Envelope( - subject: "How's Your Experience So Far?", + subject: __("How's Your Experience So Far?"), )->from(config('mail.from.address', 'hello@example.com'), 'Victor'); } diff --git a/app/Mail/Drip/ImportHelpEmail.php b/app/Mail/Drip/ImportHelpEmail.php index 5e955090..34c4e250 100644 --- a/app/Mail/Drip/ImportHelpEmail.php +++ b/app/Mail/Drip/ImportHelpEmail.php @@ -37,7 +37,7 @@ class ImportHelpEmail extends Mailable implements ShouldQueue public function envelope(): Envelope { return new Envelope( - subject: "Let's Import Your Transactions", + subject: __("Let's Import Your Transactions"), )->from(config('mail.from.address', 'hello@example.com'), 'Victor'); } diff --git a/app/Mail/Drip/OnboardingReminderEmail.php b/app/Mail/Drip/OnboardingReminderEmail.php index d2256898..d2beea99 100644 --- a/app/Mail/Drip/OnboardingReminderEmail.php +++ b/app/Mail/Drip/OnboardingReminderEmail.php @@ -37,7 +37,7 @@ class OnboardingReminderEmail extends Mailable implements ShouldQueue public function envelope(): Envelope { return new Envelope( - subject: 'Need Help Getting Started?', + subject: __('Need Help Getting Started?'), )->from(config('mail.from.address', 'hello@example.com'), 'Victor'); } diff --git a/app/Mail/Drip/PromoCodeEmail.php b/app/Mail/Drip/PromoCodeEmail.php index b388b3c4..14cbb635 100644 --- a/app/Mail/Drip/PromoCodeEmail.php +++ b/app/Mail/Drip/PromoCodeEmail.php @@ -37,7 +37,7 @@ class PromoCodeEmail extends Mailable implements ShouldQueue public function envelope(): Envelope { return (new Envelope( - subject: 'Your Founder Discount - 80% Off First Period' + subject: __('Your Founder Discount - 80% Off First Period') ))->from(config('mail.from.address', 'hello@example.com'), 'Victor'); } diff --git a/app/Mail/Drip/SubscriptionCancelledEmail.php b/app/Mail/Drip/SubscriptionCancelledEmail.php index f9c7426a..8dd96b92 100644 --- a/app/Mail/Drip/SubscriptionCancelledEmail.php +++ b/app/Mail/Drip/SubscriptionCancelledEmail.php @@ -37,7 +37,7 @@ class SubscriptionCancelledEmail extends Mailable implements ShouldQueue public function envelope(): Envelope { return new Envelope( - subject: "We're sorry to see you go", + subject: __("We're sorry to see you go"), )->from(config('mail.from.address', 'hello@example.com'), 'Victor'); } diff --git a/app/Mail/Drip/WelcomeEmail.php b/app/Mail/Drip/WelcomeEmail.php index d994e0b5..3de9f5f4 100644 --- a/app/Mail/Drip/WelcomeEmail.php +++ b/app/Mail/Drip/WelcomeEmail.php @@ -37,7 +37,7 @@ class WelcomeEmail extends Mailable implements ShouldQueue public function envelope(): Envelope { return new Envelope( - subject: 'Welcome to Whisper Money - Your Privacy-First Finance App', + subject: __('Welcome to Whisper Money - Your Privacy-First Finance App'), )->from(config('mail.from.address', 'hello@example.com'), 'Victor'); } diff --git a/app/Models/User.php b/app/Models/User.php index 3e4586a1..2f2ed416 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Enums\DripEmailType; use App\Notifications\VerifyEmailNotification; use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -16,7 +17,7 @@ use Laravel\Cashier\Billable; use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Pennant\Concerns\HasFeatures; -class User extends Authenticatable implements MustVerifyEmail +class User extends Authenticatable implements HasLocalePreference, MustVerifyEmail { /** @use HasFactory<\Database\Factories\UserFactory> */ use Billable, HasFactory, HasFeatures, HasUuids, Notifiable, TwoFactorAuthenticatable; @@ -33,6 +34,7 @@ class User extends Authenticatable implements MustVerifyEmail 'encryption_salt', 'onboarded_at', 'currency_code', + 'locale', ]; /** @@ -135,6 +137,11 @@ class User extends Authenticatable implements MustVerifyEmail return $this->email === config('app.demo.email'); } + public function preferredLocale(): string + { + return $this->locale ?? 'en'; + } + public function sendEmailVerificationNotification(): void { $this->notify(new VerifyEmailNotification); diff --git a/app/Notifications/VerifyEmailNotification.php b/app/Notifications/VerifyEmailNotification.php index 87aa0d50..a0aef5e7 100644 --- a/app/Notifications/VerifyEmailNotification.php +++ b/app/Notifications/VerifyEmailNotification.php @@ -13,7 +13,7 @@ class VerifyEmailNotification extends VerifyEmail return (new MailMessage) ->from(config('mail.from.address', 'hello@example.com'), 'Victor') - ->subject('Verify Your Email - Whisper Money') + ->subject(__('Verify Your Email - Whisper Money')) ->markdown('mail.verify-email', [ 'userName' => $notifiable->name, 'verificationUrl' => $verificationUrl, diff --git a/bootstrap/app.php b/bootstrap/app.php index be35e9f2..f55756cb 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -4,6 +4,7 @@ use App\Http\Middleware\EnsureBudgetsFeature; use App\Http\Middleware\EnsureUserIsSubscribed; use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; +use App\Http\Middleware\SetLocale; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -30,6 +31,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->web(append: [ HandleAppearance::class, + SetLocale::class, HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, \App\Http\Middleware\BlockDemoAccountActions::class.':auto', diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index b48fe78d..89130e5e 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -65,6 +65,7 @@ class UserFactory extends Factory return $this->state(fn (array $attributes) => [ 'onboarded_at' => now(), 'encryption_salt' => 'test-salt', + 'locale' => 'en', ]); } diff --git a/database/migrations/2026_01_22_143232_add_locale_to_users_table.php b/database/migrations/2026_01_22_143232_add_locale_to_users_table.php new file mode 100644 index 00000000..2dd35c61 --- /dev/null +++ b/database/migrations/2026_01_22_143232_add_locale_to_users_table.php @@ -0,0 +1,28 @@ +string('locale', 2)->nullable()->after('currency_code'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('locale'); + }); + } +}; diff --git a/lang/en/auth.php b/lang/en/auth.php new file mode 100644 index 00000000..6598e2c0 --- /dev/null +++ b/lang/en/auth.php @@ -0,0 +1,20 @@ + 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + +]; diff --git a/lang/en/pagination.php b/lang/en/pagination.php new file mode 100644 index 00000000..d4814118 --- /dev/null +++ b/lang/en/pagination.php @@ -0,0 +1,19 @@ + '« Previous', + 'next' => 'Next »', + +]; diff --git a/lang/en/passwords.php b/lang/en/passwords.php new file mode 100644 index 00000000..fad3a7d7 --- /dev/null +++ b/lang/en/passwords.php @@ -0,0 +1,22 @@ + 'Your password has been reset.', + 'sent' => 'We have emailed your password reset link.', + 'throttled' => 'Please wait before retrying.', + 'token' => 'This password reset token is invalid.', + 'user' => "We can't find a user with that email address.", + +]; diff --git a/lang/en/validation.php b/lang/en/validation.php new file mode 100644 index 00000000..63ec29a1 --- /dev/null +++ b/lang/en/validation.php @@ -0,0 +1,200 @@ + 'The :attribute field must be accepted.', + 'accepted_if' => 'The :attribute field must be accepted when :other is :value.', + 'active_url' => 'The :attribute field must be a valid URL.', + 'after' => 'The :attribute field must be a date after :date.', + 'after_or_equal' => 'The :attribute field must be a date after or equal to :date.', + 'alpha' => 'The :attribute field must only contain letters.', + 'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.', + 'alpha_num' => 'The :attribute field must only contain letters and numbers.', + 'any_of' => 'The :attribute field is invalid.', + 'array' => 'The :attribute field must be an array.', + 'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.', + 'before' => 'The :attribute field must be a date before :date.', + 'before_or_equal' => 'The :attribute field must be a date before or equal to :date.', + 'between' => [ + 'array' => 'The :attribute field must have between :min and :max items.', + 'file' => 'The :attribute field must be between :min and :max kilobytes.', + 'numeric' => 'The :attribute field must be between :min and :max.', + 'string' => 'The :attribute field must be between :min and :max characters.', + ], + 'boolean' => 'The :attribute field must be true or false.', + 'can' => 'The :attribute field contains an unauthorized value.', + 'confirmed' => 'The :attribute field confirmation does not match.', + 'contains' => 'The :attribute field is missing a required value.', + 'current_password' => 'The password is incorrect.', + 'date' => 'The :attribute field must be a valid date.', + 'date_equals' => 'The :attribute field must be a date equal to :date.', + 'date_format' => 'The :attribute field must match the format :format.', + 'decimal' => 'The :attribute field must have :decimal decimal places.', + 'declined' => 'The :attribute field must be declined.', + 'declined_if' => 'The :attribute field must be declined when :other is :value.', + 'different' => 'The :attribute field and :other must be different.', + 'digits' => 'The :attribute field must be :digits digits.', + 'digits_between' => 'The :attribute field must be between :min and :max digits.', + 'dimensions' => 'The :attribute field has invalid image dimensions.', + 'distinct' => 'The :attribute field has a duplicate value.', + 'doesnt_contain' => 'The :attribute field must not contain any of the following: :values.', + 'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.', + 'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.', + 'email' => 'The :attribute field must be a valid email address.', + 'encoding' => 'The :attribute field must be encoded in :encoding.', + 'ends_with' => 'The :attribute field must end with one of the following: :values.', + 'enum' => 'The selected :attribute is invalid.', + 'exists' => 'The selected :attribute is invalid.', + 'extensions' => 'The :attribute field must have one of the following extensions: :values.', + 'file' => 'The :attribute field must be a file.', + 'filled' => 'The :attribute field must have a value.', + 'gt' => [ + 'array' => 'The :attribute field must have more than :value items.', + 'file' => 'The :attribute field must be greater than :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than :value.', + 'string' => 'The :attribute field must be greater than :value characters.', + ], + 'gte' => [ + 'array' => 'The :attribute field must have :value items or more.', + 'file' => 'The :attribute field must be greater than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than or equal to :value.', + 'string' => 'The :attribute field must be greater than or equal to :value characters.', + ], + 'hex_color' => 'The :attribute field must be a valid hexadecimal color.', + 'image' => 'The :attribute field must be an image.', + 'in' => 'The selected :attribute is invalid.', + 'in_array' => 'The :attribute field must exist in :other.', + 'in_array_keys' => 'The :attribute field must contain at least one of the following keys: :values.', + 'integer' => 'The :attribute field must be an integer.', + 'ip' => 'The :attribute field must be a valid IP address.', + 'ipv4' => 'The :attribute field must be a valid IPv4 address.', + 'ipv6' => 'The :attribute field must be a valid IPv6 address.', + 'json' => 'The :attribute field must be a valid JSON string.', + 'list' => 'The :attribute field must be a list.', + 'lowercase' => 'The :attribute field must be lowercase.', + 'lt' => [ + 'array' => 'The :attribute field must have less than :value items.', + 'file' => 'The :attribute field must be less than :value kilobytes.', + 'numeric' => 'The :attribute field must be less than :value.', + 'string' => 'The :attribute field must be less than :value characters.', + ], + 'lte' => [ + 'array' => 'The :attribute field must not have more than :value items.', + 'file' => 'The :attribute field must be less than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be less than or equal to :value.', + 'string' => 'The :attribute field must be less than or equal to :value characters.', + ], + 'mac_address' => 'The :attribute field must be a valid MAC address.', + 'max' => [ + 'array' => 'The :attribute field must not have more than :max items.', + 'file' => 'The :attribute field must not be greater than :max kilobytes.', + 'numeric' => 'The :attribute field must not be greater than :max.', + 'string' => 'The :attribute field must not be greater than :max characters.', + ], + 'max_digits' => 'The :attribute field must not have more than :max digits.', + 'mimes' => 'The :attribute field must be a file of type: :values.', + 'mimetypes' => 'The :attribute field must be a file of type: :values.', + 'min' => [ + 'array' => 'The :attribute field must have at least :min items.', + 'file' => 'The :attribute field must be at least :min kilobytes.', + 'numeric' => 'The :attribute field must be at least :min.', + 'string' => 'The :attribute field must be at least :min characters.', + ], + 'min_digits' => 'The :attribute field must have at least :min digits.', + 'missing' => 'The :attribute field must be missing.', + 'missing_if' => 'The :attribute field must be missing when :other is :value.', + 'missing_unless' => 'The :attribute field must be missing unless :other is :value.', + 'missing_with' => 'The :attribute field must be missing when :values is present.', + 'missing_with_all' => 'The :attribute field must be missing when :values are present.', + 'multiple_of' => 'The :attribute field must be a multiple of :value.', + 'not_in' => 'The selected :attribute is invalid.', + 'not_regex' => 'The :attribute field format is invalid.', + 'numeric' => 'The :attribute field must be a number.', + 'password' => [ + 'letters' => 'The :attribute field must contain at least one letter.', + 'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.', + 'numbers' => 'The :attribute field must contain at least one number.', + 'symbols' => 'The :attribute field must contain at least one symbol.', + 'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.', + ], + 'present' => 'The :attribute field must be present.', + 'present_if' => 'The :attribute field must be present when :other is :value.', + 'present_unless' => 'The :attribute field must be present unless :other is :value.', + 'present_with' => 'The :attribute field must be present when :values is present.', + 'present_with_all' => 'The :attribute field must be present when :values are present.', + 'prohibited' => 'The :attribute field is prohibited.', + 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', + 'prohibited_if_accepted' => 'The :attribute field is prohibited when :other is accepted.', + 'prohibited_if_declined' => 'The :attribute field is prohibited when :other is declined.', + 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', + 'prohibits' => 'The :attribute field prohibits :other from being present.', + 'regex' => 'The :attribute field format is invalid.', + 'required' => 'The :attribute field is required.', + 'required_array_keys' => 'The :attribute field must contain entries for: :values.', + 'required_if' => 'The :attribute field is required when :other is :value.', + 'required_if_accepted' => 'The :attribute field is required when :other is accepted.', + 'required_if_declined' => 'The :attribute field is required when :other is declined.', + 'required_unless' => 'The :attribute field is required unless :other is in :values.', + 'required_with' => 'The :attribute field is required when :values is present.', + 'required_with_all' => 'The :attribute field is required when :values are present.', + 'required_without' => 'The :attribute field is required when :values is not present.', + 'required_without_all' => 'The :attribute field is required when none of :values are present.', + 'same' => 'The :attribute field must match :other.', + 'size' => [ + 'array' => 'The :attribute field must contain :size items.', + 'file' => 'The :attribute field must be :size kilobytes.', + 'numeric' => 'The :attribute field must be :size.', + 'string' => 'The :attribute field must be :size characters.', + ], + 'starts_with' => 'The :attribute field must start with one of the following: :values.', + 'string' => 'The :attribute field must be a string.', + 'timezone' => 'The :attribute field must be a valid timezone.', + 'unique' => 'The :attribute has already been taken.', + 'uploaded' => 'The :attribute failed to upload.', + 'uppercase' => 'The :attribute field must be uppercase.', + 'url' => 'The :attribute field must be a valid URL.', + 'ulid' => 'The :attribute field must be a valid ULID.', + 'uuid' => 'The :attribute field must be a valid UUID.', + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'custom-message', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap our attribute placeholder + | with something more reader friendly such as "E-Mail Address" instead + | of "email". This simply helps us make our message more expressive. + | + */ + + 'attributes' => [], + +]; diff --git a/lang/es.json b/lang/es.json new file mode 100644 index 00000000..a4c32d3c --- /dev/null +++ b/lang/es.json @@ -0,0 +1,1053 @@ +{ + "\"? This action cannot be undone.": "\"? Esta acción no se puede deshacer.", + "\"? This action cannot be undone. All budget periods, allocations, and transaction assignments will be permanently removed.": "\"? Esta acción no se puede deshacer. Todos los períodos de presupuesto, asignaciones y asignaciones de transacciones serán eliminados permanentemente.", + "$KO!F6LMHU1W%TAEQFZMD9": "$KO!F6LMHU1W%TAEQFZMD9", + "+ Add another column": "+ Agregar otra columna", + "amber": "ámbar", + "+ note": "+ nota", + ", and press": ", y presiona", + "...and 40+ more": "...y más de 40", + "/12 min": "/12 min", + "1. Data Controller": "1. Controlador de Datos", + "10. Children's Privacy": "10. Privacidad de los Niños", + "10. Limitation of Liability": "10. Limitación de Responsabilidad", + "11. Changes to This Privacy Policy": "11. Cambios a Esta Política de Privacidad", + "11. Indemnification": "11. Indemnización", + "12. Contact Us": "12. Contáctanos", + "12. Governing Law and Dispute Resolution": "12. Ley Aplicable y Resolución de Disputas", + "13. Changes to Terms": "13. Cambios a los Términos", + "14. Severability": "14. Divisibilidad", + "15. Entire Agreement": "15. Acuerdo Completo", + "16. Contact Us": "16. Contáctanos", + "2. Acceptance of Terms": "2. Aceptación de Términos", + "2. Information We Collect": "2. Información que Recopilamos", + "2FA Recovery Codes": "Códigos de Recuperación 2FA", + "3. How We Use Your Information": "3. Cómo Usamos Tu Información", + "3. Service Description": "3. Descripción del Servicio", + "4. Data Security and Encryption": "4. Seguridad de Datos y Encriptación", + "4. User Accounts and Responsibilities": "4. Cuentas de Usuario y Responsabilidades", + "5. Data Ownership and License": "5. Propiedad de Datos y Licencia", + "5. Third-Party Services": "5. Servicios de Terceros", + "6. Data Retention": "6. Retención de Datos", + "6. Intellectual Property Rights": "6. Derechos de Propiedad Intelectual", + "7. International Data Transfers": "7. Transferencias Internacionales de Datos", + "7. Payment Terms": "7. Términos de Pago", + "8. Termination": "8. Cancelación", + "8. Your Rights Under GDPR": "8. Tus Derechos Bajo GDPR", + "9. Cookies and Tracking": "9. Cookies y Rastreo", + "9. Disclaimers and Warranties": "9. Descargos de Responsabilidad y Garantías", + "@alext_money": "@alext_money", + "@davidk_dev": "@davidk_dev", + "@emmalou": "@emmalou", + "@jessicap": "@jessicap", + "@mike_tech": "@mike_tech", + "@sarahm_finance": "@sarahm_finance", + "A new verification link has been sent to the email address you provided during registration.": "Se ha enviado un nuevo enlace de verificación a la dirección de correo electrónico que proporcionaste durante el registro.", + "A new verification link has been sent to your email address.": "Se ha enviado un nuevo enlace de verificación a tu dirección de correo electrónico.", + "A unique encryption key is generated on your device. Only you have access to it—we never see or store it.": "Se genera una clave de encriptación única en tu dispositivo. Solo tú tienes acceso a ella, nosotros nunca la vemos ni la almacenamos.", + "AI can't help you with your transactions because they're end-to-end encrypted. This is intentional—we believe your financial data should never be fed into AI systems that you don't control.": "La IA no puede ayudarte con tus transacciones porque están encriptadas de extremo a extremo. Esto es intencional: creemos que tus datos financieros nunca deben ser alimentados a sistemas de IA que no controlas.", + "AI requires sending your data to external servers": "La IA requiere enviar tus datos a servidores externos", + "Accept responsibility for all activities that occur under your account": "Acepta la responsabilidad de todas las actividades que ocurran en tu cuenta", + "Access Controls:": "Controles de Acceso:", + "Access your data securely from any device with automatic syncing.": "Accede a tus datos de forma segura desde cualquier dispositivo con sincronización automática.", + "Access your data with end-to-end encryption to ensure privacy and security": "Accede a tus datos con encriptación de extremo a extremo para garantizar privacidad y seguridad", + "Access your finances on any device, anytime, anywhere.": "Accede a tus finanzas desde cualquier dispositivo, en cualquier momento y lugar.", + "Account": "Cuenta", + "Account Details": "Detalles de Cuenta", + "Account Information:": "Información de Cuenta:", + "Account Type": "Tipo de Cuenta", + "Account Types": "Tipos de Cuenta", + "Account is required": "Se requiere una cuenta", + "Account name": "Nombre de cuenta", + "Accounts": "Cuentas", + "Accounts Created": "Cuentas Creadas", + "Actions": "Acciones", + "Add Another Account": "Agregar Otra Cuenta", + "Add Condition": "Agregar Condición", + "Add Group": "Agregar Grupo", + "Add Labels": "Agregar Etiquetas", + "Add More Accounts": "Agregar Más Cuentas", + "Add More Accounts?": "¿Agregar Más Cuentas?", + "Add another account to track more of your finances.": "Agrega otra cuenta para rastrear más de tus finanzas.", + "All Set!": "¡Todo Listo!", + "Add Transaction": "Agregar Transacción", + "Add a new bank account to track your transactions.": "Agrega una nueva cuenta bancaria para rastrear tus transacciones.", + "Add a new category to organize your transactions.": "Agrega una nueva categoría para organizar tus transacciones.", + "Add a new label to tag your transactions.": "Agrega una nueva etiqueta para etiquetar tus transacciones.", + "Add labels": "Agregar etiquetas", + "Add labels...": "Agregar etiquetas...", + "Add note": "Agregar nota", + "Add notes...": "Agregar notas...", + "Add the app to your home screen for the best experience.": "Añade la app a tu pantalla de inicio para la mejor experiencia.", + "Add transaction": "Agregar transacción", + "Address:": "Dirección:", + "Alex T.": "Alex T.", + "All": "Todo", + "All Done!": "¡Todo Listo!", + "All data is stored on secure servers with encryption at rest": "Todos los datos se almacenan en servidores seguros con encriptación en reposo", + "All fees are non-refundable except as required by law or explicitly stated otherwise": "Todas las tarifas no son reembolsables excepto según lo requiera la ley o se indique explícitamente de otra manera", + "All transactions appear to be duplicates. No new transactions will be imported.": "Todas las transacciones parecen ser duplicadas. No se importarán transacciones nuevas.", + "All your transactions are already categorized.": "Todas tus transacciones ya están categorizadas.", + "Allocated Amount": "Cantidad Asignado", + "Allocated:": "Asignado:", + "Already have an account?": "¿Ya tienes una cuenta?", + "Amount": "Valor", + "Amount is required": "Se requiere un valor", + "Any bugs, viruses, or similar harmful components transmitted through the service": "Cualquier error, virus o componente dañino similar transmitido a través del servicio", + "Any errors or omissions in content or any loss or damage incurred from using content": "Cualquier error u omisión en el contenido o cualquier pérdida o daño incurrido por usar el contenido", + "Any interruption or cessation of transmission to or from the service": "Cualquier interrupción o cese de transmisión hacia o desde el servicio", + "Any unauthorized access to or use of our servers and/or personal information": "Cualquier acceso no autorizado o uso de nuestros servidores y/o información personal", + "Appearance": "Apariencia", + "Appearance settings": "Configuración de apariencia", + "Are you overspending? Know exactly where you stand.": "¿Estás gastando de más? Conoce exactamente dónde estás parado.", + "Are you sure you want to delete \"": "¿Estás seguro de que deseas eliminar \"", + "Are you sure you want to delete this balance record? This action cannot be undone.": "¿Estás seguro de que deseas eliminar este registro de balance? Esta acción no se puede deshacer.", + "Are you sure you want to delete your account?": "¿Estás seguro de que deseas eliminar tu cuenta?", + "As a developer, I appreciate the security architecture. This is how finance apps should be built.": "Como desarrollador, aprecio la arquitectura de seguridad. Así es como deberían construirse las apps de finanzas.", + "As a user in the European Union, you have the following rights regarding your personal data:": "Como usuario en la Unión Europea, tienes los siguientes derechos con respecto a tus datos personales:", + "Assign a new category": "Asignar una nueva categoría", + "At least one action is required": "Se requiere al menos una acción", + "Auto-detect": "Detectar automáticamente", + "Automatically categorize transactions with customizable rules and patterns.": "Categoriza automáticamente las transacciones con reglas y patrones personalizables.", + "Automation Rules": "Reglas de Automatización", + "Annual": "Anual", + "Automation rules": "Reglas de automatización", + "Automation rules settings": "Configuración de reglas de automatización", + "Available:": "Disponible:", + "Back": "Atrás", + "Back to Transactions": "Volver a Transacciones", + "Balance": "Balance", + "Balance (Optional)": "Balance (Opcional)", + "Balance Date": "Fecha del Balance", + "Balance History": "Historial de Balance", + "Balance Tracking": "Seguimiento de Balance", + "Balance evolution": "Evolución del balance", + "Bi-weekly": "Quincenal", + "Bank": "Banco", + "Bank accounts": "Cuentas bancarias", + "Bank logo preview": "Vista previa del logo del banco", + "Bank name": "Nombre del banco", + "Beautiful charts and graphs help you understand your spending patterns.": "Gráficos hermosos te ayudan a entender tus patrones de gasto.", + "Best Value": "Mejor Valor", + "Billed annually at": "Facturado anualmente a", + "Billed annually at $": "Facturado anualmente a $", + "Billing management is not available on the demo account.": "La gestión de facturación no está disponible en la cuenta demo.", + "blue": "azul", + "Browse Files": "Explorar Archivos", + "Budget Name": "Nombre del Presupuesto", + "Budget Spending": "Gasto del Presupuesto", + "Budget Tracking": "Seguimiento de Presupuesto", + "Budgets": "Presupuestos", + "Built for speed with instant sync and smooth interactions.": "Construido para velocidad con sincronización instantánea e interacciones fluidas.", + "By creating an account or using Whisper Money, you agree to be bound by these Terms of Service and our Privacy Policy. If you do not agree to these terms, you may not use our service. These terms constitute a legally binding agreement between you and Whisper Money.": "Al crear una cuenta o usar Whisper Money, aceptas estar sujeto a estos Términos de Servicio y nuestra Política de Privacidad. Si no aceptas estos términos, no puedes usar nuestro servicio. Estos términos constituyen un acuerdo legalmente vinculante entre tú y Whisper Money.", + "CSV, XLS, XLSX files": "Archivos CSV, XLS, XLSX", + "Cancel": "Cancelar", + "Cashflow": "Flujo de Efectivo", + "Cashflow Trend": "Tendencia del Flujo de Efectivo", + "Categories": "Categorías", + "Categories below 5% of total": "Categorías por debajo del 5% del total", + "Categories settings": "Configuración de categorías", + "Categorize": "Categorizar", + "Categorize Transactions": "Categorizar Transacciones", + "Category": "Categoría", + "Category (Optional)": "Categoría (Opcional)", + "Category name": "Nombre de categoría", + "Change": "Cambiar", + "Change category": "Cambiar categoría", + "Check Demo": "Ver Demo", + "Checking": "Cuenta Corriente", + "Choose the plan that works for you": "Elige el plan que funcione para ti", + "Clean interface, powerful features, and zero compromise on privacy. What more could you want?": "Interfaz limpia, funciones potentes y cero compromisos con la privacidad. ¿Qué más podrías querer?", + "Clear": "Limpiar", + "Clear Encryption Key?": "¿Borrar Clave de Encriptación?", + "Clear Key": "Borrar Clave", + "Clear selection": "Limpiar selección", + "Click here to resend the verification email.": "Haz clic aquí para reenviar el correo de verificación.", + "Click to lock encryption key": "Haz clic para bloquear la clave de encriptación", + "Click to unlock encryption key": "Haz clic para desbloquear la clave de encriptación", + "Client-Side Encryption": "Encriptación del Lado del Cliente", + "Close": "Cerrar", + "Color": "Color", + "Columns": "Columnas", + "Community": "Comunidad", + "Company Name:": "Nombre de la Empresa:", + "Conditions": "Condiciones", + "Conditions joined by:": "Condiciones unidas por:", + "Confirm": "Confirmar", + "Confirm Password": "Confirmar Contraseña", + "Confirm password": "Confirmar contraseña", + "Confirm your encryption password": "Confirma tu contraseña de encriptación", + "Confirm your password": "Confirma tu contraseña", + "Confirmation": "Confirmación", + "Consider saving more if possible.": "Considera ahorrar más si es posible.", + "Continue": "Continuar", + "Continue Setup": "Continuar Configuración", + "Continue to Import": "Continuar con Importación", + "Create \"": "Crear \"", + "Create Account": "Crear Cuenta", + "Create Automation Rule": "Crear Regla de Automatización", + "Create Budget": "Crear Presupuesto", + "Create Category": "Crear Categoría", + "Create Label": "Crear Etiqueta", + "Create Rule": "Crear Regla", + "Create Transaction": "Crear Transacción", + "Create Your Encryption Password": "Crea Tu Contraseña de Encriptación", + "Create Your First Account": "Crea Tu Primera Cuenta", + "Create an Account": "Crear una Cuenta", + "Creating...": "Creando...", + "Create a Password": "Crear una Contraseña", + "Create a new transaction": "Crea una nueva transacción", + "Create a new transaction.": "Crea una nueva transacción.", + "Create a rule to automatically categorize transactions and add labels.": "Crea una regla para categorizar automáticamente transacciones y agregar etiquetas.", + "Create a strong encryption password to secure your data": "Crea una contraseña de encriptación fuerte para proteger tus datos", + "Create account": "Crear cuenta", + "Create an account": "Crear una cuenta", + "Create budgets that adapt to your spending habits and goals.": "Crea presupuestos que se adaptan a tus hábitos de gasto y metas.", + "Create rules like \"If description contains 'AMAZON', categorize as Shopping\"": "Crea reglas como \"Si la descripción contiene 'AMAZON', categorizar como Compras\"", + "Create rules to automatically categorize your transactions based on patterns you define.": "Crea reglas para categorizar automáticamente tus transacciones basándote en patrones que definas.", + "Create, edit, and delete rules anytime": "Crea, edita y elimina reglas en cualquier momento", + "Credit Card": "Tarjeta de Crédito", + "Ctrl+B": "Ctrl+B", + "Ctrl+N": "Ctrl+N", + "Ctrl+R": "Ctrl+R", + "cyan": "cian", + "Currency": "Moneda", + "Current Balance": "Balance Actual", + "Current password": "Contraseña actual", + "Custom": "Personalizado", + "Custom Labels": "Etiquetas Personalizadas", + "Customize Categories": "Personalizar Categorías", + "Customize Your Categories": "Personaliza Tus Categorías", + "DD-MM-YYYY (e.g., 31-12-2024)": "DD-MM-YYYY (ej., 31-12-2024)", + "Dark": "Oscuro", + "Dashboard": "Panel", + "Data Accuracy:": "Precisión de Datos:", + "Daily spending and transactions": "Gastos diarios y transacciones", + "Data Imported": "Datos Importados", + "Data is Encrypted": "Los Datos Están Encriptados", + "Date": "Fecha", + "Date Format": "Formato de Fecha", + "Date is required": "Se requiere una fecha", + "David K.": "David K.", + "Delete": "Eliminar", + "Delete Account": "Eliminar Cuenta", + "Delete Automation Rule": "Eliminar Regla de Automatización", + "Delete Budget": "Eliminar Presupuesto", + "Delete Category": "Eliminar Categoría", + "Delete Label": "Eliminar Etiqueta", + "Delete Transaction": "Eliminar Transacción", + "Delete account": "Eliminar cuenta", + "Delete balance": "Eliminar balance", + "Delete your account and all of its resources": "Elimina tu cuenta y todos sus recursos", + "Deleting...": "Eliminando...", + "Demo encryption password:": "Contraseña de encriptación de demo:", + "Description": "Descripción", + "Description is required": "Se requiere una descripción", + "Details": "Detalles", + "Details →": "Detalles →", + "Disable 2FA": "Desactivar 2FA", + "Disable privacy mode": "Desactivar modo privado", + "Disabled": "Desactivado", + "Discord for 80% off": "Discord con 80% de descuento", + "Don't have an account?": "¿No tienes una cuenta?", + "Download as CSV or Excel format": "Descargar en formato CSV o Excel", + "Drop your file here, or click to browse": "Arrastra tu archivo aquí, o haz clic para explorar", + "Duplicate": "Duplicado", + "Duplicates": "Duplicados", + "E2E Encryption": "Encriptación E2E", + "Free & Private": "Gratis y privado", + "Each recovery code can be used once to access your account and will be removed after use. If you need more, click": "Cada código de recuperación se puede usar una vez para acceder a tu cuenta y será eliminado después de usarse. Si necesitas más, haz clic", + "Edit": "Editar", + "Edit Account": "Editar Cuenta", + "Edit Automation Rule": "Editar Regla de Automatización", + "Edit Balance": "Editar Balance", + "Edit Budget": "Editar Presupuesto", + "Edit Category": "Editar Categoría", + "Edit Label": "Editar Etiqueta", + "Edit Transaction": "Editar transacción", + "Edit account": "Editar cuenta", + "Edit budget": "Editar presupuesto", + "Email": "Correo Electrónico", + "Email address": "Dirección de correo electrónico", + "Email address, name, and password (encrypted)": "Dirección de correo electrónico, nombre y contraseña (encriptados)", + "Email password reset link": "Enviar enlace para restablecer contraseña", + "Email verification": "Verificación de correo electrónico", + "Email:": "Correo:", + "Emma L.": "Emma L.", + "Enable 2FA": "Activar 2FA", + "Enable privacy mode": "Activar modo privado", + "Enabled": "Activado", + "Encrypted on your device": "Encriptado en tu dispositivo", + "Encryption Password": "Contraseña de Encriptación", + "Encryption Set": "Encriptación Configurada", + "Encryption key not available. Please go back and set up encryption.": "Clave de encriptación no disponible. Por favor, vuelve atrás y configura la encriptación.", + "End-to-End Encryption": "Encriptación de Extremo a Extremo", + "Entertainment (Movies, Sports, Hobbies)": "Entretenimiento (Películas, Deportes, Hobbies)", + "Encryption key not available": "Clave de encriptación no disponible", + "End-to-End Encryption:": "Encriptación de Extremo a Extremo:", + "End-to-end encryption": "Encriptación de extremo a extremo", + "English": "Inglés", + "Ensure your account is using a long, random password to stay secure": "Asegúrate de que tu cuenta esté usando una contraseña larga y aleatoria para mantenerse segura", + "Ensure your account is using a long, random password to stay secure.": "Asegúrate de que tu cuenta esté usando una contraseña larga y aleatoria para mantenerte seguro.", + "Enter a strong encryption password": "Ingresa una contraseña de encriptación fuerte", + "Enter a strong password": "Ingresa una contraseña fuerte", + "Enter recovery code": "Ingresar código de recuperación", + "Enter your details below to create your account": "Ingresa tus datos a continuación para crear tu cuenta", + "Enter your email and password below to log in": "Ingresa tu correo electrónico y contraseña a continuación para iniciar sesión", + "Enter your email to receive a password reset link": "Ingresa tu correo electrónico para recibir un enlace para restablecer contraseña", + "Enter your encryption password": "Ingresa tu contraseña de encriptación", + "Enter your encryption password to decrypt transactions information and accounts name.": "Ingresa tu contraseña de encriptación para descifrar la información de transacciones y nombres de cuentas.", + "Error": "Error", + "Errors (": "Errores (", + "Español": "Español", + "Even we can't access your data. It's truly private.": "Ni siquiera nosotros podemos acceder a tus datos. Es verdaderamente privado.", + "Every transaction belongs to one of three types:": "Cada transacción pertenece a uno de tres tipos:", + "Everything you need to manage your finances securely.": "Todo lo que necesitas para gestionar tus finanzas de forma segura.", + "Everything you need. Nothing you don't.": "Todo lo que necesitas. Nada que no.", + "emerald": "esmeralda", + "Expense": "Gasto", + "Expense Categories": "Categorías de Gastos", + "Expense tracking": "Seguimiento de gastos", + "Expenses": "Gastos", + "Export": "Exportar", + "Export Data": "Exportar Datos", + "Export from any bank": "Exporta desde cualquier banco", + "Failed to create transaction": "Error al crear la transacción", + "Failed to decrypt message. Please check your password and try again.": "Error al descifrar el mensaje. Por favor verifica tu contraseña e inténtalo de nuevo.", + "Failed to update transaction": "Error al actualizar la transacción", + "Feedback": "Comentarios", + "Filter": "Filtrar", + "Filter accounts...": "Filtrar cuentas...", + "Filter categories...": "Filtrar categorías...", + "Filter labels...": "Filtrar etiquetas...", + "Filter rules...": "Filtrar reglas...", + "Filter rules....": "Filtrar reglas....", + "Filters": "Filtros", + "Finally, a finance app that respects my privacy. The encryption gives me peace of mind.": "Finalmente, una app de finanzas que respeta mi privacidad. La encriptación me da tranquilidad.", + "Financial Advice Disclaimer:": "Descargo de Responsabilidad sobre Asesoramiento Financiero:", + "Financial Data:": "Datos Financieros:", + "Financial decisions made based on data in the application": "Decisiones financieras tomadas basándose en datos de la aplicación", + "Finding historical transactions": "Buscando transacciones históricas", + "Finish Setup": "Finalizar Configuración", + "Forgot password": "Olvidé mi contraseña", + "Forgot password?": "¿Olvidaste tu contraseña?", + "From": "Desde", + "fuchsia": "fucsia", + "Full name": "Nombre completo", + "Fully transparent and open source. Review the code yourself.": "Totalmente transparente y de código abierto. Revisa el código tú mismo.", + "Get Started": "Comenzar", + "Get started quickly with your existing financial data.": "Comienza rápidamente con tus datos financieros existentes.", + "Github": "Github", + "Go to Dashboard": "Ir al Panel", + "Go to your account's transaction history": "Ve al historial de transacciones de tu cuenta", + "Good progress on your savings.": "Buen progreso en tus ahorros.", + "gray": "gris", + "green": "verde", + "Great Progress!": "¡Gran Progreso!", + "Great job! You're saving well.": "¡Excelente trabajo! Estás ahorrando bien.", + "Got it": "Entendido", + "Groups joined by:": "Grupos unidos por:", + "How End-to-End Encryption Works": "Cómo Funciona la Encriptación de Extremo a Extremo", + "How much do you want to budget per period?": "¿Cuánto deseas presupuestar por período?", + "How to Export from Your Bank:": "Cómo Exportar desde Tu Banco:", + "I Understand, Continue": "Entiendo, Continuar", + "I switched from Mint and haven't looked back. The privacy features are unmatched.": "Cambié desde Mint y no he mirado atrás. Las características de privacidad no tienen comparación.", + "IP address, browser type, device information, and operating system": "Dirección IP, tipo de navegador, información del dispositivo y sistema operativo", + "Icon": "Ícono", + "If you have any questions, concerns, or feedback about these Terms of Service, please contact us:": "Si tienes alguna pregunta, inquietud o comentario sobre estos Términos de Servicio, por favor contáctanos:", + "If you have any questions, concerns, or requests regarding this Privacy Policy or our data practices, please contact us:": "Si tienes alguna pregunta, inquietud o solicitud con respecto a esta Política de Privacidad o nuestras prácticas de datos, por favor contáctanos:", + "Ignore": "Ignorar", + "indigo": "índigo", + "Install App": "Instalar App", + "Install Whisper Money": "Instalar Whisper Money", + "Import": "Importar", + "Import Transactions": "Importar Transacciones", + "Import Your Transactions": "Importa Tus Transacciones", + "Import Your Transactions in Seconds": "Importa Tus Transacciones en Segundos", + "Import a year's worth of transactions in under 10 seconds. Simply export a CSV or XLS file from your bank and drag it into Whisper Money. All data is encrypted locally before upload.": "Importa transacciones de un año completo en menos de 10 segundos. Simplemente exporta un archivo CSV o XLS desde tu banco y arrástralo a Whisper Money. Todos los datos se encriptan localmente antes de subirse.", + "Import balance history from CSV files": "Importar historial de balances desde archivos CSV", + "Import balances": "Importar balances", + "Import in seconds": "Importa en segundos", + "Import transactions": "Importar transacciones", + "Import transactions from CSV/Excel": "Importar transacciones desde CSV/Excel", + "Income": "Ingresos", + "Income Sources": "Fuentes de Ingresos", + "Income minus expenses": "Ingresos menos gastos", + "Information about how you use our service, including access times and features used": "Información sobre cómo usas nuestro servicio, incluyendo horarios de acceso y funciones utilizadas", + "Instant Application": "Aplicación Instantánea", + "Intelligent insights": "Análisis inteligentes", + "Investment": "Inversión", + "Jessica P.": "Jessica P.", + "Join our Discord": "Únete a nuestro Discord", + "Join thousands of users who have taken control of their finances without compromising their privacy.": "Únete a miles de usuarios que han tomado el control de sus finanzas sin comprometer su privacidad.", + "KB max.": "KB máx.", + "Keep me logged in": "Mantenerme conectado", + "Keep me logged in (convenient)": "Mantenerme conectado (conveniente)", + "Keep me logged in (less secure)": "Mantenerme conectado (menos seguro)", + "Key Storage": "Almacenamiento de Clave", + "Label (Optional)": "Etiqueta (Opcional)", + "Label name": "Nombre de etiqueta", + "Labels": "Etiquetas", + "Labels settings": "Configuración de etiquetas", + "Language": "Idioma", + "lime": "lima", + "Last updated:": "Última actualización:", + "Latest transactions in this account": "Últimas transacciones en esta cuenta", + "Let's Get Started": "Comencemos", + "Lifetime": "De por vida", + "Lifetime License": "Licencia de Por Vida", + "Lifetime updates": "Actualizaciones de por vida", + "Light": "Claro", + "Lightning fast": "Ultrarrápido", + "Lightning-Fast CSV/XLS Import": "Importación Ultrarrápida de CSV/XLS", + "Load more": "Cargar más", + "Loading": "Cargando", + "Loading last balance...": "Cargando último balance...", + "Loading recovery codes": "Cargando códigos de recuperación", + "Loading...": "Cargando...", + "Loan": "Préstamo", + "Lock encryption key": "Bloquear clave de encriptación", + "Log in": "Iniciar sesión", + "Log in to your account": "Inicia sesión en tu cuenta", + "Log in to your bank's website or app": "Inicia sesión en el sitio web o app de tu banco", + "Log out": "Cerrar sesión", + "Logo": "Logo", + "Look for \"Export\" or \"Download\" option": "Busca la opción \"Exportar\" o \"Descargar\"", + "Love that my financial data is encrypted. No more worrying about data breaches!": "Me encanta que mis datos financieros estén encriptados. ¡No más preocupaciones por violaciones de datos!", + "Love the simplicity. It's powerful but not overwhelming like other apps.": "Me encanta la simplicidad. Es potente pero no abrumadora como otras apps.", + "Lower numbers execute first": "Los números más bajos se ejecutan primero", + "MM-DD-YYYY (e.g., 12-31-2024)": "MM-DD-YYYY (ej., 12-31-2024)", + "Maintain and promptly update your account information": "Mantén y actualiza prontamente tu información de cuenta", + "Manage Plan": "Gestionar Plan", + "Manage Subscription": "Gestionar Suscripción", + "Manage your bank accounts": "Gestiona tus cuentas bancarias", + "Manage your profile and account settings": "Gestiona tu perfil y configuración de cuenta", + "Manage your subscription, update payment methods, or view invoices through the Stripe billing portal.": "Gestiona tu suscripción, actualiza métodos de pago o consulta facturas a través del portal de facturación de Stripe.", + "Manage your transaction automation rules": "Gestiona tus reglas de automatización de transacciones", + "Manage your transaction automation rules. Rules will be applied to categorize transactions automatically.": "Gestiona tus reglas de automatización de transacciones. Las reglas se aplicarán para categorizar transacciones automáticamente.", + "Manage your transaction categories": "Gestiona tus categorías de transacciones", + "Manage your transaction labels": "Gestiona tus etiquetas de transacciones", + "Manage your two-factor authentication settings": "Gestiona tu configuración de autenticación de dos factores", + "Manually record and track your financial transactions, budgets, and expenses": "Registra y rastrea manualmente tus transacciones financieras, presupuestos y gastos", + "Max": "Máx", + "Michael R.": "Michael R.", + "Min": "Mín", + "Money Flow": "Flujo de Dinero", + "Month": "Mes", + "Monthly": "Mensual", + "Monthly income, expenses, and net cashflow over the last 12 months": "Ingresos, gastos y flujo de efectivo neto mensual durante los últimos 12 meses", + "More": "Más", + "More actions": "Más acciones", + "More options": "Más opciones", + "Most Popular": "Más Popular", + "Multi-Device Sync": "Sincronización Multi-Dispositivo", + "Name": "Nombre", + "neutral": "neutro", + "Net": "Neto", + "Net Cashflow": "Flujo de Efectivo Neto", + "Net Worth Evolution": "Evolución del Patrimonio Neto", + "New": "Nuevo", + "New password": "Nueva contraseña", + "Next": "Siguiente", + "Next month": "Mes siguiente", + "No AI Snooping": "Sin Espionaje por IA", + "No AI. No bank connections. Your privacy is our priority.": "Sin IA. Sin conexiones bancarias. Tu privacidad es nuestra prioridad.", + "No Bank Access Required": "Sin Acceso Bancario Requerido", + "No Uncategorized Transactions": "Sin Transacciones Sin Categorizar", + "No account data available": "No hay datos de cuentas disponibles", + "No accounts found.": "No se encontraron cuentas.", + "No accounts found. Add your first account in Settings.": "No se encontraron cuentas. Agrega tu primera cuenta en Configuración.", + "No accounts found. Please create an account first.": "No se encontraron cuentas. Por favor crea una cuenta primero.", + "No automation rules found.": "No se encontraron reglas de automatización.", + "No active period": "Sin periodo activo", + "No balance data available": "No hay datos de balance disponibles", + "No balance records found.": "No se encontraron registros de balance.", + "No cashflow data for this period": "Sin datos de flujo de efectivo para este período", + "No categories found.": "No se encontraron categorías.", + "No category found.": "No se encontró categoría.", + "No expenses this period": "Sin gastos en este período", + "No income this period": "Sin ingresos en este período", + "No labels found.": "No se encontraron etiquetas.", + "No results found": "No se encontraron resultados", + "No spending data this month": "Sin datos de gasto este mes", + "No transactions found.": "No se encontraron transacciones.", + "No tracking": "Sin seguimiento", + "None": "Ninguno", + "None (Remove)": "Ninguno (Eliminar)", + "Notes": "Notas", + "Nothing in these Terms excludes or limits our liability for death or personal injury caused by negligence, fraud, or any liability that cannot be excluded or limited under applicable law.": "Nada en estos Términos excluye o limita nuestra responsabilidad por muerte o lesiones personales causadas por negligencia, fraude, o cualquier responsabilidad que no pueda ser excluida o limitada bajo la ley aplicable.", + "Notify us immediately of any unauthorized access or security breach": "Notifícanos inmediatamente de cualquier acceso no autorizado o violación de seguridad", + "Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Una vez que tu cuenta sea eliminada, todos sus recursos y datos también serán eliminados permanentemente. Por favor ingresa tu contraseña para confirmar que deseas eliminar permanentemente tu cuenta.", + "Only you know this password. It never leaves your device.": "Solo tú conoces esta contraseña. Nunca sale de tu dispositivo.", + "orange": "naranja", + "Open menu": "Abrir menú", + "Open source": "Código abierto", + "Or, return to": "O, regresar a", + "Organize financial data with categories and custom labels": "Organiza datos financieros con categorías y etiquetas personalizadas", + "Organize transactions with tags that make sense for your workflow.": "Organiza transacciones con etiquetas que tengan sentido para tu flujo de trabajo.", + "Other": "Otros", + "Other Categories (": "Otras Categorías (", + "Others": "Otros", + "Our service is not intended for users under the age of 16. We do not knowingly collect personal information from children. If you believe we have collected information from a child, please contact us immediately, and we will delete it.": "Nuestro servicio no está destinado a usuarios menores de 16 años. No recopilamos intencionalmente información personal de niños. Si crees que hemos recopilado información de un niño, por favor contáctanos inmediatamente y la eliminaremos.", + "Overview of your financial health": "Vista general de tu salud financiera", + "Page": "Página", + "Password": "Contraseña", + "pink": "rosa", + "purple": "púrpura", + "Password changes are disabled on the demo account.": "Los cambios de contraseña están deshabilitados en la cuenta demo.", + "Password settings": "Configuración de contraseña", + "Passwords match": "Las contraseñas coinciden", + "Pattern Matching": "Coincidencia de Patrones", + "Payment Processors:": "Procesadores de Pago:", + "Payments are processed by third-party payment processors and subject to their terms": "Los pagos son procesados por procesadores de pago de terceros y están sujetos a sus términos", + "Percentage of income saved": "Porcentaje de ingresos ahorrados", + "Perfect for investment portfolios and retirement accounts": "Perfecto para carteras de inversión y cuentas de jubilación", + "Period Duration (days)": "Duración del Período (días)", + "Period Type": "Tipo de Período", + "Platform": "Plataforma", + "Please enter your new password below": "Por favor ingresa tu nueva contraseña a continuación", + "Please proceed with caution, this cannot be undone.": "Por favor procede con precaución, esto no se puede deshacer.", + "Please unlock your encryption key to import transactions": "Por favor desbloquea tu clave de encriptación para importar transacciones", + "Please unlock your encryption key to save transactions": "Por favor desbloquea tu clave de encriptación para guardar transacciones", + "Please verify your email address by clicking on the link we just emailed to you.": "Por favor verifica tu dirección de correo electrónico haciendo clic en el enlace que te acabamos de enviar.", + "Prev": "Ant", + "Preview (first 3 rows)": "Vista previa (primeras 3 filas)", + "Preview Balances": "Vista Previa de Balances", + "Preview Transactions": "Vista Previa de Transacciones", + "Previous": "Anterior", + "Previous month": "Mes anterior", + "Priority": "Prioridad", + "Priority support": "Soporte prioritario", + "Privacy Policy": "Política de Privacidad", + "Privacy Policy - Whisper Money": "Política de Privacidad - Whisper Money", + "Privacy by Design": "Privacidad por Diseño", + "Privacy comes first": "La privacidad es lo primero", + "Privacy policy for Whisper Money. Learn how we collect, use, and protect your personal information with end-to-end encryption.": "Política de privacidad de Whisper Money. Aprende cómo recopilamos, usamos y protegemos tu información personal con encriptación de extremo a extremo.", + "Privacy policy for Whisper Money. Learn how we collect, use, and protect your personal information.": "Política de privacidad de Whisper Money. Aprende cómo recopilamos, usamos y protegemos tu información personal.", + "Pro Monthly": "Pro Mensual", + "Pro Plan Active": "Plan Pro Activo", + "Pro Yearly": "Pro Anual", + "Profile Settings": "Configuración del Perfil", + "Profile information": "Información del perfil", + "Protect your privacy with no tracking or third-party analytics.": "Protege tu privacidad sin rastreo ni análisis de terceros.", + "red": "rojo", + "Re-evaluate All Expenses": "Reevaluar Todos los Gastos", + "Re-evaluate rules": "Reevaluar reglas", + "Recovery Codes": "Códigos de Recuperación", + "Recovery codes": "Códigos de recuperación", + "Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.": "Los códigos de recuperación te permiten recuperar acceso si pierdes tu dispositivo 2FA. Guárdalos en un administrador de contraseñas seguro.", + "Regenerate Codes": "Regenerar Códigos", + "Register": "Registrarse", + "Regular Security Audits:": "Auditorías de Seguridad Regulares:", + "Remaining": "Restante", + "Remember me": "Recuérdame", + "Remove all labels": "Eliminar todas las etiquetas", + "Resend verification email": "Reenviar correo de verificación", + "Reset password": "Restablecer contraseña", + "Retirement / Pension": "Jubilación / Pensión", + "Review and contribute to our open-source codebase on GitHub.": "Revisa y contribuye a nuestro código de código abierto en GitHub.", + "Right of Access:": "Derecho de Acceso:", + "Right to Data Portability:": "Derecho a la Portabilidad de Datos:", + "Right to Erasure:": "Derecho al Olvido:", + "Right to Lodge a Complaint:": "Derecho a Presentar una Queja:", + "Right to Object:": "Derecho a Objetar:", + "Right to Rectification:": "Derecho de Rectificación:", + "Right to Restriction:": "Derecho de Restricción:", + "Right to Withdraw Consent:": "Derecho a Retirar el Consentimiento:", + "Roadmap": "Hoja de Ruta", + "Rollover Type": "Tipo de Transferencia", + "rose": "rosado", + "Row": "Fila", + "Rule \":rule\" applied": "Regla \":rule\" aplicada", + "Rule title": "Título de regla", + "Rules": "Reglas", + "Rules apply automatically when you import new transactions": "Las reglas se aplican automáticamente cuando importas nuevas transacciones", + "STARBUCKS@TEKKA PLC": "STARBUCKS@TEKKA PLC", + "Sarah M.": "Sarah M.", + "Save": "Guardar", + "Save Balance": "Guardar Balance", + "Save Changes": "Guardar Cambios", + "Save password": "Guardar contraseña", + "Saved": "Guardado", + "Saving...": "Guardando...", + "Savings": "Ahorros", + "Savings Rate": "Tasa de Ahorro", + "Savings rate": "Tasa de ahorro", + "Search": "Buscar", + "Search bank...": "Buscar banco...", + "Search categories...": "Buscar categorías...", + "Search description or notes...": "Buscar descripción o notas...", + "Search disabled (encryption key not set)": "Búsqueda deshabilitada (clave de encriptación no configurada)", + "Search labels...": "Buscar etiquetas...", + "Search or create labels...": "Buscar o crear etiquetas...", + "Search, move": "Buscar, mover", + "Secure Storage:": "Almacenamiento Seguro:", + "See balances": "Ver balances", + "Select a category": "Seleccionar una categoría", + "Select a category (optional)": "Seleccionar una categoría (opcional)", + "Select a color": "Seleccionar un color", + "Select a label": "Seleccionar una etiqueta", + "Select a type": "Seleccionar un tipo", + "Select account": "Seleccionar cuenta", + "Select account type": "Seleccionar tipo de cuenta", + "Select all": "Seleccionar todo", + "Select all transactions": "Seleccionar todas las transacciones", + "Select amount column": "Seleccionar columna de valor", + "Select an icon": "Seleccionar un icono", + "Select at least a category or a label to track.": "Selecciona al menos una categoría o etiqueta para rastrear.", + "Select balance column": "Seleccionar columna de balance", + "Select balance column (optional)": "Seleccionar columna de balance (opcional)", + "Select categories...": "Seleccionar categorías...", + "Select currency": "Seleccionar moneda", + "Select date column": "Seleccionar columna de fecha", + "Select description column": "Seleccionar columna de descripción", + "Select labels (optional)": "Seleccionar etiquetas (opcional)", + "Select labels...": "Seleccionar etiquetas...", + "Select language": "Seleccionar idioma", + "Select row": "Seleccionar fila", + "Selected": "Seleccionado", + "Selected account not found": "Cuenta seleccionada no encontrada", + "Service Availability:": "Disponibilidad del Servicio:", + "Session only": "Solo sesión", + "Session only (more secure)": "Solo sesión (más seguro)", + "Set Account Balance": "Establecer Balance de Cuenta", + "Set Category": "Establecer Categoría", + "Set spending limits and get real-time updates on your financial goals.": "Establece límites de gasto y obtén actualizaciones en tiempo real sobre tus metas financieras.", + "Set the balance for this account on a specific date.": "Actualiza el balance de esta cuenta.", + "Set up a spending limit for a category or label.": "Configura un límite de gasto para un categoría o etiqueta.", + "Setting things up...": "Configurando...", + "Settings": "Configuración", + "Setup Encryption": "Configura tu encriptación", + "Sign up": "Crear cuenta", + "Simple, transparent pricing": "Precios simples y transparentes", + "Skip": "Saltar", + "slate": "pizarra", + "Smart Automation Rules": "Reglas automáticas", + "Smart Categorization": "Categorización Inteligente", + "Smart budgeting": "Presupuestos inteligentes", + "Smart categorization": "Categorización inteligente", + "Spending exceeds income this period.": "El gasto excede el ingreso en este período.", + "Spent": "Gastado", + "Spent:": "Gastado:", + "Square images only (max": "Solo imágenes cuadradas (máx", + "Start My Financial Journey": "Comenzar Mi Viaje Financiero", + "Start Your Financial Journey": "Comienza Tu Viaje Financiero", + "Status": "Estado", + "stone": "piedra", + "Storage Preference": "Preferencia de Almacenamiento", + "Strict access controls and authentication mechanisms protect against unauthorized access": "Controles de acceso estrictos y mecanismos de autenticación protegen contra acceso no autorizado", + "Subscription fees are billed in advance on a recurring basis until cancelled": "Las tarifas de suscripción se facturan por adelantado de forma recurrente hasta que se cancele", + "Supported formats": "Formatos soportados", + "Supports CSV, XLS, and XLSX files": "Soporta archivos CSV, XLS y XLSX", + "Sync now": "Sincronizar ahora", + "Sync your encrypted financial data across multiple devices via cloud storage": "Sincroniza tus datos financieros encriptados en múltiples dispositivos a través de almacenamiento en la nube", + "System": "Sistema", + "Tap": "Toca", + "Tap the": "Toca el botón", + "Share": "Compartir", + "button in your browser toolbar": "en la barra de herramientas de tu navegador", + "Add to Home Screen": "Añadir a pantalla de inicio", + "Take control of your finances with privacy-first money tracking. Let's set up your account in just a few minutes.": "Toma el control de tus finanzas con seguimiento de dinero que prioriza la privacidad. Configuremos tu cuenta en solo unos minutos.", + "teal": "cerceta", + "Technical Information:": "Información Técnica:", + "Terms of Service": "Términos de Servicio", + "Terms of Service - Whisper Money": "Términos de Servicio - Whisper Money", + "Terms of service for Whisper Money. Review the rules and regulations for using our platform.": "Términos de servicio de Whisper Money. Revisa las reglas y regulaciones para usar nuestra plataforma.", + "Terms of service for Whisper Money. Review the rules and regulations for using our secure personal finance platform.": "Términos de servicio de Whisper Money. Revisa las reglas y regulaciones para usar nuestra plataforma segura de finanzas personales.", + "The Whisper Money name and logo are trademarks of Whisper Money. You may not use these trademarks without our prior written consent.": "El nombre y logo de Whisper Money son marcas registradas de Whisper Money. No puedes usar estas marcas sin nuestro consentimiento previo por escrito.", + "The Whisper Money platform, including its software, design, text, graphics, logos, and other content (excluding your personal data), is owned by Whisper Money and protected by copyright, trademark, and other intellectual property laws. You may not copy, modify, distribute, sell, or lease any part of our service or software without our explicit written permission.": "La plataforma Whisper Money, incluyendo su software, diseño, texto, gráficos, logos y otro contenido (excluyendo tus datos personales), es propiedad de Whisper Money y está protegida por derechos de autor, marcas registradas y otras leyes de propiedad intelectual. No puedes copiar, modificar, distribuir, vender o arrendar ninguna parte de nuestro servicio o software sin nuestro permiso explícito por escrito.", + "The automation rules save me so much time. And knowing my data is private? Priceless.": "Las reglas de automatización me ahorran mucho tiempo. ¿Y saber que mis datos son privados? No tiene precio.", + "The budgeting features are intuitive and the dark mode is gorgeous. Best finance app I've used.": "Las funciones de presupuesto son intuitivas y el modo oscuro es hermoso. La mejor app de finanzas que he usado.", + "The demo account cannot be deleted.": "La cuenta demo no se puede eliminar.", + "The import feature saved me hours. Got all my transactions in seconds.": "La función de importación me ahorró horas. Obtuve todas mis transacciones en segundos.", + "The legal basis for processing your data includes: performance of our contract with you, your consent, our legitimate interests in improving the service, and compliance with legal obligations.": "La base legal para procesar tus datos incluye: ejecución de nuestro contrato contigo, tu consentimiento, nuestros intereses legítimos en mejorar el servicio y cumplimiento de obligaciones legales.", + "The most secure personal finance app with end-to-end encryption. Track expenses, create budgets, and manage your money privately.": "La app más segura de finanzas personales con encriptación de extremo a extremo. Rastrea gastos, crea presupuestos y administra tu dinero de forma privada.", + "The most secure way to understand your finances": "La forma más segura de entender tus finanzas", + "All your money in one place. No spreadsheets. Free.": "Todo tu dinero en un solo sitio. Sin Excels. Gratis.", + "There are different account types. Some track transactions, others just track balance over time.": "Hay diferentes tipos de cuenta. Algunas rastrean transacciones, otras solo rastrean el balance a lo largo del tiempo.", + "These Terms of Service govern your use of the Whisper Money personal finance platform.": "Estos Términos de Servicio rigen tu uso de la plataforma de finanzas personales Whisper Money.", + "These Terms, together with our Privacy Policy, constitute the entire agreement between you and Whisper Money regarding the use of our service and supersede any prior agreements or understandings, whether written or oral.": "Estos Términos, junto con nuestra Política de Privacidad, constituyen el acuerdo completo entre tú y Whisper Money con respecto al uso de nuestro servicio y reemplazan cualquier acuerdo o entendimiento previo, ya sea escrito u oral.", + "This account type is for balance tracking only and doesn't support transactions.": "Este tipo de cuenta es solo para rastreo de balance y no soporta transacciones.", + "This action is irreversible. All transactions in this account will also be permanently deleted.": "Esta acción es irreversible. Todas las transacciones en esta cuenta también serán eliminadas permanentemente.", + "This is a secure area of the application. Please confirm your password before continuing.": "Esta es un área segura de la aplicación. Por favor confirma tu contraseña antes de continuar.", + "This month's income and expenses": "Ingresos y gastos de este mes", + "This password will encrypt all your financial data. Make it strong and memorable — we can't recover it for you.": "Esta contraseña encriptará todos tus datos financieros. Hazla fuerte y memorable: no podemos recuperarla por ti.", + "This transaction was imported from a file. The description cannot be modified.": "Esta transacción fue importada de un archivo. La descripción no se puede modificar.", + "This will remove your encryption key from this browser session. You'll need to enter your password again to unlock encrypted content.": "Esto eliminará tu clave de encriptación de esta sesión del navegador. Necesitarás ingresar tu contraseña nuevamente para desbloquear el contenido encriptado.", + "This will take less than 5 minutes": "Esto tomará menos de 5 minutos", + "This will update the allocated amount for the current and future periods.": "Esto actualizará el valor asignado para el período actual y futuro.", + "This would break our end-to-end encryption promise": "Esto rompería nuestra promesa de encriptación de extremo a extremo", + "Title": "Título", + "To": "Para", + "To authenticate your access and protect your account security": "Para autenticar tu acceso y proteger la seguridad de tu cuenta", + "To comply with legal obligations and enforce our terms": "Para cumplir con obligaciones legales y hacer cumplir nuestros términos", + "To enable cloud synchronization of your encrypted financial data across devices": "Para habilitar la sincronización en la nube de tus datos financieros encriptados entre dispositivos", + "To exercise any of these rights, please contact us at victor@whisper.money. We will respond to your request within 30 days.": "Para ejercer cualquiera de estos derechos, por favor contáctanos en victor@whisper.money. Responderemos a tu solicitud dentro de 30 días.", + "To improve and optimize our service based on usage patterns": "Para mejorar y optimizar nuestro servicio basándonos en patrones de uso", + "To process payments for premium features or subscriptions": "Para procesar pagos por funciones premium o suscripciones", + "To process subscription payments and transactions. These processors have access only to the information necessary to perform their functions and are obligated to protect your data": "Para procesar pagos de suscripción y transacciones. Estos procesadores tienen acceso solo a la información necesaria para realizar sus funciones y están obligados a proteger tus datos", + "To send transactional emails, password resets, and service notifications": "Para enviar correos transaccionales, restablecimientos de contraseña y notificaciones del servicio", + "To send you service-related notifications, updates, and security alerts via email": "Para enviarte notificaciones relacionadas con el servicio, actualizaciones y alertas de seguridad por correo electrónico", + "Toggle theme": "Cambiar tema", + "Top Spending Categories": "Principales Categorías de Gasto", + "Top spending categories": "Principales categorías de gasto", + "Total": "Total", + "Track all your finances in one place — checking, savings, credit cards, investments, and more.": "Rastrea todas tus finanzas en un solo lugar: cuenta corriente, ahorros, tarjetas de crédito, inversiones y más.", + "Track your income, expenses, and savings": "Rastrea tus ingresos, gastos y ahorros", + "Track your spending with flexible budgets": "Rastrea tus gastos con presupuestos flexibles", + "Tracking": "Seguimiento", + "Tracking spending for": "Rastreando gastos para", + "Tracking:": "Seguimiento:", + "Transfer": "Transferencia", + "Transaction Date": "Fecha transaccion", + "Transaction created successfully": "Transacción creada exitosamente", + "Transaction created, but failed to update balance": "Transacción creada, pero no se pudo actualizar el balance", + "Transaction description": "Descripción", + "Transaction details, budgets, categories, and other financial information you manually enter into the application": "Detalles de transacciones, presupuestos, categorías y otra información financiera que ingresas manualmente en la aplicación", + "Transaction updated successfully": "Transacción actualizada exitosamente", + "Transactions": "Transacciones", + "Transactions in this category will not be counted in top expenses or income. Transfer categories are mainly used for transactions between accounts.": "Estas transacciones no se contará como gasto ni como ingreso.", + "Trusted by people who value their privacy": "Confiado por personas que valoran su privacidad", + "Trusted by thousands of users": "Confiado por miles de usuarios", + "Two-Factor Authentication": "Autenticación en dos pasas", + "Two-factor authentication settings cannot be changed on the demo account.": "La configuración de autenticación de dos factores no se puede cambiar en la cuenta demo.", + "Type": "Tipo", + "Type DELETE": "Escribe ELIMINAR", + "Uncategorized": "Sin categorizar", + "Understand your spending with beautiful charts and reports.": "Entiende tus gastos con gráficos y reportes hermosos.", + "Understanding Categories": "Entendiendo las Categorías", + "Unlimited accounts": "Cuentas ilimitadas", + "Unlimited transactions": "Transacciones ilimitadas", + "Unlock": "Desbloquear", + "Unlock Encrypted Data": "Desbloquear Datos Encriptados", + "Unlock encryption key": "Desbloquear clave de encriptación", + "Unlock encryption to add transactions": "Desbloquea la encriptación crear una transacción", + "Unlock encryption to import transactions": "Desbloquea la encriptación para importar transacciones", + "User account": "Cuenta de usuario", + "Update": "Actualizar", + "Update account balance": "Actualizar balance de cuenta", + "Update balance": "Actualizar balance", + "Update balances periodically to track growth": "Actualiza los balances periódicamente para rastrear el crecimiento", + "Update password": "Actualizar contraseña", + "Update the account information.": "Actualiza la información de la cuenta.", + "Update the balance record.": "Actualiza el registro de balance.", + "Update the category and notes for this transaction.": "Actualiza la categoría y notas de esta transacción.", + "Update the category information.": "Actualiza la información de la categoría.", + "Update the label information.": "Actualiza la información de la etiqueta.", + "Update the rule to automatically categorize transactions and add labels.": "Actualiza la regla para categorizar automáticamente transacciones y agregar etiquetas.", + "Update your account's appearance settings": "Actualiza la configuración de apariencia de tu cuenta", + "Update your budget settings. To change the allocated amount or tracking, use the budget page directly.": "Actualiza la configuración de tu presupuesto. Para cambiar el valor asignado o el seguimiento, usa la página de presupuesto directamente.", + "Update your name and email address": "Actualiza tu nombre y dirección de correo electrónico", + "Updating...": "Actualizando...", + "Upon termination, we will delete your personal data in accordance with our Privacy Policy and applicable law, typically within 30 days.": "Al cancelar, eliminaremos tus datos personales de acuerdo con nuestra Política de Privacidad y la ley aplicable, típicamente dentro de 30 días.", + "Usage Information:": "Información de Uso:", + "Use Defaults": "Usar Valores Predeterminados", + "Use a strong password (minimum 12 characters). This password will encrypt your data.": "Usa una contraseña fuerte (mínimo 12 caracteres). Esta contraseña encriptará tus datos.", + "Use the service only for lawful purposes and in compliance with all applicable laws": "Usa el servicio solo para fines legales y en cumplimiento de todas las leyes aplicables", + "Value": "Valor", + "Verify email": "Verificar correo electrónico", + "Version:": "Versión:", + "violet": "violeta", + "View Details": "Ver Detalles", + "View and manage balance records for this account.": "Ver y gestionar registros de balance para esta cuenta.", + "View and manage your bank accounts": "Ver y administrar tus cuentas bancarias", + "View and manage your transactions": "Ver y gestionar tus transacciones", + "View balance evolution over time": "Ver evolución del balance a lo largo del tiempo", + "View details": "Ver detalles", + "Visual Reports": "Reportes Visuales", + "Visual insights": "Análisis visuales", + "Visual insights & reports": "Análisis visuales e informes", + "Warning": "Advertencia", + "We Can't Read It": "No Podemos Leerlo", + "We collect the following types of personal information:": "Recopilamos los siguientes tipos de información personal:", + "We do not sell, trade, or rent your personal information to third parties for marketing purposes.": "No vendemos, comercializamos ni alquilamos tu información personal a terceros con fines de marketing.", + "We don't need direct access to your bank accounts. No sharing credentials, no third-party integrations, no security risks. You stay in complete control of your financial data.": "No necesitamos acceso directo a tus cuentas bancarias. Sin compartir credenciales, sin integraciones de terceros, sin riesgos de seguridad. Mantienes el control total de tus datos financieros.", + "We don't track, sell, or share your data. Ever.": "No rastreamos, vendemos ni compartimos tus datos. Nunca.", + "We encourage you to contact us directly at victor@whisper.money to resolve any disputes before initiating legal proceedings.": "Te animamos a contactarnos directamente en victor@whisper.money para resolver cualquier disputa antes de iniciar procedimientos legales.", + "We implement robust security measures to protect your personal information:": "Implementamos medidas de seguridad robustas para proteger tu información personal:", + "We may update this Privacy Policy from time to time to reflect changes in our practices or for legal, operational, or regulatory reasons. When we make material changes, we will notify you by email and/or by posting a notice on our website at least 30 days before the changes take effect. Your continued use of the service after changes become effective constitutes acceptance of the updated policy.": "Podemos actualizar esta Política de Privacidad de vez en cuando para reflejar cambios en nuestras prácticas o por razones legales, operativas o regulatorias. Cuando hagamos cambios materiales, te notificaremos por correo electrónico y/o publicando un aviso en nuestro sitio web al menos 30 días antes de que los cambios entren en vigor. Tu uso continuado del servicio después de que los cambios entren en vigor constituye la aceptación de la política actualizada.", + "We never see your transaction descriptions": "Nunca vemos las descripciones de tus transacciones", + "We process your personal data for the following purposes:": "Procesamos tus datos personales para los siguientes propósitos:", + "We regularly review and update our security practices": "Revisamos y actualizamos regularmente nuestras prácticas de seguridad", + "We reserve the right to change our pricing with 30 days advance notice to subscribers": "Nos reservamos el derecho de cambiar nuestros precios con 30 días de aviso previo a los suscriptores", + "We reserve the right to modify, suspend, or discontinue any part of the service at any time, with or without notice. We will not be liable to you or any third party for any modification, suspension, or discontinuation of the service.": "Nos reservamos el derecho de modificar, suspender o descontinuar cualquier parte del servicio en cualquier momento, con o sin previo aviso. No seremos responsables ante ti o cualquier tercero por cualquier modificación, suspensión o descontinuación del servicio.", + "We see:": "Vemos:", + "We store encrypted data we can't read. Even if our servers were compromised, your data stays secure.": "Almacenamos datos encriptados que no podemos leer. Incluso si nuestros servidores fueran comprometidos, tus datos permanecen seguros.", + "We use essential cookies to maintain your session and ensure the proper functioning of our service. These cookies are necessary for the service to work and cannot be disabled. We do not use tracking cookies or analytics cookies without your explicit consent.": "Usamos cookies esenciales para mantener tu sesión y asegurar el funcionamiento adecuado de nuestro servicio. Estas cookies son necesarias para que el servicio funcione y no se pueden desactivar. No usamos cookies de seguimiento o análisis sin tu consentimiento explícito.", + "We use the following third-party services to operate our platform:": "Usamos los siguientes servicios de terceros para operar nuestra plataforma:", + "We're looking through your transaction history to find expenses that match this budget. This usually takes a few seconds.": "Estamos revisando tu historial de transacciones para encontrar gastos que coincidan con este presupuesto. Esto usualmente toma unos segundos.", + "We've created a comprehensive set of categories for you. You can customize them now or adjust them later in settings.": "Hemos creado un conjunto completo de categorías para ti. Puedes personalizarlas ahora o ajustarlas más tarde en la configuración.", + "Weekly": "Semanal", + "Welcome to Pro!": "¡Bienvenido a Pro!", + "Welcome to
Whisper Money": "Bienvenido a
Whisper Money", + "What people are saying": "Lo que la gente dice", + "When you create an account with Whisper Money, you agree to:": "Cuando creas una cuenta con Whisper Money, aceptas:", + "When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.": "Cuando actives la autenticación de dos factores, se te pedirá un pin seguro durante el inicio de sesión. Este pin se puede obtener de una aplicación compatible con TOTP en tu teléfono.", + "Where processing is based on consent, you can withdraw it at any time": "Cuando el procesamiento se basa en el consentimiento, puedes retirarlo en cualquier momento", + "Where your money comes from": "De dónde viene tu dinero", + "Where your money goes": "A dónde va tu dinero", + "While we use industry-standard security measures, no method of transmission over the internet or electronic storage is 100% secure. We cannot guarantee absolute security but continuously work to protect your data.": "Aunque usamos medidas de seguridad estándar de la industria, ningún método de transmisión por internet o almacenamiento electrónico es 100% seguro. No podemos garantizar seguridad absoluta pero trabajamos continuamente para proteger tus datos.", + "Whisper Money - Secure Personal Finance App": "Whisper Money - App Segura de Finanzas Personales", + "Whisper Money - The Most Secure Personal Finance App": "Whisper Money - La App Más Segura de Finanzas Personales", + "Whisper Money is a personal finance tracking application that allows you to:": "Whisper Money es una aplicación de seguimiento de finanzas personales que te permite:", + "Whisper Money is the data controller responsible for your personal information.": "Whisper Money es el controlador de datos responsable de tu información personal.", + "Whisper Money uses end-to-end encryption to protect your financial data. Here's how it works:": "Whisper Money usa encriptación de extremo a extremo para proteger tus datos financieros. Así es como funciona:", + "Whisper Money. All rights reserved.": "Whisper Money. Todos los derechos reservados.", + "Why No AI Auto-Categorization?": "¿Por Qué No Hay Auto-Categorización por IA?", + "With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.": "Con la autenticación de dos factores activada, se te pedirá un pin seguro y aleatorio durante el inicio de sesión, que puedes obtener de la aplicación compatible con TOTP en tu teléfono.", + "Works everywhere": "Funciona en todas partes", + "YYYY-MM-DD (e.g., 2024-12-31)": "YYYY-MM-DD (ej., 2024-12-31)", + "Year": "Año", + "yellow": "amarillo", + "You agree to indemnify, defend, and hold harmless Whisper Money and its officers, directors, employees, and agents from any claims, liabilities, damages, losses, and expenses, including reasonable legal fees, arising out of or related to your use of the service, violation of these Terms, or violation of any rights of another party.": "Aceptas indemnizar, defender y eximir de responsabilidad a Whisper Money y sus funcionarios, directores, empleados y agentes de cualquier reclamo, responsabilidad, daño, pérdida y gasto, incluidos los honorarios legales razonables, que surjan de o estén relacionados con tu uso del servicio, violación de estos Términos o violación de los derechos de otra parte.", + "You agree to pay all applicable fees as described at the time of purchase": "Aceptas pagar todas las tarifas aplicables según se describan en el momento de la compra", + "You are responsible for all taxes associated with your purchase": "Eres responsable de todos los impuestos asociados con tu compra", + "You are responsible for maintaining backups of your data. While we implement reasonable backup procedures, we recommend you export and save copies of important data regularly.": "Eres responsable de mantener copias de seguridad de tus datos. Aunque implementamos procedimientos razonables de respaldo, recomendamos que exportes y guardes copias de datos importantes regularmente.", + "You can always customize categories later in Settings → Categories": "Siempre puedes personalizar las categorías más tarde en Configuración → Categorías", + "You can file a complaint with your local data protection authority": "Puedes presentar una queja ante tu autoridad local de protección de datos", + "You can object to processing of your data based on legitimate interests": "Puedes objetar el procesamiento de tus datos basándose en intereses legítimos", + "You can request a copy of the personal data we hold about you": "Puedes solicitar una copia de los datos personales que tenemos sobre ti", + "You can request correction of inaccurate or incomplete data": "Puedes solicitar la corrección de datos inexactos o incompletos", + "You can request deletion of your personal data (right to be forgotten)": "Puedes solicitar la eliminación de tus datos personales (derecho al olvido)", + "You can request restriction of processing in certain circumstances": "Puedes solicitar la restricción del procesamiento en ciertas circunstancias", + "You can request your data in a structured, machine-readable format": "Puedes solicitar tus datos en un formato estructurado y legible por máquina", + "You may cancel your subscription at any time. Upon cancellation, you will retain access until the end of your current billing period, after which your subscription will not renew.": "Puedes cancelar tu suscripción en cualquier momento. Al cancelar, mantendrás acceso hasta el final de tu período de facturación actual, después del cual tu suscripción no se renovará.", + "You may not use the service to engage in any illegal activity, transmit malicious code, attempt to gain unauthorized access to our systems, or interfere with the proper functioning of the service.": "No puedes usar el servicio para participar en ninguna actividad ilegal, transmitir código malicioso, intentar obtener acceso no autorizado a nuestros sistemas o interferir con el funcionamiento adecuado del servicio.", + "You may terminate your account at any time by deleting your account through the application settings or by contacting us. Upon termination, your right to use the service will immediately cease.": "Puedes cancelar tu cuenta en cualquier momento eliminándola a través de la configuración de la aplicación o contactándonos. Al cancelar, tu derecho a usar el servicio cesará inmediatamente.", + "You must be at least 16 years old to use this service. By using Whisper Money, you represent and warrant that you meet this age requirement.": "Debes tener al menos 16 años para usar este servicio. Al usar Whisper Money, declaras y garantizas que cumples con este requisito de edad.", + "You now have full access to all Whisper Money features. Thank you for supporting us!": "¡Ahora tienes acceso completo a todas las funciones de Whisper Money. Gracias por apoyarnos!", + "You retain all ownership rights to the financial data and information you enter into Whisper Money. We do not claim any ownership over your personal financial information.": "Conservas todos los derechos de propiedad sobre los datos financieros e información que ingresas en Whisper Money. No reclamamos ninguna propiedad sobre tu información financiera personal.", + "You see:": "Ves:", + "You'll receive an exclusive promo code via DM!": "¡Recibirás un código promocional exclusivo por mensaje directo!", + "You're All Set!": "¡Estás Todo Listo!", + "You're enjoying all the benefits of Whisper Money Pro": "Estás disfrutando de todos los beneficios de Whisper Money Pro", + "You're in complete control": "Tienes control completo", + "You've categorized all your transactions.": "Has categorizado todas tus transacciones.", + "Your Accounts": "Tus Cuentas", + "Your Categories Include:": "Tus Categorías Incluyen:", + "Your Data, Your Privacy": "Tus Datos, Tu Privacidad", + "Your Data:": "Tus Datos:", + "Your Private Key": "Tu Clave Privada", + "Your Pro Plan": "Tu Plan Pro", + "Your accounts are ready and your data is securely encrypted. Welcome to Whisper Money!": "Tus cuentas están listas y tus datos están encriptados de forma segura. ¡Bienvenido a Whisper Money!", + "Your continued use of the service after the effective date of revised Terms constitutes your acceptance of the changes. If you do not agree to the new terms, you must stop using the service and may delete your account.": "Tu uso continuado del servicio después de la fecha efectiva de los Términos revisados constituye tu aceptación de los cambios. Si no estás de acuerdo con los nuevos términos, debes dejar de usar el servicio y puedes eliminar tu cuenta.", + "Your data is encrypted before it leaves your browser.": "Tus datos se encriptan antes de salir de tu navegador.", + "Your data is ready": "Tus datos están listos", + "Your data is yours alone. Sign up to get started.": "Tus datos son solo tuyos. Regístrate para comenzar.", + "No credit card required. Your data stays private with E2E encryption.": "Sin tarjeta de crédito. Tus datos permanecen privados con encriptación E2E.", + "Your data is yours. Export it anytime in standard formats.": "Tus datos son tuyos. Expórtalos en cualquier momento en formatos estándar.", + "Your email address is unverified.": "Tu dirección de correo electrónico no está verificada.", + "Your financial data is encrypted on your device before being transmitted to our servers, ensuring that only you can access your information": "Tus datos financieros se encriptan en tu dispositivo antes de ser transmitidos a nuestros servidores, asegurando que solo tú puedas acceder a tu información", + "Your financial data is encrypted on your device before it ever reaches our servers.": "Tus datos financieros se encriptan en tu dispositivo antes de que lleguen a nuestros servidores.", + "Your financial data is encrypted on your device. Only you can access it.": "Tus datos financieros se encriptan en tu dispositivo. Solo tú puedes acceder a ellos.", + "Your financial data stays private with end-to-end encryption. The most secure way to manage your personal finances.": "Tus datos financieros permanecen privados con encriptación de extremo a extremo. La forma más segura de gestionar tus finanzas personales.", + "Your financial data stays private with end-to-end encryption. Track expenses, create budgets, and achieve your goals—all while keeping your information completely secure.": "Tus datos financieros permanecen privados con encriptación de extremo a extremo. Rastrea gastos, crea presupuestos y alcanza tus metas, todo mientras mantienes tu información completamente segura.", + "Understand your finances and make better decisions without the friction. Track expenses, create budgets, and achieve your goals—all in one place.": "Entiende tus finanzas para tomar mejores decisiones sin fricción. Rastrea gastos, crea presupuestos y alcanza tus metas, todo en un solo sitio.", + "Your first account must be a": "Tu primera cuenta debe ser una", + "Your rules run entirely in your browser": "Tus reglas se ejecutan completamente en tu navegador", + "Your transactions, accounts, and budgets are encrypted on your device before syncing to the cloud.": "Tus transacciones, cuentas y presupuestos se encriptan en tu dispositivo antes de sincronizarse con la nube.", + "Your use or inability to use the service": "Tu uso o incapacidad para usar el servicio", + "Zero tracking": "Sin rastreo", + "Zero-Knowledge Architecture": "Arquitectura de Conocimiento Cero", + "[Loading...]": "[Cargando...]", + "above.": "arriba.", + "account.": "cuenta.", + "balance record": "registro de balance", + "balance records": "registros de balance", + "balances imported": "balances importados", + "category(ies) total.": "categoría(s) en total.", + "e.g., Monthly Budget": "ej., Presupuesto Mensual", + "e.g., Padel Budget": "ej., Presupuesto de Pádel", + "email@example.com": "email@example.com", + "en_US": "es_ES", + "finance app, budgeting, expense tracking, end-to-end encryption, secure finance, personal finance, money management, privacy, encrypted finance app": "app de finanzas, presupuestos, seguimiento de gastos, encriptación de extremo a extremo, finanzas seguras, finanzas personales, gestión del dinero, privacidad, app de finanzas encriptada", + "for the last 12 months": "en los últimos 12 meses", + "index, follow": "index, follow", + "label(s) total.": "etiqueta(s) en total.", + "log in": "iniciar sesión", + "of": "de", + "on the last 30 days": "en los últimos 30 días", + "one-time": "pago único", + "or you can": "o puedes", + "or, enter the code manually": "o, ingresa el código manualmente", + "per month": "por mes", + "per year": "por año", + "px) /": "px) /", + "row(s) total": "fila(s) en total", + "rule(s) total.": "regla(s) en total.", + "summary_large_image": "summary_large_image", + "this month": "este mes", + "to confirm.": "para confirmar.", + "transactions imported": "transacciones importadas", + "transactions matching current filter": "transacciones que coinciden con el filtro actual", + "transactions total": "transacciones en total", + "victor@whisper.money": "victor@whisper.money", + "vs last month": "vs mes anterior", + "vs last period": "vs período anterior", + "will be updated or created.": "será actualizado o creado.", + "— $9/month": "— $9/month", + "← Back to home": "← Volver al inicio", + "🎉 Get a founder discount •": "🎉 Obtén un descuento de fundador •", + "Choose the account where transactions will be imported": "Elige la cuenta donde se importarán las transacciones", + "Drop your CSV or Excel file here, or click to browse": "Arrastra tu archivo CSV o Excel aquí, o haz clic para buscar", + "Import transactions from CSV or Excel files": "Importar transacciones desde archivos CSV o Excel", + "Importing Transactions": "Importando Transacciones", + "Map Columns": "Mapear Columnas", + "Match your file columns to transaction fields": "Asocia las columnas de tu archivo con los campos de transacción", + "No bank found.": "No se encontró ningún banco.", + "Please wait while we import your transactions": "Por favor, espera mientras importamos tus transacciones", + "Preview Transactions": "Vista Previa de Transacciones", + "Review transactions before importing": "Revisa las transacciones antes de importar", + "Searching...": "Buscando...", + "Select Account": "Seleccionar Cuenta", + "Select bank...": "Seleccionar banco...", + "Type at least 3 characters to search": "Escribe al menos 3 caracteres para buscar", + "Upload File": "Subir Archivo", + "- we never sell your data": "- nunca vendemos tus datos", + "100% private": "100% privado", + "1,200+ users": "1.200+ usuarios", + "15% more savings": "15% más de ahorro", + "23% better": "23% mejor", + "Saving": "Ahorro", + "after 3 months with Whisper Money": "después de 3 meses con Whisper Money", + "/month": "/mes", + "one-time": "pago único", + "spending awareness reported": "conocimiento del gasto reportado", + "taking control of their finances": "tomando el control de sus finanzas", + "401k, IRA, pension funds": "401k, planes de pensiones", + "Failed to create account. Please try again.": "Error al crear la cuenta. Por favor, inténtalo de nuevo.", + "Failed to set balance. Please try again.": "Error al establecer el balance. Por favor, inténtalo de nuevo.", + "Failed to setup encryption. Please try again.": "Error al configurar la encriptación. Por favor, inténtalo de nuevo.", + "Fair": "Aceptable", + "Food & Dining (Groceries, Restaurants, Delivery)": "Alimentación (Supermercado, Restaurantes, Delivery)", + "Good": "Buena", + "Health & Wellness (Medical, Pharmacy, Fitness)": "Salud y Bienestar (Médico, Farmacia, Gimnasio)", + "Housing (Rent, Utilities, Maintenance)": "Vivienda (Alquiler, Suministros, Mantenimiento)", + "Import transactions for your account. You can export transaction history from your bank's website.": "Importa transacciones para tu cuenta. Puedes exportar el historial de transacciones desde la web de tu banco.", + "Import your transaction history to start tracking your finances.": "Importa tu historial de transacciones para empezar a rastrear tus finanzas.", + "Income (Salary, Freelance, Investments)": "Ingresos (Salario, Freelance, Inversiones)", + "Let's start with your main checking account. You can add more accounts later.": "Empecemos con tu cuenta corriente principal. Puedes agregar más cuentas después.", + "Money coming into an account from a source (e.g., salary, refunds, interest). Increases your balance.": "Dinero que entra en una cuenta desde una fuente (ej., salario, reembolsos, intereses). Aumenta tu balance.", + "Money going out of an account to pay for something (e.g., groceries, rent, subscriptions). Decreases your balance.": "Dinero que sale de una cuenta para pagar algo (ej., supermercado, alquiler, suscripciones). Disminuye tu balance.", + "Mortgages and loans": "Hipotecas y préstamos", + "Moving money between accounts. It does not count in expenses or income charts.": "Mover dinero entre cuentas. No cuenta en los gráficos de gastos o ingresos.", + "Password must be at least 12 characters": "La contraseña debe tener al menos 12 caracteres", + "Passwords do not match": "Las contraseñas no coinciden", + "Please enter a balance": "Por favor, ingresa un balance", + "Please enter a bank name.": "Por favor, ingresa un nombre de banco.", + "Please enter an account name.": "Por favor, ingresa un nombre de cuenta.", + "Please fill in all required fields.": "Por favor, completa todos los campos obligatorios.", + "Please select a bank.": "Por favor, selecciona un banco.", + "Redirecting...": "Redirigiendo...", + "Retirement": "Jubilación", + "Save money for goals": "Ahorra dinero para tus metas", + "Set Balance": "Establecer Balance", + "Set the current balance for this account to start tracking.": "Establece el balance actual de esta cuenta para empezar a rastrear.", + "Setting up encryption...": "Configurando encriptación...", + "Shopping (Clothing, Electronics, Gifts)": "Compras (Ropa, Electrónica, Regalos)", + "Smart Rules": "Reglas Inteligentes", + "Stocks, ETFs, and portfolios": "Acciones, ETFs y carteras", + "Strong": "Fuerte", + "Take control of your finances with privacy-first money tracking. Let's set up your account in just a few minutes.": "Toma el control de tus finanzas con un seguimiento privado de tu dinero. Vamos a configurar tu cuenta en pocos minutos.", + "This account tracks balance changes over time instead of individual transactions.": "Esta cuenta rastrea cambios de balance a lo largo del tiempo en lugar de transacciones individuales.", + "This will take less than 5 minutes": "Esto tomará menos de 5 minutos", + "Track credit card spending": "Rastrea los gastos de tarjeta de crédito", + "Transactions + Balance": "Transacciones + Balance", + "Transportation (Fuel, Public Transit, Parking)": "Transporte (Combustible, Transporte público, Aparcamiento)", + "Transfers (Between accounts, Savings)": "Transferencias (Entre cuentas, Ahorros)", + "Weak": "Débil", + "Welcome": "Bienvenido", + "Would you like to add more accounts or continue to the dashboard?": "¿Quieres agregar más cuentas o continuar al panel?", + "You already have accounts set up. Let's continue with the onboarding.": "Ya tienes cuentas configuradas. Continuemos con la configuración.", + "Your first account must be a checking account.": "Tu primera cuenta debe ser una cuenta corriente.", + "Your key will be cleared when you close the browser.": "Tu clave se borrará cuando cierres el navegador.", + "Your key will be stored until you log out.": "Tu clave se almacenará hasta que cierres sesión.", + "Creating subscription...": "Creando suscripción...", + "We are processing your payment...": "Estamos procesando tu pago...", + "Your subscription is now active": "Tu suscripción ya está activa", + "Verify Your Email - Whisper Money": "Verifica Tu Email - Whisper Money", + "Verify your email, :name!": "¡Verifica tu correo, :name!", + "Hi! I'm Victor, the founder of Whisper Money. Thanks for signing up — I just need you to verify your email address to get started.": "¡Hola! Soy Víctor, el fundador de Whisper Money. Gracias por registrarte — solo necesito que verifiques tu dirección de correo electrónico para comenzar.", + "Once verified, you'll be able to set up your encryption key and start tracking your finances with full privacy.": "Una vez verificado, podrás configurar tu clave de encriptación y empezar a rastrear tus finanzas con total privacidad.", + "Verify Email Address": "Verificar Correo Electrónico", + "If you didn't create a Whisper Money account, you can safely ignore this email.": "Si no creaste una cuenta en Whisper Money, puedes ignorar este correo.", + "If you're having trouble clicking the \"Verify Email Address\" button, copy and paste the URL below into your web browser:": "Si tienes problemas para hacer clic en el botón \"Verificar Correo Electrónico\", copia y pega la URL de abajo en tu navegador web:", + "Welcome to Whisper Money - Your Privacy-First Finance App": "Bienvenido a Whisper Money - Tu App de Finanzas con Privacidad", + "Welcome to Whisper Money, :name!": "¡Bienvenido a Whisper Money, :name!", + "Hi! I'm Victor, the founder of Whisper Money. I wanted to personally welcome you and thank you for joining us.": "¡Hola! Soy Víctor, el fundador de Whisper Money. Quería darte la bienvenida personalmente y agradecerte por unirte.", + "When I started building Whisper Money, I was frustrated with finance apps that wanted to mine my data or use AI to analyze my spending. I just wanted a simple, private way to track my finances - and I figured others might too.": "Cuando empecé a construir Whisper Money, estaba frustrado con las apps de finanzas que querían minar mis datos o usar IA para analizar mis gastos. Solo quería una forma simple y privada de rastrear mis finanzas - y pensé que otros también.", + "Your Data is Truly Private": "Tus Datos Son Verdaderamente Privados", + "I built Whisper Money with **end-to-end encryption** because I believe your financial data should be yours alone:": "Construí Whisper Money con **encriptación de extremo a extremo** porque creo que tus datos financieros deben ser solo tuyos:", + "Your financial data is encrypted with a key that only you control": "Tus datos financieros están encriptados con una clave que solo tú controlas", + "I cannot read your transactions, balances, or any personal information (and I don't want to!)": "No puedo leer tus transacciones, balances ni ninguna información personal (¡y no quiero!)", + "Your encryption key never leaves your device": "Tu clave de encriptación nunca sale de tu dispositivo", + "No AI, No Data Mining": "Sin IA, Sin Minería de Datos", + "I don't use AI to analyze your spending or sell insights about your habits. Your financial data stays between you and your spreadsheet-loving self.": "No uso IA para analizar tus gastos ni vender información sobre tus hábitos. Tus datos financieros quedan entre tú y tu afición por las hojas de cálculo.", + "What's more, even if I wanted to use it, I couldn't, since you're the only one who has the key to read the data.": "Es más, aunque quisiera usarlos, no podría, ya que tú eres el único que tiene la clave para leer los datos.", + "If you have any questions or run into any issues, **just reply to this email**. I personally read every message and I'm here to help!": "Si tienes alguna pregunta o problema, **simplemente responde a este correo**. Leo personalmente cada mensaje y estoy aquí para ayudar.", + "Thanks for giving Whisper Money a try. It means a lot to me.": "Gracias por probar Whisper Money. Significa mucho para mí.", + "How's Your Experience So Far?": "¿Cómo Va Tu Experiencia Hasta Ahora?", + "How's it going, :name?": "¿Cómo va todo, :name?", + "Hi! It's Victor here, the founder of Whisper Money. You've been using the app for a few days now, and I'd love to hear how it's working for you.": "¡Hola! Soy Víctor, el fundador de Whisper Money. Llevas unos días usando la app y me encantaría saber cómo te está funcionando.", + "A Few Quick Questions": "Unas Preguntas Rápidas", + "Is the app meeting your expectations?": "¿La app cumple con tus expectativas?", + "Is there anything confusing or frustrating?": "¿Hay algo confuso o frustrante?", + "What features would make Whisper Money more useful for you?": "¿Qué funciones harían que Whisper Money fuera más útil para ti?", + "As a solo founder, your feedback directly shapes what I build next. I personally read every response and take your suggestions seriously. When you subscribe, you're not just supporting some big corporation - you're helping me continue building something I'm passionate about.": "Como fundador en solitario, tus comentarios moldean directamente lo que construyo a continuación. Leo personalmente cada respuesta y tomo tus sugerencias en serio. Cuando te suscribes, no estás apoyando a una gran corporación, estás ayudándome a seguir construyendo algo que me apasiona.", + "Help Me Improve": "Ayúdame a Mejorar", + "Whether it's a bug you've found, a feature you'd love to see, or just general thoughts - I want to hear it all. This is my project, and I care deeply about making it work well for you.": "Ya sea un error que hayas encontrado, una función que te gustaría ver, o simplemente pensamientos generales, quiero escucharlo todo. Este es mi proyecto y me importa mucho que funcione bien para ti.", + "Just reply to this email with your feedback. No surveys, no forms, just a direct conversation with me.": "Simplemente responde a este correo con tus comentarios. Sin encuestas, sin formularios, solo una conversación directa conmigo.", + "Thanks for being part of Whisper Money. It really means a lot.": "Gracias por ser parte de Whisper Money. Realmente significa mucho.", + "Let's Import Your Transactions": "Importemos Tus Transacciones", + "Need help importing your transactions, :name?": "¿Necesitas ayuda para importar tus transacciones, :name?", + "Hi! It's Victor, the founder of Whisper Money. I noticed you've completed your setup but haven't imported any transactions yet. Let me help you get started!": "¡Hola! Soy Víctor, el fundador de Whisper Money. Noté que completaste tu configuración pero aún no has importado transacciones. ¡Déjame ayudarte a empezar!", + "How to Import Your Transactions": "Cómo Importar Tus Transacciones", + "Step 1: Export from your bank": "Paso 1: Exporta desde tu banco", + "Log into your bank's website and look for \"Export\" or \"Download transactions\". Choose CSV format if available.": "Inicia sesión en la web de tu banco y busca \"Exportar\" o \"Descargar transacciones\". Elige formato CSV si está disponible.", + "Step 2: Upload to Whisper Money": "Paso 2: Sube a Whisper Money", + "Go to your dashboard and click \"Import Transactions\". Select your CSV file and I'll map the columns automatically.": "Ve a tu panel y haz clic en \"Importar Transacciones\". Selecciona tu archivo CSV y mapearé las columnas automáticamente.", + "Step 3: Review and confirm": "Paso 3: Revisa y confirma", + "Check that everything looks correct and click \"Import\". Your transactions will be encrypted and stored securely.": "Verifica que todo se vea correcto y haz clic en \"Importar\". Tus transacciones serán encriptadas y almacenadas de forma segura.", + "Prefer to Start Fresh?": "¿Prefieres Empezar de Cero?", + "You can also manually add transactions and account balances. Some users prefer to start tracking from today rather than importing history - that's totally fine! Do whatever works best for you.": "También puedes agregar transacciones y balances de cuentas manualmente. Algunos usuarios prefieren empezar a rastrear desde hoy en lugar de importar historial, ¡eso está totalmente bien! Haz lo que funcione mejor para ti.", + "If you're having trouble with the import or need help with your specific bank's format, just reply to this email. I personally handle support and I'm happy to help you figure it out.": "Si tienes problemas con la importación o necesitas ayuda con el formato de tu banco, simplemente responde a este correo. Yo personalmente manejo el soporte y estaré encantado de ayudarte.", + "Thanks for giving Whisper Money a try!": "¡Gracias por probar Whisper Money!", + "Need Help Getting Started?": "¿Necesitas Ayuda para Empezar?", + "Hey :name, is everything okay?": "Hey :name, ¿está todo bien?", + "Hi! It's Victor, the founder of Whisper Money. I noticed you signed up but haven't completed your setup yet. I wanted to check in personally and see if something went wrong or if you need any help.": "¡Hola! Soy Víctor, el fundador de Whisper Money. Noté que te registraste pero aún no has completado tu configuración. Quería contactarte personalmente para ver si algo salió mal o si necesitas ayuda.", + "I know setting up a new app can be overwhelming, and I want to make sure you have everything you need to get started.": "Sé que configurar una nueva app puede ser abrumador, y quiero asegurarme de que tengas todo lo que necesitas para empezar.", + "Common Questions": "Preguntas Frecuentes", + "Is the encryption confusing?": "¿La encriptación es confusa?", + "No worries! Your encryption key is automatically generated and stored securely on your device. You don't need to remember anything - I've made it as simple as possible.": "¡No te preocupes! Tu clave de encriptación se genera automáticamente y se almacena de forma segura en tu dispositivo. No necesitas recordar nada, lo he hecho lo más simple posible.", + "Not sure how to import transactions?": "¿No sabes cómo importar transacciones?", + "I support CSV imports from most banks. Just export your transactions and upload them - it takes less than a minute. If your bank format is different, just let me know and I can help.": "Soporto importación CSV de la mayoría de bancos. Solo exporta tus transacciones y súbelas, toma menos de un minuto. Si el formato de tu banco es diferente, avísame y te ayudo.", + "Something not working?": "¿Algo no funciona?", + "Just reply to this email and let me know what's happening. I personally read every response and will help you get started. This is my project, so I care about making sure it works for you.": "Simplemente responde a este correo y cuéntame qué está pasando. Leo personalmente cada respuesta y te ayudaré a empezar. Este es mi proyecto, así que me importa que funcione para ti.", + "Looking forward to hearing from you! And if you decide Whisper Money isn't for you, that's totally fine - but I'd love to know why so I can improve.": "¡Espero tus comentarios! Y si decides que Whisper Money no es para ti, está totalmente bien, pero me encantaría saber por qué para poder mejorar.", + "Your Founder Discount - 80% Off First Period": "Tu Descuento de Fundador - 80% de Descuento en el Primer Período", + "Welcome aboard, :name!": "¡Bienvenido a bordo, :name!", + "Hi! It's Victor, the founder of Whisper Money. I see you've already started importing your transactions - that's awesome! You're well on your way to taking control of your finances while keeping your data private.": "¡Hola! Soy Víctor, el fundador de Whisper Money. Veo que ya has empezado a importar tus transacciones, ¡genial! Estás en camino de tomar el control de tus finanzas mientras mantienes tus datos privados.", + "A Special Offer for You": "Una Oferta Especial para Ti", + "As one of our early users, I want to offer you a special founder's discount. When you subscribe, you're not just getting a great app - you're directly supporting me as I continue building Whisper Money. Every subscription helps me keep the lights on and build features you actually want.": "Como uno de nuestros primeros usuarios, quiero ofrecerte un descuento especial de fundador. Cuando te suscribes, no solo obtienes una gran app, estás apoyándome directamente mientras sigo construyendo Whisper Money. Cada suscripción me ayuda a mantener el proyecto y construir las funciones que realmente quieres.", + "Use code **:code** to get **80% off** your first period (monthly or yearly!)": "Usa el código **:code** para obtener **80% de descuento** en tu primer período (¡mensual o anual!)", + "This gives you full access to all Whisper Money features:": "Esto te da acceso completo a todas las funciones de Whisper Money:", + "Unlimited transaction imports": "Importaciones de transacciones ilimitadas", + "Automated categorization rules": "Reglas de categorización automática", + "Multiple account tracking": "Seguimiento de múltiples cuentas", + "End-to-end encrypted storage": "Almacenamiento con encriptación de extremo a extremo", + "Claim Your Discount": "Reclama Tu Descuento", + "This code won't last forever, but more importantly, your support means the world to me. As a solo founder, every subscriber helps me continue building something I'm passionate about.": "Este código no durará para siempre, pero lo más importante es que tu apoyo significa mucho para mí. Como fundador en solitario, cada suscriptor me ayuda a seguir construyendo algo que me apasiona.", + "Thanks for being part of this journey with me!": "¡Gracias por ser parte de este viaje conmigo!", + "We're sorry to see you go": "Lamentamos que te vayas", + "We're sorry to see you go, :name": "Lamentamos que te vayas, :name", + "Hi! It's Victor, the founder of Whisper Money. I noticed you've cancelled your subscription, and I wanted to reach out personally.": "¡Hola! Soy Víctor, el fundador de Whisper Money. Noté que cancelaste tu suscripción y quería contactarte personalmente.", + "First, thank you for giving Whisper Money a try. I hope it helped you get a better handle on your finances while keeping your data private.": "Primero, gracias por probar Whisper Money. Espero que te haya ayudado a manejar mejor tus finanzas mientras mantenías tus datos privados.", + "Before you go...": "Antes de irte...", + "If there's anything that didn't work well for you, or if you have suggestions for improvement, I'd genuinely love to hear about it. As a solo founder, your feedback is invaluable in making Whisper Money better.": "Si hay algo que no funcionó bien para ti, o si tienes sugerencias para mejorar, me encantaría saberlo. Como fundador en solitario, tus comentarios son invaluables para mejorar Whisper Money.", + "If you'd like to come back, here's a special offer just for you:": "Si te gustaría volver, aquí tienes una oferta especial solo para ti:", + "Use code **CONTINUE50** to get **50% off** all current and future payments - works for both monthly and yearly subscriptions.": "Usa el código **CONTINUE50** para obtener **50% de descuento** en todos los pagos actuales y futuros, funciona para suscripciones mensuales y anuales.", + "Reactivate Your Subscription": "Reactiva Tu Suscripción", + "Your data and settings will be preserved, so you can pick up right where you left off.": "Tus datos y configuración se conservarán, así que puedes retomar donde lo dejaste.", + "If you have any questions or just want to chat, simply reply to this email. I read and respond to every message personally.": "Si tienes alguna pregunta o simplemente quieres charlar, responde a este correo. Leo y respondo personalmente cada mensaje.", + "Thanks again for being part of this journey!": "¡Gracias de nuevo por ser parte de este viaje!" +} diff --git a/lang/es/auth.php b/lang/es/auth.php new file mode 100644 index 00000000..fa22f7bb --- /dev/null +++ b/lang/es/auth.php @@ -0,0 +1,20 @@ + 'Estas credenciales no coinciden con nuestros registros.', + 'password' => 'La contraseña proporcionada es incorrecta.', + 'throttle' => 'Demasiados intentos de acceso. Por favor intente de nuevo en :seconds segundos.', + +]; diff --git a/lang/es/pagination.php b/lang/es/pagination.php new file mode 100644 index 00000000..f8f044e1 --- /dev/null +++ b/lang/es/pagination.php @@ -0,0 +1,19 @@ + '« Anterior', + 'next' => 'Siguiente »', + +]; diff --git a/lang/es/passwords.php b/lang/es/passwords.php new file mode 100644 index 00000000..0dd0d3f3 --- /dev/null +++ b/lang/es/passwords.php @@ -0,0 +1,22 @@ + 'Tu contraseña ha sido restablecida.', + 'sent' => 'Te hemos enviado por correo electrónico el enlace para restablecer tu contraseña.', + 'throttled' => 'Por favor espera antes de intentar de nuevo.', + 'token' => 'Este token de restablecimiento de contraseña es inválido.', + 'user' => 'No podemos encontrar un usuario con esa dirección de correo electrónico.', + +]; diff --git a/lang/es/validation.php b/lang/es/validation.php new file mode 100644 index 00000000..55479097 --- /dev/null +++ b/lang/es/validation.php @@ -0,0 +1,200 @@ + 'El campo :attribute debe ser aceptado.', + 'accepted_if' => 'El campo :attribute debe ser aceptado cuando :other sea :value.', + 'active_url' => 'El campo :attribute debe ser una URL válida.', + 'after' => 'El campo :attribute debe ser una fecha posterior a :date.', + 'after_or_equal' => 'El campo :attribute debe ser una fecha posterior o igual a :date.', + 'alpha' => 'El campo :attribute solo debe contener letras.', + 'alpha_dash' => 'El campo :attribute solo debe contener letras, números, guiones y guiones bajos.', + 'alpha_num' => 'El campo :attribute solo debe contener letras y números.', + 'any_of' => 'El campo :attribute es inválido.', + 'array' => 'El campo :attribute debe ser un arreglo.', + 'ascii' => 'El campo :attribute solo debe contener caracteres alfanuméricos y símbolos de un byte.', + 'before' => 'El campo :attribute debe ser una fecha anterior a :date.', + 'before_or_equal' => 'El campo :attribute debe ser una fecha anterior o igual a :date.', + 'between' => [ + 'array' => 'El campo :attribute debe tener entre :min y :max elementos.', + 'file' => 'El campo :attribute debe tener entre :min y :max kilobytes.', + 'numeric' => 'El campo :attribute debe estar entre :min y :max.', + 'string' => 'El campo :attribute debe tener entre :min y :max caracteres.', + ], + 'boolean' => 'El campo :attribute debe ser verdadero o falso.', + 'can' => 'El campo :attribute contiene un valor no autorizado.', + 'confirmed' => 'La confirmación del campo :attribute no coincide.', + 'contains' => 'El campo :attribute falta un valor requerido.', + 'current_password' => 'La contraseña es incorrecta.', + 'date' => 'El campo :attribute debe ser una fecha válida.', + 'date_equals' => 'El campo :attribute debe ser una fecha igual a :date.', + 'date_format' => 'El campo :attribute debe coincidir con el formato :format.', + 'decimal' => 'El campo :attribute debe tener :decimal lugares decimales.', + 'declined' => 'El campo :attribute debe ser rechazado.', + 'declined_if' => 'El campo :attribute debe ser rechazado cuando :other sea :value.', + 'different' => 'El campo :attribute y :other deben ser diferentes.', + 'digits' => 'El campo :attribute debe tener :digits dígitos.', + 'digits_between' => 'El campo :attribute debe tener entre :min y :max dígitos.', + 'dimensions' => 'El campo :attribute tiene dimensiones de imagen inválidas.', + 'distinct' => 'El campo :attribute tiene un valor duplicado.', + 'doesnt_contain' => 'El campo :attribute no debe contener ninguno de los siguientes: :values.', + 'doesnt_end_with' => 'El campo :attribute no debe terminar con ninguno de los siguientes: :values.', + 'doesnt_start_with' => 'El campo :attribute no debe comenzar con ninguno de los siguientes: :values.', + 'email' => 'El campo :attribute debe ser una dirección de correo electrónico válida.', + 'encoding' => 'El campo :attribute debe estar codificado en :encoding.', + 'ends_with' => 'El campo :attribute debe terminar con uno de los siguientes: :values.', + 'enum' => 'El :attribute seleccionado es inválido.', + 'exists' => 'El :attribute seleccionado es inválido.', + 'extensions' => 'El campo :attribute debe tener una de las siguientes extensiones: :values.', + 'file' => 'El campo :attribute debe ser un archivo.', + 'filled' => 'El campo :attribute debe tener un valor.', + 'gt' => [ + 'array' => 'El campo :attribute debe tener más de :value elementos.', + 'file' => 'El campo :attribute debe ser mayor que :value kilobytes.', + 'numeric' => 'El campo :attribute debe ser mayor que :value.', + 'string' => 'El campo :attribute debe ser mayor que :value caracteres.', + ], + 'gte' => [ + 'array' => 'El campo :attribute debe tener :value elementos o más.', + 'file' => 'El campo :attribute debe ser mayor o igual a :value kilobytes.', + 'numeric' => 'El campo :attribute debe ser mayor o igual a :value.', + 'string' => 'El campo :attribute debe ser mayor o igual a :value caracteres.', + ], + 'hex_color' => 'El campo :attribute debe ser un color hexadecimal válido.', + 'image' => 'El campo :attribute debe ser una imagen.', + 'in' => 'El :attribute seleccionado es inválido.', + 'in_array' => 'El campo :attribute debe existir en :other.', + 'in_array_keys' => 'El campo :attribute debe contener al menos una de las siguientes claves: :values.', + 'integer' => 'El campo :attribute debe ser un número entero.', + 'ip' => 'El campo :attribute debe ser una dirección IP válida.', + 'ipv4' => 'El campo :attribute debe ser una dirección IPv4 válida.', + 'ipv6' => 'El campo :attribute debe ser una dirección IPv6 válida.', + 'json' => 'El campo :attribute debe ser una cadena JSON válida.', + 'list' => 'El campo :attribute debe ser una lista.', + 'lowercase' => 'El campo :attribute debe estar en minúsculas.', + 'lt' => [ + 'array' => 'El campo :attribute debe tener menos de :value elementos.', + 'file' => 'El campo :attribute debe ser menor que :value kilobytes.', + 'numeric' => 'El campo :attribute debe ser menor que :value.', + 'string' => 'El campo :attribute debe ser menor que :value caracteres.', + ], + 'lte' => [ + 'array' => 'El campo :attribute no debe tener más de :value elementos.', + 'file' => 'El campo :attribute debe ser menor o igual a :value kilobytes.', + 'numeric' => 'El campo :attribute debe ser menor o igual a :value.', + 'string' => 'El campo :attribute debe ser menor o igual a :value caracteres.', + ], + 'mac_address' => 'El campo :attribute debe ser una dirección MAC válida.', + 'max' => [ + 'array' => 'El campo :attribute no debe tener más de :max elementos.', + 'file' => 'El campo :attribute no debe ser mayor que :max kilobytes.', + 'numeric' => 'El campo :attribute no debe ser mayor que :max.', + 'string' => 'El campo :attribute no debe ser mayor que :max caracteres.', + ], + 'max_digits' => 'El campo :attribute no debe tener más de :max dígitos.', + 'mimes' => 'El campo :attribute debe ser un archivo de tipo: :values.', + 'mimetypes' => 'El campo :attribute debe ser un archivo de tipo: :values.', + 'min' => [ + 'array' => 'El campo :attribute debe tener al menos :min elementos.', + 'file' => 'El campo :attribute debe tener al menos :min kilobytes.', + 'numeric' => 'El campo :attribute debe ser al menos :min.', + 'string' => 'El campo :attribute debe tener al menos :min caracteres.', + ], + 'min_digits' => 'El campo :attribute debe tener al menos :min dígitos.', + 'missing' => 'El campo :attribute debe estar ausente.', + 'missing_if' => 'El campo :attribute debe estar ausente cuando :other sea :value.', + 'missing_unless' => 'El campo :attribute debe estar ausente a menos que :other sea :value.', + 'missing_with' => 'El campo :attribute debe estar ausente cuando :values esté presente.', + 'missing_with_all' => 'El campo :attribute debe estar ausente cuando :values estén presentes.', + 'multiple_of' => 'El campo :attribute debe ser un múltiplo de :value.', + 'not_in' => 'El :attribute seleccionado es inválido.', + 'not_regex' => 'El formato del campo :attribute es inválido.', + 'numeric' => 'El campo :attribute debe ser un número.', + 'password' => [ + 'letters' => 'El campo :attribute debe contener al menos una letra.', + 'mixed' => 'El campo :attribute debe contener al menos una letra mayúscula y una minúscula.', + 'numbers' => 'El campo :attribute debe contener al menos un número.', + 'symbols' => 'El campo :attribute debe contener al menos un símbolo.', + 'uncompromised' => 'El :attribute proporcionado ha aparecido en una filtración de datos. Por favor elige un :attribute diferente.', + ], + 'present' => 'El campo :attribute debe estar presente.', + 'present_if' => 'El campo :attribute debe estar presente cuando :other sea :value.', + 'present_unless' => 'El campo :attribute debe estar presente a menos que :other sea :value.', + 'present_with' => 'El campo :attribute debe estar presente cuando :values esté presente.', + 'present_with_all' => 'El campo :attribute debe estar presente cuando :values estén presentes.', + 'prohibited' => 'El campo :attribute está prohibido.', + 'prohibited_if' => 'El campo :attribute está prohibido cuando :other sea :value.', + 'prohibited_if_accepted' => 'El campo :attribute está prohibido cuando :other sea aceptado.', + 'prohibited_if_declined' => 'El campo :attribute está prohibido cuando :other sea rechazado.', + 'prohibited_unless' => 'El campo :attribute está prohibido a menos que :other esté en :values.', + 'prohibits' => 'El campo :attribute prohíbe que :other esté presente.', + 'regex' => 'El formato del campo :attribute es inválido.', + 'required' => 'El campo :attribute es obligatorio.', + 'required_array_keys' => 'El campo :attribute debe contener entradas para: :values.', + 'required_if' => 'El campo :attribute es obligatorio cuando :other sea :value.', + 'required_if_accepted' => 'El campo :attribute es obligatorio cuando :other sea aceptado.', + 'required_if_declined' => 'El campo :attribute es obligatorio cuando :other sea rechazado.', + 'required_unless' => 'El campo :attribute es obligatorio a menos que :other esté en :values.', + 'required_with' => 'El campo :attribute es obligatorio cuando :values esté presente.', + 'required_with_all' => 'El campo :attribute es obligatorio cuando :values estén presentes.', + 'required_without' => 'El campo :attribute es obligatorio cuando :values no esté presente.', + 'required_without_all' => 'El campo :attribute es obligatorio cuando ninguno de :values esté presente.', + 'same' => 'El campo :attribute debe coincidir con :other.', + 'size' => [ + 'array' => 'El campo :attribute debe contener :size elementos.', + 'file' => 'El campo :attribute debe tener :size kilobytes.', + 'numeric' => 'El campo :attribute debe ser :size.', + 'string' => 'El campo :attribute debe tener :size caracteres.', + ], + 'starts_with' => 'El campo :attribute debe comenzar con uno de los siguientes: :values.', + 'string' => 'El campo :attribute debe ser una cadena de texto.', + 'timezone' => 'El campo :attribute debe ser una zona horaria válida.', + 'unique' => 'El :attribute ya ha sido tomado.', + 'uploaded' => 'El :attribute falló al subirse.', + 'uppercase' => 'El campo :attribute debe estar en mayúsculas.', + 'url' => 'El campo :attribute debe ser una URL válida.', + 'ulid' => 'El campo :attribute debe ser un ULID válido.', + 'uuid' => 'El campo :attribute debe ser un UUID válido.', + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'custom-message', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap our attribute placeholder + | with something more reader friendly such as "E-Mail Address" instead + | of "email". This simply helps us make our message more expressive. + | + */ + + 'attributes' => [], + +]; diff --git a/phpunit.xml b/phpunit.xml index 2468e95b..5b52d429 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -33,5 +33,6 @@ + diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 695245da..e0e1edba 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -1,6 +1,6 @@ import '../css/app.css'; -import { createInertiaApp } from '@inertiajs/react'; +import { createInertiaApp, router } from '@inertiajs/react'; import * as Sentry from '@sentry/react'; import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; import { @@ -19,6 +19,7 @@ import { SyncProvider } from './contexts/sync-context'; import { initializeTheme } from './hooks/use-appearance'; import { initializePostHog } from './lib/posthog'; import type { SharedData } from './types'; +import { setTranslations } from './utils/i18n'; Sentry.init({ dsn: 'https://47f7a823afae4c2f93ab3159ca7c0a3a@bugsink.whisper.money/2', @@ -56,6 +57,19 @@ createInertiaApp({ const initialUser = initialPageProps?.auth?.user ?? null; const initialIsAuthenticated = Boolean(initialUser); + // Initialize translations from server-rendered page data + setTranslations( + (initialPageProps?.translations as Record) ?? {}, + ); + + // Keep translations in sync on every Inertia navigation + router.on('navigate', (event) => { + const pageProps = event.detail.page.props as SharedData; + setTranslations( + (pageProps?.translations as Record) ?? {}, + ); + }); + root.render( diff --git a/resources/js/components/accounts/account-balance-chart.tsx b/resources/js/components/accounts/account-balance-chart.tsx index 639d8092..6e802cf9 100644 --- a/resources/js/components/accounts/account-balance-chart.tsx +++ b/resources/js/components/accounts/account-balance-chart.tsx @@ -23,7 +23,10 @@ import { convertSingleAccountData, useChartViews, } from '@/hooks/use-chart-views'; +import { useLocale } from '@/hooks/use-locale'; import { Account } from '@/types/account'; +import { formatMonthFromYearMonth } from '@/utils/date'; +import { __ } from '@/utils/i18n'; import { format, subMonths } from 'date-fns'; import { useEffect, useMemo, useRef, useState } from 'react'; import { Bar, BarChart, XAxis } from 'recharts'; @@ -52,21 +55,18 @@ interface AccountBalanceChartProps { onBalanceClick?: () => void; } -function formatXAxisLabel(value: string): string { - const [year, month] = value.split('-'); - const date = new Date(parseInt(year), parseInt(month) - 1); - const monthName = date.toLocaleString('en-US', { month: 'short' }); - const currentYear = new Date().getFullYear(); - - if (parseInt(year) === currentYear) { - return monthName; - } - - return `${monthName} ${year.slice(-2)}`; +function createXAxisFormatter(locale: string) { + return function formatXAxisLabel(value: string): string { + return formatMonthFromYearMonth(value, locale); + }; } -function formatCurrency(value: number, currencyCode: string): string { - return new Intl.NumberFormat('en-US', { +function formatChartCurrency( + value: number, + currencyCode: string, + locale: string, +): string { + return new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode, minimumFractionDigits: 0, @@ -104,6 +104,7 @@ export function AccountBalanceChart({ refreshKey, onBalanceClick, }: AccountBalanceChartProps) { + const locale = useLocale(); const [balanceData, setBalanceData] = useState( null, ); @@ -181,12 +182,18 @@ export function AccountBalanceChart({ length={{ min: 5, max: 20 }} /> ), + color: 'var(--color-chart-2)', }, }; + const formatXAxisLabel = useMemo( + () => createXAxisFormatter(locale), + [locale], + ); + const valueFormatter = (value: number): string => { - return formatCurrency(value, account.currency_code); + return formatChartCurrency(value, account.currency_code, locale); }; const scrollContainerRef = useRef(null); @@ -204,7 +211,7 @@ export function AccountBalanceChart({ return ( - Balance evolution + {__('Balance evolution')}
@@ -220,11 +227,11 @@ export function AccountBalanceChart({ return ( - Balance evolution + {__('Balance evolution')}
- No balance data available + {__('No balance data available')}
@@ -236,7 +243,7 @@ export function AccountBalanceChart({
- Balance evolution + {__('Balance evolution')}
diff --git a/resources/js/components/accounts/balances-modal.tsx b/resources/js/components/accounts/balances-modal.tsx index d5535342..531cd935 100644 --- a/resources/js/components/accounts/balances-modal.tsx +++ b/resources/js/components/accounts/balances-modal.tsx @@ -34,6 +34,7 @@ import { TableRow, } from '@/components/ui/table'; import type { Account, AccountBalance } from '@/types/account'; +import { __ } from '@/utils/i18n'; import { Pencil, Trash2 } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; @@ -218,9 +219,11 @@ export function BalancesModal({ - Balance History + {__('Balance History')} - View and manage balance records for this account. + {__( + 'View and manage balance records for this account.', + )} @@ -235,12 +238,12 @@ export function BalancesModal({ - Date + {__('Date')} - Balance + {__('Balance')} - Actions + {__('Actions')} @@ -251,7 +254,7 @@ export function BalancesModal({ colSpan={3} className="h-24 text-center" > - Loading... + {__('Loading...')} ) : balances.length === 0 ? ( @@ -260,7 +263,9 @@ export function BalancesModal({ colSpan={3} className="h-24 text-center" > - No balance records found. + {__( + 'No balance records found.', + )} ) : ( @@ -313,7 +318,10 @@ export function BalancesModal({ {lastPage > 1 && (
- {total} balance record{total !== 1 ? 's' : ''} + {total}{' '} + {total === 1 + ? __('balance record') + : __('balance records')}
- Page {currentPage} of {lastPage} + {__('Page')} {currentPage} {__('of')}{' '} + {lastPage}
@@ -353,15 +362,15 @@ export function BalancesModal({ > - Edit Balance + {__('Edit Balance')} - Update the balance record. + {__('Update the balance record.')}
- +
- + setEditingBalance(null)} disabled={isEditSubmitting} > - Cancel + {__('Cancel')} @@ -407,22 +418,25 @@ export function BalancesModal({ > - Delete balance + + {__('Delete balance')} + - Are you sure you want to delete this balance record? - This action cannot be undone. + {__( + 'Are you sure you want to delete this balance record? This action cannot be undone.', + )} - Cancel + {__('Cancel')} - {isDeleting ? 'Deleting...' : 'Delete'} + {isDeleting ? __('Deleting...') : __('Delete')} diff --git a/resources/js/components/accounts/bank-combobox.tsx b/resources/js/components/accounts/bank-combobox.tsx index 60af7a85..69939892 100644 --- a/resources/js/components/accounts/bank-combobox.tsx +++ b/resources/js/components/accounts/bank-combobox.tsx @@ -16,6 +16,7 @@ import { } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; import { type Bank } from '@/types/account'; +import { __ } from '@/utils/i18n'; import { Check, ChevronsUpDown, Plus } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; @@ -121,7 +122,7 @@ export function BankCombobox({ {selectedBank.name}
) : ( - 'Select bank...' + __('Select bank...') )} @@ -132,17 +133,18 @@ export function BankCombobox({ > 1}> + {isLoading - ? 'Searching...' + ? __('Searching...') : searchQuery.length < 3 - ? 'Type at least 3 characters to search' - : 'No bank found.'} + ? __('Type at least 3 characters to search') + : __('No bank found.')} {banks.map((bank) => ( @@ -190,7 +192,8 @@ export function BankCombobox({ > - Create "{searchQuery}" + {__('Create "')} + {searchQuery}" diff --git a/resources/js/components/accounts/create-account-dialog.tsx b/resources/js/components/accounts/create-account-dialog.tsx index f3ce708d..bdad62e8 100644 --- a/resources/js/components/accounts/create-account-dialog.tsx +++ b/resources/js/components/accounts/create-account-dialog.tsx @@ -17,6 +17,7 @@ import { } from '@/components/ui/tooltip'; import { encrypt, importKey } from '@/lib/crypto'; import { getStoredKey } from '@/lib/key-storage'; +import { __ } from '@/utils/i18n'; import { router } from '@inertiajs/react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { AccountForm, AccountFormData } from './account-form'; @@ -163,7 +164,7 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) { } const createButton = ( - + ); return ( @@ -189,9 +190,11 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) { - Create Account + {__('Create Account')} - Add a new bank account to track your transactions. + {__( + 'Add a new bank account to track your transactions.', + )}
@@ -204,7 +207,7 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) { onClick={() => setOpen(false)} disabled={isSubmitting} > - Cancel + {__('Cancel')}

- Square images only (max {MAX_DIMENSIONS}x - {MAX_DIMENSIONS}px) / {MAX_FILE_SIZE / 1024}KB max. + {__('Square images only (max')} + {MAX_DIMENSIONS}x{MAX_DIMENSIONS} + {__('px) /')} + {MAX_FILE_SIZE / 1024} + {__('KB max.')}

{error && ( diff --git a/resources/js/components/accounts/delete-account-dialog.tsx b/resources/js/components/accounts/delete-account-dialog.tsx index b587c234..c2c9fcb9 100644 --- a/resources/js/components/accounts/delete-account-dialog.tsx +++ b/resources/js/components/accounts/delete-account-dialog.tsx @@ -11,6 +11,7 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { type Account } from '@/types/account'; +import { __ } from '@/utils/i18n'; import { Form, router } from '@inertiajs/react'; import { useState } from 'react'; @@ -44,28 +45,30 @@ export function DeleteAccountDialog({ - Delete Account + {__('Delete Account')}

- This action is irreversible. All transactions in - this account will also be permanently deleted. + {__( + 'This action is irreversible. All transactions in\n this account will also be permanently deleted.', + )}

- Type DELETE to - confirm. + {__('Type')} + DELETE + {__('to\n confirm.')}

- + setConfirmText(e.target.value)} - placeholder="Type DELETE" + placeholder={__('Type DELETE')} autoComplete="off" />
@@ -89,7 +92,7 @@ export function DeleteAccountDialog({ onClick={() => handleOpenChange(false)} disabled={processing} > - Cancel + {__('Cancel')}
diff --git a/resources/js/components/accounts/import-balances/import-balance-step-account.tsx b/resources/js/components/accounts/import-balances/import-balance-step-account.tsx index 2e515e92..4f813a10 100644 --- a/resources/js/components/accounts/import-balances/import-balance-step-account.tsx +++ b/resources/js/components/accounts/import-balances/import-balance-step-account.tsx @@ -4,6 +4,7 @@ import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { type Account } from '@/types/account'; import type { UUID } from '@/types/uuid'; +import { __ } from '@/utils/i18n'; import { Building2 } from 'lucide-react'; interface ImportBalanceStepAccountProps { @@ -23,7 +24,7 @@ export function ImportBalanceStepAccount({ return (

- No accounts found. Please create an account first. + {__('No accounts found. Please create an account first.')}

); @@ -46,6 +47,7 @@ export function ImportBalanceStepAccount({ value={account.id} id={`account-${account.id}`} /> + {account.bank.logo ? ( diff --git a/resources/js/components/accounts/import-balances/import-balance-step-mapping.tsx b/resources/js/components/accounts/import-balances/import-balance-step-mapping.tsx index d9cb02b3..cadd4e16 100644 --- a/resources/js/components/accounts/import-balances/import-balance-step-mapping.tsx +++ b/resources/js/components/accounts/import-balances/import-balance-step-mapping.tsx @@ -15,6 +15,7 @@ import type { ParsedRow, } from '@/types/balance-import'; import { DateFormat } from '@/types/import'; +import { __ } from '@/utils/i18n'; interface ImportBalanceStepMappingProps { columnOptions: ColumnOption[]; @@ -77,7 +78,8 @@ export function ImportBalanceStepMapping({
- + {columnOptions.map((option, index) => ( @@ -132,7 +139,7 @@ export function ImportBalanceStepMapping({ {isValid && previewBalances.length > 0 && (
{previewBalances.map((balance, index) => ( @@ -154,7 +161,7 @@ export function ImportBalanceStepMapping({ {!dateFormatDetected && (
- + @@ -166,11 +173,12 @@ export function ImportBalanceStepMapping({ value={DateFormat.YearMonthDay} id="format-ymd" /> +
@@ -178,11 +186,12 @@ export function ImportBalanceStepMapping({ value={DateFormat.MonthDayYear} id="format-mdy" /> +
@@ -190,11 +199,12 @@ export function ImportBalanceStepMapping({ value={DateFormat.DayMonthYear} id="format-dmy" /> +
@@ -204,10 +214,10 @@ export function ImportBalanceStepMapping({
diff --git a/resources/js/components/accounts/import-balances/import-balance-step-preview.tsx b/resources/js/components/accounts/import-balances/import-balance-step-preview.tsx index 686b6a53..5dff6291 100644 --- a/resources/js/components/accounts/import-balances/import-balance-step-preview.tsx +++ b/resources/js/components/accounts/import-balances/import-balance-step-preview.tsx @@ -7,7 +7,10 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { useLocale } from '@/hooks/use-locale'; import { type ParsedBalance } from '@/types/balance-import'; +import { formatDateMedium } from '@/utils/date'; +import { __ } from '@/utils/i18n'; interface ImportBalanceStepPreviewProps { balances: ParsedBalance[]; @@ -24,30 +27,22 @@ export function ImportBalanceStepPreview({ onBack, isImporting, }: ImportBalanceStepPreviewProps) { + const locale = useLocale(); const total = balances.length; const formatBalance = (balance: number): string => { - return new Intl.NumberFormat('en-US', { + return new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode, }).format(balance / 100); }; - const formatDate = (dateStr: string): string => { - const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - }); - }; - return (

- {total} balance{total !== 1 ? 's' : ''} will be updated or - created. + {total} balance{total !== 1 ? 's' : ''} + {__('will be updated or\n created.')}

@@ -55,9 +50,9 @@ export function ImportBalanceStepPreview({
- Row + {__('Row')} - Date + {__('Date')} - Balance + {__('Balance')} - Error + {__('Error')}
- Date + {__('Date')} - Balance + {__('Balance')} @@ -75,7 +70,10 @@ export function ImportBalanceStepPreview({ balances.map((balance, index) => ( - {formatDate(balance.balance_date)} + {formatDateMedium( + balance.balance_date, + locale, + )} {formatBalance(balance.balance)} @@ -93,7 +91,7 @@ export function ImportBalanceStepPreview({ onClick={onBack} disabled={isImporting} > - Back + {__('Back')} diff --git a/resources/js/components/accounts/update-balance-dialog.tsx b/resources/js/components/accounts/update-balance-dialog.tsx index 691c4bf1..8d6d8a47 100644 --- a/resources/js/components/accounts/update-balance-dialog.tsx +++ b/resources/js/components/accounts/update-balance-dialog.tsx @@ -15,6 +15,7 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import type { Account, AccountBalance } from '@/types/account'; +import { __ } from '@/utils/i18n'; import { useEffect, useRef, useState } from 'react'; interface UpdateBalanceDialogProps { @@ -149,18 +150,20 @@ export function UpdateBalanceDialog({ - Update balance + {__('Update balance')} - Set the balance for this account on a specific date. + {__( + 'Set the balance for this account on a specific date.', + )}
- + {isLoadingLastBalance ? (
- Loading last balance... + {__('Loading last balance...')}
) : (
- + handleOpenChange(false)} disabled={isSubmitting || isLoadingLastBalance} > - Cancel + {__('Cancel')} diff --git a/resources/js/components/appearance-dropdown.tsx b/resources/js/components/appearance-dropdown.tsx index da87bb4a..15375fa3 100644 --- a/resources/js/components/appearance-dropdown.tsx +++ b/resources/js/components/appearance-dropdown.tsx @@ -6,6 +6,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { useAppearance } from '@/hooks/use-appearance'; +import { __ } from '@/utils/i18n'; import { Monitor, Moon, Sun } from 'lucide-react'; import { HTMLAttributes } from 'react'; @@ -36,20 +37,20 @@ export default function AppearanceToggleDropdown({ className="h-9 w-9 rounded-md" > {getCurrentIcon()} - Toggle theme + {__('Toggle theme')} updateAppearance('light')}> - Light + {__('Light')} updateAppearance('dark')}> - Dark + {__('Dark')} - System + {__('System')} diff --git a/resources/js/components/appearance-tabs.tsx b/resources/js/components/appearance-tabs.tsx index 1a1e271f..f0b7ce64 100644 --- a/resources/js/components/appearance-tabs.tsx +++ b/resources/js/components/appearance-tabs.tsx @@ -1,5 +1,6 @@ import { Appearance, useAppearance } from '@/hooks/use-appearance'; import { cn } from '@/lib/utils'; +import { __ } from '@/utils/i18n'; import { LucideIcon, Monitor, Moon, Sun } from 'lucide-react'; import { HTMLAttributes } from 'react'; @@ -10,9 +11,9 @@ export default function AppearanceToggleTab({ const { appearance, updateAppearance } = useAppearance(); const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [ - { value: 'light', icon: Sun, label: 'Light' }, - { value: 'dark', icon: Moon, label: 'Dark' }, - { value: 'system', icon: Monitor, label: 'System' }, + { value: 'light', icon: Sun, label: __('Light') }, + { value: 'dark', icon: Moon, label: __('Dark') }, + { value: 'system', icon: Monitor, label: __('System') }, ]; return ( diff --git a/resources/js/components/automation-rules/automation-rules-dialog.tsx b/resources/js/components/automation-rules/automation-rules-dialog.tsx index b8c291d1..86dfb91c 100644 --- a/resources/js/components/automation-rules/automation-rules-dialog.tsx +++ b/resources/js/components/automation-rules/automation-rules-dialog.tsx @@ -56,6 +56,7 @@ import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { type AutomationRule, getRuleActions } from '@/types/automation-rule'; import { type Category, getCategoryColorClasses } from '@/types/category'; import { type Label } from '@/types/label'; +import { __ } from '@/utils/i18n'; interface AutomationRulesDialogProps { open: boolean; @@ -81,22 +82,22 @@ function AutomationRuleActions({ - Actions + {__('Actions')} setEditOpen(true)}> - Edit + {__('Edit')} setDeleteOpen(true)} variant="destructive" > - Delete + {__('Delete')} @@ -154,15 +155,15 @@ function AutomationRuleRow({ - Actions + {__('Actions')} setEditOpen(true)}> - Edit + {__('Edit')} setDeleteOpen(true)} variant="destructive" > - Delete + {__('Delete')} @@ -220,7 +221,7 @@ export function AutomationRulesDialog({ const columns: ColumnDef[] = [ { accessorKey: 'priority', - header: 'Priority', + header: __('Priority'), cell: ({ row }) => { return (
@@ -231,7 +232,7 @@ export function AutomationRulesDialog({ }, { accessorKey: 'title', - header: 'Title', + header: __('Title'), cell: ({ row }) => { return (
{row.getValue('title')}
@@ -240,7 +241,7 @@ export function AutomationRulesDialog({ }, { id: 'actions_display', - header: 'Actions', + header: __('Actions'), cell: ({ row }) => { const rule = row.original; const actions = getRuleActions(rule); @@ -299,7 +300,7 @@ export function AutomationRulesDialog({ return ( - Add note + {__('Add note')} ); }, @@ -337,17 +338,18 @@ export function AutomationRulesDialog({ - Automation Rules + {__('Automation Rules')} - Manage your transaction automation rules. Rules will be - applied to categorize transactions automatically. + {__( + 'Manage your transaction automation rules. Rules will be applied to categorize transactions automatically.', + )}
- No automation rules found. + {__('No automation rules found.')} )} @@ -417,8 +419,8 @@ export function AutomationRulesDialog({
- {table.getFilteredRowModel().rows.length} rule(s) - total. + {table.getFilteredRowModel().rows.length}{' '} + {__('rule(s) total.')}
diff --git a/resources/js/components/automation-rules/create-automation-rule-dialog.tsx b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx index d6ec528f..4fff5a1c 100644 --- a/resources/js/components/automation-rules/create-automation-rule-dialog.tsx +++ b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx @@ -21,6 +21,7 @@ import { } from '@/lib/rule-builder-utils'; import type { Category } from '@/types/category'; import type { Label } from '@/types/label'; +import { __ } from '@/utils/i18n'; import { router } from '@inertiajs/react'; import { useState } from 'react'; @@ -124,26 +125,28 @@ export function CreateAutomationRuleDialog({ return ( - + - Create Automation Rule + {__('Create Automation Rule')} - Create a rule to automatically categorize transactions - and add labels. + {__( + 'Create a rule to automatically categorize transactions\n and add labels.', + )}
- Title + {__('Title')} setTitle(e.target.value)} - placeholder="Rule title" + placeholder={__('Rule title')} required /> + {errors.title && (

{errors.title} @@ -152,7 +155,9 @@ export function CreateAutomationRuleDialog({

- Priority + + {__('Priority')} + +

- Lower numbers execute first + {__('Lower numbers execute first')}

{errors.priority && (

@@ -179,21 +185,23 @@ export function CreateAutomationRuleDialog({ />

-

Actions

+

{__('Actions')}

- At least one action is required + {__('At least one action is required')}

- Set Category + {__('Set Category')}
@@ -201,13 +209,13 @@ export function CreateAutomationRuleDialog({
- Add Labels + {__('Add Labels')}
@@ -234,7 +242,7 @@ export function CreateAutomationRuleDialog({ onClick={() => setOpen(false)} disabled={isSubmitting} > - Cancel + {__('Cancel')}
@@ -219,7 +222,7 @@ export function RuleBuilder({ value, onChange, error }: RuleBuilderProps) { onClick={addGroup} > - Add Group + {__('Add Group')} {error &&

{error}

} @@ -314,7 +317,7 @@ function ConditionRow({ onChange={(e) => onChange({ ...condition, value: e.target.value }) } - placeholder="Value" + placeholder={__('Value')} className="w-full sm:flex-1" step={inputType === 'number' ? 'any' : undefined} /> diff --git a/resources/js/components/breadcrumbs.tsx b/resources/js/components/breadcrumbs.tsx index ef3aeb13..0fda6d12 100644 --- a/resources/js/components/breadcrumbs.tsx +++ b/resources/js/components/breadcrumbs.tsx @@ -7,6 +7,7 @@ import { BreadcrumbSeparator, } from '@/components/ui/breadcrumb'; import { type BreadcrumbItem as BreadcrumbItemType } from '@/types'; +import { __ } from '@/utils/i18n'; import { Link } from '@inertiajs/react'; import { Fragment } from 'react'; @@ -27,12 +28,12 @@ export function Breadcrumbs({ {isLast ? ( - {item.title} + {__(item.title)} ) : ( - {item.title} + {__(item.title)} )} diff --git a/resources/js/components/budgets/budget-list-card.tsx b/resources/js/components/budgets/budget-list-card.tsx index d59192a8..22f9d404 100644 --- a/resources/js/components/budgets/budget-list-card.tsx +++ b/resources/js/components/budgets/budget-list-card.tsx @@ -9,8 +9,11 @@ import { CardTitle, } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; +import { useLocale } from '@/hooks/use-locale'; import { Budget, getBudgetPeriodTypeLabel } from '@/types/budget'; import { formatCurrency } from '@/utils/currency'; +import { formatDate } from '@/utils/date'; +import { __ } from '@/utils/i18n'; import { Link } from '@inertiajs/react'; import { ArrowRight, Calendar } from 'lucide-react'; import { useMemo } from 'react'; @@ -21,6 +24,7 @@ interface Props { } export function BudgetListCard({ budget, currencyCode }: Props) { + const locale = useLocale(); const currentPeriod = budget.periods?.[0]; const stats = useMemo(() => { @@ -53,19 +57,13 @@ export function BudgetListCard({ budget, currencyCode }: Props) { }, [currentPeriod]); const periodLabel = useMemo(() => { - if (!currentPeriod) return 'No active period'; + if (!currentPeriod) return __('No active period'); - const start = new Date(currentPeriod.start_date).toLocaleDateString( - 'en-US', - { month: 'short', day: 'numeric' }, - ); - const end = new Date(currentPeriod.end_date).toLocaleDateString( - 'en-US', - { month: 'short', day: 'numeric' }, - ); + const start = formatDate(currentPeriod.start_date, 'MMM d', locale); + const end = formatDate(currentPeriod.end_date, 'MMM d', locale); return `${start} - ${end}`; - }, [currentPeriod]); + }, [currentPeriod, locale]); const statusColor = useMemo(() => { if (stats.percentageUsed >= 100) @@ -78,7 +76,7 @@ export function BudgetListCard({ budget, currencyCode }: Props) { const trackingLabel = useMemo(() => { if (budget.category) return budget.category.name; if (budget.label) return budget.label.name; - return 'No tracking'; + return __('No tracking'); }, [budget]); return ( @@ -93,16 +91,19 @@ export function BudgetListCard({ budget, currencyCode }: Props) {
- {getBudgetPeriodTypeLabel(budget.period_type)} + {__(getBudgetPeriodTypeLabel(budget.period_type))}
- Spent + + {__('Spent')} + - {formatCurrency(stats.totalSpent, currencyCode)} of{' '} + {formatCurrency(stats.totalSpent, currencyCode)}{' '} + {__('of')}{' '} {formatCurrency(stats.totalAllocated, currencyCode)}
@@ -110,8 +111,11 @@ export function BudgetListCard({ budget, currencyCode }: Props) { value={Math.min(stats.percentageUsed, 100)} className="h-2" /> +
- Remaining + + {__('Remaining')} + {formatCurrency(stats.remaining, currencyCode)} @@ -120,7 +124,8 @@ export function BudgetListCard({ budget, currencyCode }: Props) {
- Tracking: {trackingLabel} + {__('Tracking:')} + {trackingLabel} diff --git a/resources/js/components/budgets/budget-spending-chart.tsx b/resources/js/components/budgets/budget-spending-chart.tsx index aeabd393..bc54e309 100644 --- a/resources/js/components/budgets/budget-spending-chart.tsx +++ b/resources/js/components/budgets/budget-spending-chart.tsx @@ -10,8 +10,11 @@ import { ChartTooltip, type ChartConfig, } from '@/components/ui/chart'; +import { useLocale } from '@/hooks/use-locale'; import { BudgetPeriod } from '@/types/budget'; import { formatCurrency } from '@/utils/currency'; +import { formatDate } from '@/utils/date'; +import { __ } from '@/utils/i18n'; import { useMemo } from 'react'; import { Area, AreaChart, Line, XAxis } from 'recharts'; @@ -40,6 +43,7 @@ interface CustomTooltipProps { label?: string | number; currencyCode: string; hasPreviousPeriod: boolean; + locale: string; } function CustomTooltip({ @@ -47,6 +51,7 @@ function CustomTooltip({ payload, currencyCode, hasPreviousPeriod, + locale, }: CustomTooltipProps) { if (!active || !payload || !payload.length) { return null; @@ -64,21 +69,21 @@ function CustomTooltip({

{hasPreviousPeriod ? `Day ${data.day}` - : new Date(data.date).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - })} + : formatDate(data.date, 'MMM d, yyyy', locale)}

- Allocated: + + {__('Allocated:')} + {formatCurrency(allocated, currencyCode)}
- Spent: + + {__('Spent:')} + {formatCurrency(spent, currencyCode)} @@ -86,7 +91,7 @@ function CustomTooltip({ {hasPreviousPeriod && data.prevSpent !== undefined && (
- Last period: + {__('Last period:')} {formatCurrency(data.prevSpent, currencyCode)} @@ -95,7 +100,7 @@ function CustomTooltip({ )}
- Available: + {__('Available:')}
{percentage}% / @@ -135,6 +140,7 @@ export function BudgetSpendingChart({ budgetName, currencyCode, }: Props) { + const locale = useLocale(); const hasPreviousPeriod = !!previousPeriod; const chartData = useMemo(() => { @@ -212,23 +218,18 @@ export function BudgetSpendingChart({ } satisfies ChartConfig; const periodLabel = useMemo(() => { - const start = new Date(currentPeriod.start_date).toLocaleDateString( - 'en-US', - { month: 'short', day: 'numeric' }, - ); - const end = new Date(currentPeriod.end_date).toLocaleDateString( - 'en-US', - { month: 'short', day: 'numeric', year: 'numeric' }, - ); + const start = formatDate(currentPeriod.start_date, 'MMM d', locale); + const end = formatDate(currentPeriod.end_date, 'MMM d, yyyy', locale); return `${start} - ${end}`; - }, [currentPeriod]); + }, [currentPeriod, locale]); return ( - Budget Spending + {__('Budget Spending')} - Tracking spending for {budgetName} · {periodLabel} + {__('Tracking spending for')} + {budgetName} · {periodLabel} @@ -254,11 +255,13 @@ export function BudgetSpendingChart({ stopColor="var(--color-spent)" stopOpacity={0.8} /> + + + + + } /> + +
- Create Budget + {__('Create Budget')}
@@ -133,26 +134,32 @@ export function CreateBudgetDialog({ - Create Budget + {__('Create Budget')} - Set up a spending limit for a category or label. + {__( + 'Set up a spending limit for a category or label.', + )}
- Budget Name + + {__('Budget Name')} + setName(e.target.value)} - placeholder="e.g., Padel Budget" + placeholder={__('e.g., Padel Budget')} required />
- Period Type + + {__('Period Type')} + +

{periodType === 'monthly' ? 'Day of the month when the period starts (1-31)' @@ -230,7 +238,7 @@ export function CreateBudgetDialog({

- Category (Optional) + {__('Category (Optional)')}
- + {allLabels.map((label) => ( @@ -311,14 +327,15 @@ export function CreateBudgetDialog({ )}

- Select at least a category or a label to - track. + {__( + 'Select at least a category or a label to\n track.', + )}

- Allocated Amount + {__('Allocated Amount')} +

- How much do you want to budget per period? + {__( + 'How much do you want to budget per period?', + )}

- Rollover Type + {__('Rollover Type')} setName(e.target.value)} - placeholder="e.g., Monthly Budget" + placeholder={__('e.g., Monthly Budget')} required />
- +
+

{periodType === 'monthly' ? 'Day of the month when the period starts (1-31)' @@ -201,7 +206,7 @@ export function EditBudgetDialog({

+

- This will update the allocated amount for the - current and future periods. + {__( + 'This will update the allocated amount for the\n current and future periods.', + )}

- +
+ {errors.name && (

{errors.name} @@ -76,10 +80,12 @@ export function CreateCategoryDialog({

- + - + {CATEGORY_COLORS.map((color) => { @@ -126,7 +134,7 @@ export function CreateCategoryDialog({ - {color} + {__(color)}
@@ -142,14 +150,16 @@ export function CreateCategoryDialog({
- + + {errors.name && (

{errors.name} @@ -81,14 +83,16 @@ export function EditCategoryDialog({

- + - + {CATEGORY_COLORS.map((color) => { @@ -139,7 +145,7 @@ export function EditCategoryDialog({ - {color} + {__(color)}
@@ -155,7 +161,7 @@ export function EditCategoryDialog({
- + @@ -114,7 +122,7 @@ export default function DeleteUser() { resetAndClearErrors() } > - Cancel + {__('Cancel')} @@ -127,7 +135,7 @@ export default function DeleteUser() { type="submit" data-test="confirm-delete-user-button" > - Delete account + {__('Delete account')} diff --git a/resources/js/components/encryption-key-button.tsx b/resources/js/components/encryption-key-button.tsx index 614e25ff..81f3e09d 100644 --- a/resources/js/components/encryption-key-button.tsx +++ b/resources/js/components/encryption-key-button.tsx @@ -1,5 +1,6 @@ import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { cn } from '@/lib/utils'; +import { __ } from '@/utils/i18n'; import { LockKeyhole, LockKeyholeOpen } from 'lucide-react'; import { useEffect, useState } from 'react'; import { Button } from './ui/button'; @@ -70,8 +71,8 @@ export function EncryptionKeyButton() { onClick={handleClick} aria-label={ isKeySet - ? 'Lock encryption key' - : 'Unlock encryption key' + ? __('Lock encryption key') + : __('Unlock encryption key') } > {isKeySet ? ( @@ -83,8 +84,8 @@ export function EncryptionKeyButton() { {isKeySet - ? 'Click to lock encryption key' - : 'Click to unlock encryption key'} + ? __('Click to lock encryption key') + : __('Click to unlock encryption key')} @@ -103,11 +104,11 @@ export function EncryptionKeyButton() { - Clear Encryption Key? + {__('Clear Encryption Key?')} - This will remove your encryption key from this - browser session. You'll need to enter your password - again to unlock encrypted content. + {__( + "This will remove your encryption key from this browser session. You'll need to enter your password again to unlock encrypted content.", + )} @@ -115,9 +116,11 @@ export function EncryptionKeyButton() { variant="outline" onClick={() => setShowClearDialog(false)} > - Cancel + {__('Cancel')} + + - diff --git a/resources/js/components/labels/create-label-dialog.tsx b/resources/js/components/labels/create-label-dialog.tsx index 0d8eb0f3..2bcea33a 100644 --- a/resources/js/components/labels/create-label-dialog.tsx +++ b/resources/js/components/labels/create-label-dialog.tsx @@ -19,6 +19,7 @@ import { SelectValue, } from '@/components/ui/select'; import { getLabelColorClasses, LABEL_COLORS } from '@/types/label'; +import { __ } from '@/utils/i18n'; import { Form } from '@inertiajs/react'; import { useState } from 'react'; @@ -28,13 +29,13 @@ export function CreateLabelDialog({ onSuccess }: { onSuccess?: () => void }) { return ( - + - Create Label + {__('Create Label')} - Add a new label to tag your transactions. + {__('Add a new label to tag your transactions.')} void }) { {({ errors, processing }) => ( <>
- + + {errors.name && (

{errors.name} @@ -63,10 +65,12 @@ export function CreateLabelDialog({ onSuccess }: { onSuccess?: () => void }) {

- + + {errors.name && (

{errors.name} @@ -73,14 +75,16 @@ export function EditLabelDialog({

- + setPassword(e.target.value)} - placeholder="Enter a strong password" + placeholder={__('Enter a strong password')} disabled={processing} autoComplete="new-password" required minLength={12} /> +
@@ -116,39 +122,45 @@ export function StepEncryptionSetup({ onComplete }: StepEncryptionSetupProps) { ))}
- {passwordStrength.label} + {__(passwordStrength.label)}
= 12 ? 'text-emerald-600' : 'text-muted-foreground'}`} > - {password.length}/12 min + {password.length} + {__('/12 min')}
- + setConfirmPassword(e.target.value)} - placeholder="Confirm your password" + placeholder={__('Confirm your password')} disabled={processing} autoComplete="new-password" required /> + {confirmPassword && password === confirmPassword && (
- Passwords match + {__('Passwords match')}
)}
- +

{storagePreference === 'session' - ? 'Your key will be cleared when you close the browser.' - : 'Your key will be stored until you log out.'} + ? __( + 'Your key will be cleared when you close the browser.', + ) + : __('Your key will be stored until you log out.')}

@@ -188,8 +202,8 @@ export function StepEncryptionSetup({ onComplete }: StepEncryptionSetupProps) { type="submit" disabled={processing || password.length < 12} loading={processing} - loadingText="Setting up encryption..." - text={'Setup Encryption'} + loadingText={__('Setting up encryption...')} + text={__('Setup Encryption')} className="w-full sm:w-full" /> diff --git a/resources/js/components/onboarding/step-import-balances.tsx b/resources/js/components/onboarding/step-import-balances.tsx index 79f15f2e..0e615837 100644 --- a/resources/js/components/onboarding/step-import-balances.tsx +++ b/resources/js/components/onboarding/step-import-balances.tsx @@ -3,6 +3,7 @@ import { StepHeader } from '@/components/onboarding/step-header'; import { AmountInput } from '@/components/ui/amount-input'; import { Label } from '@/components/ui/label'; import { CreatedAccount } from '@/hooks/use-onboarding-state'; +import { __ } from '@/utils/i18n'; import { AlertCircle, TrendingUp, Wallet } from 'lucide-react'; import { useMemo, useState } from 'react'; @@ -24,7 +25,7 @@ export function StepImportBalances({ setError(null); if (balanceInCents === 0) { - setError('Please enter a balance'); + setError(__('Please enter a balance')); return; } @@ -35,15 +36,17 @@ export function StepImportBalances({ onComplete(); } catch (err) { console.error('Failed to set balance:', err); - setError('Failed to set balance. Please try again.'); + setError(__('Failed to set balance. Please try again.')); setIsSubmitting(false); } } const description = useMemo(() => { return account - ? `"${account.name}" is a ${account.type} account. These accounts track balance changes over time instead of individual transactions.` - : 'Set the current balance for this account to start tracking.'; + ? __( + 'This account tracks balance changes over time instead of individual transactions.', + ) + : __('Set the current balance for this account to start tracking.'); }, [account]); return ( @@ -51,7 +54,7 @@ export function StepImportBalances({ @@ -61,10 +64,13 @@ export function StepImportBalances({
-

Balance Tracking

+

+ {__('Balance Tracking')} +

- Perfect for investment portfolios and retirement - accounts + {__( + 'Perfect for investment portfolios and retirement\n accounts', + )}

@@ -72,22 +78,22 @@ export function StepImportBalances({
  • - Update balances periodically to track growth + {__('Update balances periodically to track growth')}
  • - Import balance history from CSV files + {__('Import balance history from CSV files')}
  • - View balance evolution over time + {__('View balance evolution over time')}
- +
diff --git a/resources/js/components/onboarding/step-import-transactions.tsx b/resources/js/components/onboarding/step-import-transactions.tsx index 9846f267..7cc9a4be 100644 --- a/resources/js/components/onboarding/step-import-transactions.tsx +++ b/resources/js/components/onboarding/step-import-transactions.tsx @@ -5,6 +5,7 @@ import { CreatedAccount } from '@/hooks/use-onboarding-state'; import { type Account, type Bank } from '@/types/account'; import { type AutomationRule } from '@/types/automation-rule'; import { type Category } from '@/types/category'; +import { __ } from '@/utils/i18n'; import { usePage } from '@inertiajs/react'; import { ArrowRight, FileSpreadsheet, Upload } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; @@ -42,8 +43,12 @@ export function StepImportTransactions({ const description = useMemo(() => { return account - ? `Import transactions for "${account.name}". You can export transaction history from your bank's website.` - : 'Import your transaction history to start tracking your finances.'; + ? __( + "Import transactions for your account. You can export transaction history from your bank's website.", + ) + : __( + 'Import your transaction history to start tracking your finances.', + ); }, [account]); return ( @@ -51,38 +56,44 @@ export function StepImportTransactions({

- How to Export from Your Bank: + {__('How to Export from Your Bank:')}

  1. 1 - Log in to your bank's website or app + + {__("Log in to your bank's website or app")} +
  2. 2 - Go to your account's transaction history + + {__("Go to your account's transaction history")} +
  3. 3 - Look for "Export" or "Download" option + + {__('Look for "Export" or "Download" option')} +
  4. 4 - Download as CSV or Excel format + {__('Download as CSV or Excel format')}
@@ -90,9 +101,11 @@ export function StepImportTransactions({
-

Supported formats

+

+ {__('Supported formats')} +

- CSV, XLS, XLSX files + {__('CSV, XLS, XLSX files')}

@@ -104,7 +117,7 @@ export function StepImportTransactions({ className="group w-full gap-2 !px-8 py-6 sm:w-auto" > - Import Transactions + {__('Import Transactions')} {hasImported && ( @@ -114,7 +127,8 @@ export function StepImportTransactions({ onClick={onComplete} className="group gap-2" > - Continue + {__('Continue')} + )} diff --git a/resources/js/components/onboarding/step-more-accounts.tsx b/resources/js/components/onboarding/step-more-accounts.tsx index cf9cf3ff..4a49b974 100644 --- a/resources/js/components/onboarding/step-more-accounts.tsx +++ b/resources/js/components/onboarding/step-more-accounts.tsx @@ -2,8 +2,8 @@ import { StepHeader } from '@/components/onboarding/step-header'; import { Button } from '@/components/ui/button'; import { CreatedAccount } from '@/hooks/use-onboarding-state'; import { formatAccountType } from '@/types/account'; +import { __ } from '@/utils/i18n'; import { Check, CheckCircle2, Plus, Wallet } from 'lucide-react'; -import { useMemo } from 'react'; import { StepButton } from './step-button'; interface ExistingAccount { @@ -33,24 +33,22 @@ export function StepMoreAccounts({ onAddMore, onFinish, }: StepMoreAccountsProps) { - const totalAccounts = createdAccounts.length + existingAccounts.length; - - const description = useMemo(() => { - return `You've set up ${totalAccounts} account${totalAccounts !== 1 ? 's' : ''}. Would you like to add more or continue to the dashboard?`; - }, [totalAccounts]); + const description = __( + 'Would you like to add more accounts or continue to the dashboard?', + ); return (

- Your Accounts + {__('Your Accounts')}

{createdAccounts.map((account) => ( @@ -96,10 +94,13 @@ export function StepMoreAccounts({
-

Add More Accounts?

+

+ {__('Add More Accounts?')} +

- Track all your finances in one place — checking, - savings, credit cards, investments, and more. + {__( + 'Track all your finances in one place \u2014 checking,\n savings, credit cards, investments, and more.', + )}

- +
); } diff --git a/resources/js/components/onboarding/step-smart-rules.tsx b/resources/js/components/onboarding/step-smart-rules.tsx index efe7d149..8ede7d7e 100644 --- a/resources/js/components/onboarding/step-smart-rules.tsx +++ b/resources/js/components/onboarding/step-smart-rules.tsx @@ -1,5 +1,6 @@ import { StepButton } from '@/components/onboarding/step-button'; import { StepHeader } from '@/components/onboarding/step-header'; +import { __ } from '@/utils/i18n'; import { Bot, Eye, EyeOff, Sparkles, Zap } from 'lucide-react'; interface StepSmartRulesProps { @@ -12,8 +13,10 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {
@@ -22,11 +25,14 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {
-

Pattern Matching

+

+ {__('Pattern Matching')} +

- Create rules like "If description contains 'AMAZON', - categorize as Shopping" + {__( + 'Create rules like "If description contains \'AMAZON\',\n categorize as Shopping"', + )}

@@ -36,12 +42,13 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {

- Instant Application + {__('Instant Application')}

- Rules apply automatically when you import new - transactions + {__( + 'Rules apply automatically when you import new\n transactions', + )}

@@ -50,10 +57,10 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {

- Why No AI Auto-Categorization? + {__('Why No AI Auto-Categorization?')}

- Privacy comes first + {__('Privacy comes first')}

@@ -65,12 +72,14 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {

- AI requires sending your data to external - servers + {__( + 'AI requires sending your data to external\n servers', + )}

- This would break our end-to-end encryption - promise + {__( + 'This would break our end-to-end encryption\n promise', + )}

@@ -81,10 +90,12 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {

- Your rules run entirely in your browser + {__('Your rules run entirely in your browser')}

- We never see your transaction descriptions + {__( + 'We never see your transaction descriptions', + )}

@@ -95,17 +106,17 @@ export function StepSmartRules({ onContinue }: StepSmartRulesProps) {

- You're in complete control + {__("You're in complete control")}

- Create, edit, and delete rules anytime + {__('Create, edit, and delete rules anytime')}

- +
); } diff --git a/resources/js/components/onboarding/step-welcome.tsx b/resources/js/components/onboarding/step-welcome.tsx index e17859f7..66ef3e9a 100644 --- a/resources/js/components/onboarding/step-welcome.tsx +++ b/resources/js/components/onboarding/step-welcome.tsx @@ -1,5 +1,6 @@ import { StepButton } from '@/components/onboarding/step-button'; import { StepHeader } from '@/components/onboarding/step-header'; +import { __ } from '@/utils/i18n'; import { Bird } from 'lucide-react'; interface StepWelcomeProps { @@ -12,16 +13,21 @@ export function StepWelcome({ onContinue }: StepWelcomeProps) { Whisper Money')} + description={__( + "Take control of your finances with privacy-first money tracking. Let's set up your account in just a few minutes.", + )} large />
- +

- This will take less than 5 minutes + {__('This will take less than 5 minutes')}

diff --git a/resources/js/components/partials/header.tsx b/resources/js/components/partials/header.tsx index cb33bf18..6ad150ec 100644 --- a/resources/js/components/partials/header.tsx +++ b/resources/js/components/partials/header.tsx @@ -1,6 +1,7 @@ import { cn } from '@/lib/utils'; import { dashboard } from '@/routes'; import { type SharedData } from '@/types'; +import { __ } from '@/utils/i18n'; import { Link, usePage } from '@inertiajs/react'; import { BirdIcon, Github, StarIcon } from 'lucide-react'; import { useEffect, useState } from 'react'; @@ -50,6 +51,61 @@ export default function Header({ Whisper Money
- Uncategorized + {__('Uncategorized')} ) : ( @@ -133,12 +134,13 @@ export function CategoryCombobox({ + - No category found. + {__('No category found.')} {showUncategorized && ( - Uncategorized + {__('Uncategorized')} + {sortedLabels.length === 0 && !showCreateOption && ( - No labels found. + + {__('No labels found.')} + )} {allowRemoveAll && ( - Remove all labels + {__('Remove all labels')} )} {showCreateOption && ( diff --git a/resources/js/components/sync-status-button.tsx b/resources/js/components/sync-status-button.tsx index 72ae698a..2c851698 100644 --- a/resources/js/components/sync-status-button.tsx +++ b/resources/js/components/sync-status-button.tsx @@ -8,6 +8,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { useSyncContext } from '@/contexts/sync-context'; +import { __ } from '@/utils/i18n'; import { formatDistanceToNow } from 'date-fns'; import { CloudAlert, CloudCheck, CloudOff, RefreshCw } from 'lucide-react'; import { useState } from 'react'; @@ -83,7 +84,7 @@ export function SyncStatusButton() { - Sync now + {__('Sync now')} diff --git a/resources/js/components/transactions/bulk-actions-bar.tsx b/resources/js/components/transactions/bulk-actions-bar.tsx index 05ccab93..94501eb6 100644 --- a/resources/js/components/transactions/bulk-actions-bar.tsx +++ b/resources/js/components/transactions/bulk-actions-bar.tsx @@ -18,6 +18,7 @@ import { import { isAdmin } from '@/hooks/use-admin'; import { type Category } from '@/types/category'; import { type Label } from '@/types/label'; +import { __ } from '@/utils/i18n'; import { CheckCheck, MoreHorizontal, @@ -74,7 +75,8 @@ export function BulkActionsBar({
{isSelectingAll ? ( <> - All {displayCount} transaction + {__('All')} + {displayCount} transaction {displayCount !== 1 ? 's' : ''} selected ) : ( @@ -96,12 +98,15 @@ export function BulkActionsBar({ className="h-auto px-0 py-1 text-xs text-primary hover:text-primary/80" > - Select all + {__('Select all')} - Select all {totalFilteredCount}{' '} - transactions matching current filter + {__('Select all')} + {totalFilteredCount}{' '} + {__( + 'transactions matching current filter', + )} @@ -129,7 +134,7 @@ export function BulkActionsBar({ variant="outline" size="icon" disabled={isUpdating} - aria-label="More actions" + aria-label={__('More actions')} > @@ -141,7 +146,7 @@ export function BulkActionsBar({ disabled={isUpdating} > - Re-evaluate rules + {__('Re-evaluate rules')} {isDeleteEnabled && ( @@ -150,7 +155,7 @@ export function BulkActionsBar({ onSelect={onDelete} > - Delete + {__('Delete')} )} @@ -164,7 +169,7 @@ export function BulkActionsBar({ size="icon" onClick={onClear} disabled={isUpdating} - aria-label="Clear selection" + aria-label={__('Clear selection')} > diff --git a/resources/js/components/transactions/bulk-category-select.tsx b/resources/js/components/transactions/bulk-category-select.tsx index 50fb6ba3..6f31f9e7 100644 --- a/resources/js/components/transactions/bulk-category-select.tsx +++ b/resources/js/components/transactions/bulk-category-select.tsx @@ -1,5 +1,6 @@ import { CategorySelect } from '@/components/transactions/category-select'; import { type Category } from '@/types/category'; +import { __ } from '@/utils/i18n'; import { useState } from 'react'; interface BulkCategorySelectProps { @@ -27,7 +28,7 @@ export function BulkCategorySelect({ onValueChange={handleChange} categories={categories} disabled={disabled} - placeholder="Change category" + placeholder={__('Change category')} triggerClassName="h-9 w-[180px]" showUncategorized={true} /> diff --git a/resources/js/components/transactions/bulk-label-select.tsx b/resources/js/components/transactions/bulk-label-select.tsx index bb209734..da41acc5 100644 --- a/resources/js/components/transactions/bulk-label-select.tsx +++ b/resources/js/components/transactions/bulk-label-select.tsx @@ -1,5 +1,6 @@ import { LabelCombobox } from '@/components/shared/label-combobox'; import { type Label } from '@/types/label'; +import { __ } from '@/utils/i18n'; import { useState } from 'react'; interface BulkLabelSelectProps { @@ -26,7 +27,7 @@ export function BulkLabelSelect({ onValueChange={handleChange} labels={labels} disabled={disabled} - placeholder="Add labels" + placeholder={__('Add labels')} triggerClassName="h-9 w-[180px] min-h-9" allowCreate={true} allowRemoveAll={true} diff --git a/resources/js/components/transactions/category-cell.tsx b/resources/js/components/transactions/category-cell.tsx index 3b982aba..d9987580 100644 --- a/resources/js/components/transactions/category-cell.tsx +++ b/resources/js/components/transactions/category-cell.tsx @@ -7,6 +7,7 @@ import { transactionSyncService } from '@/services/transaction-sync'; import { type Account, type Bank } from '@/types/account'; import { type Category } from '@/types/category'; import { type DecryptedTransaction } from '@/types/transaction'; +import { __ } from '@/utils/i18n'; import { useState } from 'react'; import { toast } from 'sonner'; @@ -102,7 +103,7 @@ export function CategoryCell({ onValueChange={handleCategoryChange} categories={categories} disabled={isUpdating} - placeholder="Uncategorized" + placeholder={__('Uncategorized')} triggerClassName={cn( 'h-auto w-auto border-0 bg-transparent p-0 shadow-none focus:ring-0', className || '', diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index 46cf48db..f9d6c35d 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -27,6 +27,7 @@ import { import { Textarea } from '@/components/ui/textarea'; import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { useSyncContext } from '@/contexts/sync-context'; +import { useLocale } from '@/hooks/use-locale'; import { decrypt, encrypt, importKey } from '@/lib/crypto'; import { getStoredKey } from '@/lib/key-storage'; import { evaluateRulesForNewTransaction } from '@/lib/rule-engine'; @@ -41,7 +42,9 @@ import { type AutomationRule } from '@/types/automation-rule'; import { type Category } from '@/types/category'; import { type Label } from '@/types/label'; import { type DecryptedTransaction } from '@/types/transaction'; -import { format, getYear, parseISO } from 'date-fns'; +import { formatDate } from '@/utils/date'; +import { __ } from '@/utils/i18n'; +import { getYear, parseISO } from 'date-fns'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; @@ -70,6 +73,7 @@ export function EditTransactionDialog({ onSuccess, mode, }: EditTransactionDialogProps) { + const locale = useLocale(); const STORAGE_KEY_UPDATE_BALANCE = 'whisper_money_update_balance_on_transaction'; @@ -308,7 +312,9 @@ export function EditTransactionDialog({ } } catch (error) { console.error('Failed to update account balance:', error); - toast.error('Transaction created, but failed to update balance'); + toast.error( + __('Transaction created, but failed to update balance'), + ); } } @@ -317,26 +323,26 @@ export function EditTransactionDialog({ if (!isKeySet) { toast.error( - 'Please unlock your encryption key to save transactions', + __('Please unlock your encryption key to save transactions'), ); return; } if (mode === 'create') { if (!description.trim()) { - toast.error('Description is required'); + toast.error(__('Description is required')); return; } if (amount === 0) { - toast.error('Amount is required'); + toast.error(__('Amount is required')); return; } if (!accountId) { - toast.error('Account is required'); + toast.error(__('Account is required')); return; } if (!transactionDate) { - toast.error('Date is required'); + toast.error(__('Date is required')); return; } } else if ( @@ -344,7 +350,7 @@ export function EditTransactionDialog({ transaction?.source === 'manually_created' ) { if (!description.trim()) { - toast.error('Description is required'); + toast.error(__('Description is required')); return; } } @@ -354,7 +360,7 @@ export function EditTransactionDialog({ const trimmedDescription = description.trim(); const keyString = getStoredKey(); if (!keyString) { - throw new Error('Encryption key not available'); + throw new Error(__('Encryption key not available')); } const key = await importKey(keyString); @@ -396,7 +402,7 @@ export function EditTransactionDialog({ (acc) => acc.id === accountId, ); if (!selectedAccount) { - throw new Error('Selected account not found'); + throw new Error(__('Selected account not found')); } const createdTransaction = await transactionSyncService.create({ @@ -446,9 +452,13 @@ export function EditTransactionDialog({ ); } - toast.success('Transaction created successfully'); + toast.success(__('Transaction created successfully')); if (ruleResult.ruleName) { - toast.success(`Rule "${ruleResult.ruleName}" applied`); + toast.success( + __('Rule ":rule" applied', { + rule: ruleResult.ruleName, + }), + ); } onSuccess(newTransaction); @@ -538,7 +548,7 @@ export function EditTransactionDialog({ updatedRecord?.updated_at ?? transaction.updated_at, }; - toast.success('Transaction updated successfully'); + toast.success(__('Transaction updated successfully')); onSuccess(updatedTransaction); onOpenChange(false); @@ -548,7 +558,9 @@ export function EditTransactionDialog({ } catch (error) { console.error('Failed to save transaction:', error); toast.error( - `Failed to ${mode === 'create' ? 'create' : 'update'} transaction`, + mode === 'create' + ? __('Failed to create transaction') + : __('Failed to update transaction'), ); } finally { setIsSubmitting(false); @@ -564,13 +576,15 @@ export function EditTransactionDialog({ {mode === 'create' - ? 'Add Transaction' - : 'Edit Transaction'} + ? __('Add Transaction') + : __('Edit Transaction')} {mode === 'create' - ? 'Create a new transaction.' - : 'Update the category and notes for this transaction.'} + ? __('Create a new transaction.') + : __( + 'Update the category and notes for this transaction.', + )} @@ -585,7 +599,7 @@ export function EditTransactionDialog({ : '' } > - Date + {__('Date')} {mode === 'create' ? ( )} @@ -630,7 +655,7 @@ export function EditTransactionDialog({ : '' } > - Description + {__('Description')} {mode === 'create' || (mode === 'edit' && @@ -641,7 +666,7 @@ export function EditTransactionDialog({ onChange={(e) => setDescription(e.target.value) } - placeholder="Transaction description" + placeholder={__('Transaction description')} disabled={isSubmitting} required rows={3} @@ -658,10 +683,11 @@ export function EditTransactionDialog({ className="bg-muted" rows={3} /> +

- This transaction was imported from a - file. The description cannot be - modified. + {__( + 'This transaction was imported from a\n file. The description cannot be\n modified.', + )}

)} @@ -676,7 +702,7 @@ export function EditTransactionDialog({ : '' } > - Amount + {__('Amount')} {mode === 'create' ? ( <> @@ -703,11 +729,12 @@ export function EditTransactionDialog({ } disabled={isSubmitting} /> + - Update account balance + {__('Update account balance')} @@ -724,7 +751,9 @@ export function EditTransactionDialog({ {mode === 'create' && (
- Account + + {__('Account')} +