feat: Implement multi-language system (ID/EN) for member dashboard

- Create translation files (locales/id.ts, locales/en.ts)
- Add LanguageContext with useLanguage hook
- Add LanguageToggle component in sidebar
- Default language: Indonesian (ID)
- Translate WalletDialog and TransactionDialog
- Language preference persisted in localStorage
- Type-safe translations with autocomplete

Next: Translate remaining pages (Overview, Wallets, Transactions, Profile)
This commit is contained in:
dwindown
2025-10-12 08:51:48 +07:00
parent c0df4a7c2a
commit 371b5e0a66
10 changed files with 676 additions and 73 deletions

View File

@@ -0,0 +1,87 @@
# Multi-Language Implementation
## Overview
Implemented a simple, lightweight multi-language system for the member dashboard with Indonesian (default) and English support.
## Features
-**Default Language**: Indonesian (ID)
-**Optional Language**: English (EN)
-**Persistent**: Language preference saved in localStorage
-**Easy Toggle**: Language switcher in sidebar footer
-**Type-Safe**: Full TypeScript support with autocomplete
## Structure
### Translation Files
- `/apps/web/src/locales/id.ts` - Indonesian translations
- `/apps/web/src/locales/en.ts` - English translations
### Context & Hook
- `/apps/web/src/contexts/LanguageContext.tsx` - Language context provider
- Hook: `useLanguage()` - Access translations and language state
### Components
- `/apps/web/src/components/LanguageToggle.tsx` - Language switcher button
## Usage
### In Components
```typescript
import { useLanguage } from '@/contexts/LanguageContext'
function MyComponent() {
const { t, language, setLanguage } = useLanguage()
return (
<div>
<h1>{t.overview.title}</h1>
<p>{t.overview.totalBalance}</p>
</div>
)
}
```
### Translation Structure
```typescript
{
common: { search, filter, add, edit, delete, ... },
nav: { overview, transactions, wallets, profile, logout },
overview: { ... },
transactions: { ... },
wallets: { ... },
profile: { ... },
// etc
}
```
## Implementation Status
### ✅ Completed
1. Translation files (ID & EN)
2. Language context & provider
3. Language toggle component
4. Sidebar navigation translated
5. Build & lint passing
### 🔄 Next Steps (To be implemented)
1. Translate all member dashboard pages:
- Overview page
- Wallets page
- Transactions page
- Profile page
2. Translate dialogs:
- WalletDialog
- TransactionDialog
3. Update toast messages to use translations
4. Test language switching
## Admin Dashboard
- **Remains English-only** (as requested)
- No translation needed for admin pages
## Benefits
- ✅ No external dependencies
- ✅ Type-safe with autocomplete
- ✅ Easy to maintain
- ✅ Fast performance
- ✅ Can migrate to react-i18next later if needed

View File

@@ -1,5 +1,6 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { LanguageProvider } from './contexts/LanguageContext'
import { ThemeProvider } from './components/ThemeProvider'
import { Toaster } from './components/ui/sonner'
import { Dashboard } from './components/Dashboard'
@@ -59,9 +60,10 @@ export default function App() {
return (
<BrowserRouter>
<ThemeProvider defaultTheme="light" storageKey="tabungin-ui-theme">
<AuthProvider>
<Toaster />
<Routes>
<LanguageProvider>
<AuthProvider>
<Toaster />
<Routes>
{/* Public Routes */}
<Route path="/auth/login" element={<PublicRoute><Login /></PublicRoute>} />
<Route path="/auth/register" element={<PublicRoute><Register /></PublicRoute>} />
@@ -81,7 +83,8 @@ export default function App() {
{/* Protected Routes */}
<Route path="/*" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
</Routes>
</AuthProvider>
</AuthProvider>
</LanguageProvider>
</ThemeProvider>
</BrowserRouter>
)

View File

@@ -0,0 +1,24 @@
import { useLanguage } from '@/contexts/LanguageContext'
import { Button } from '@/components/ui/button'
import { Languages } from 'lucide-react'
export function LanguageToggle() {
const { language, setLanguage } = useLanguage()
const toggleLanguage = () => {
setLanguage(language === 'id' ? 'en' : 'id')
}
return (
<Button
variant="ghost"
size="sm"
onClick={toggleLanguage}
className="gap-2"
title={language === 'id' ? 'Switch to English' : 'Ganti ke Bahasa Indonesia'}
>
<Languages className="h-4 w-4" />
<span className="text-xs font-medium">{language === 'id' ? 'ID' : 'EN'}</span>
</Button>
)
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from "react"
import { toast } from "sonner"
import { useLanguage } from "@/contexts/LanguageContext"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -45,17 +46,19 @@ interface TransactionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
transaction?: Transaction | null
walletId?: string | null
onSuccess: () => void
}
const API = "/api"
export function TransactionDialog({ open, onOpenChange, transaction, onSuccess }: TransactionDialogProps) {
export function TransactionDialog({ open, onOpenChange, transaction, walletId: initialWalletId, onSuccess }: TransactionDialogProps) {
const { t } = useLanguage()
const [wallets, setWallets] = useState<Wallet[]>([])
const [categoryOptions, setCategoryOptions] = useState<Option[]>([])
const [loading, setLoading] = useState(false)
const [amount, setAmount] = useState(transaction?.amount?.toString() || "")
const [walletId, setWalletId] = useState(transaction?.walletId || "")
const [walletId, setWalletId] = useState(transaction?.walletId || initialWalletId || "")
const [direction, setDirection] = useState<"in" | "out">(transaction?.direction || "out")
const [categories, setCategories] = useState<string[]>(
transaction?.category ? transaction.category.split(',').map(c => c.trim()) : []
@@ -222,18 +225,18 @@ export function TransactionDialog({ open, onOpenChange, transaction, onSuccess }
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{isEditing ? "Edit Transaction" : "Add New Transaction"}</DialogTitle>
<DialogTitle>{isEditing ? t.transactionDialog.editTitle : t.transactionDialog.addTitle}</DialogTitle>
<DialogDescription>
{isEditing ? "Update your transaction details." : "Record a new transaction."}
{isEditing ? t.transactionDialog.editTitle : t.transactionDialog.addTitle}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="wallet">Wallet</Label>
<Label htmlFor="wallet">{t.transactionDialog.wallet}</Label>
<Select value={walletId} onValueChange={setWalletId}>
<SelectTrigger>
<SelectValue placeholder="Select a wallet" />
<SelectValue placeholder={t.transactionDialog.selectWallet} />
</SelectTrigger>
<SelectContent>
{wallets.map(wallet => (
@@ -247,58 +250,58 @@ export function TransactionDialog({ open, onOpenChange, transaction, onSuccess }
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="amount">Amount</Label>
<Label htmlFor="amount">{t.transactionDialog.amount}</Label>
<Input
id="amount"
type="number"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
placeholder={t.transactionDialog.amountPlaceholder}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="direction">Type</Label>
<Label htmlFor="direction">{t.transactionDialog.direction}</Label>
<Select value={direction} onValueChange={(value: "in" | "out") => setDirection(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="in">Income</SelectItem>
<SelectItem value="out">Expense</SelectItem>
<SelectItem value="in">{t.transactionDialog.income}</SelectItem>
<SelectItem value="out">{t.transactionDialog.expense}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="date">Date</Label>
<Label htmlFor="date">{t.transactionDialog.date}</Label>
<DatePicker
date={selectedDate}
onDateChange={(date) => date && setSelectedDate(date)}
placeholder="Select date"
placeholder={t.transactionDialog.selectDate}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="category">Categories</Label>
<Label htmlFor="category">{t.transactionDialog.category}</Label>
<MultipleSelector
options={categoryOptions}
selected={categories}
onChange={setCategories}
placeholder="Select or create categories..."
placeholder={t.transactionDialog.categoryPlaceholder}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="memo">Memo</Label>
<Label htmlFor="memo">{t.transactionDialog.memo}</Label>
<Input
id="memo"
value={memo}
onChange={(e) => setMemo(e.target.value)}
placeholder="Optional description"
placeholder={t.transactionDialog.memoPlaceholder}
/>
</div>
@@ -310,10 +313,10 @@ export function TransactionDialog({ open, onOpenChange, transaction, onSuccess }
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
{t.common.cancel}
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Saving..." : isEditing ? "Update" : "Create"}
{loading ? t.common.loading : isEditing ? t.common.save : t.common.add}
</Button>
</DialogFooter>
</form>

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from "react"
import { toast } from "sonner"
import { useLanguage } from "@/contexts/LanguageContext"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -56,6 +57,7 @@ interface WalletDialogProps {
const API = "/api"
export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDialogProps) {
const { t } = useLanguage()
const [name, setName] = useState(wallet?.name || "")
const [kind, setKind] = useState<"money" | "asset">(wallet?.kind || "money")
const [currency, setCurrency] = useState(wallet?.currency || "IDR")
@@ -71,7 +73,7 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
setError("Name is required")
setError(t.walletDialog.name + " is required")
return
}
@@ -141,40 +143,40 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{isEditing ? "Edit Wallet" : "Add New Wallet"}</DialogTitle>
<DialogTitle>{isEditing ? t.walletDialog.editTitle : t.walletDialog.addTitle}</DialogTitle>
<DialogDescription>
{isEditing ? "Update your wallet details." : "Create a new wallet to track your finances."}
{isEditing ? t.walletDialog.editTitle : t.walletDialog.addTitle}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Label htmlFor="name">{t.walletDialog.name}</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., My Bank Account"
placeholder={t.walletDialog.namePlaceholder}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="kind">Type</Label>
<Label htmlFor="kind">{t.walletDialog.type}</Label>
<Select value={kind} onValueChange={(value: "money" | "asset") => setKind(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="money">Money</SelectItem>
<SelectItem value="asset">Asset</SelectItem>
<SelectItem value="money">{t.walletDialog.money}</SelectItem>
<SelectItem value="asset">{t.walletDialog.asset}</SelectItem>
</SelectContent>
</Select>
</div>
{kind === "money" ? (
<div className="grid gap-2">
<Label htmlFor="currency">Currency</Label>
<Label htmlFor="currency">{t.walletDialog.currency}</Label>
<Popover open={currencyOpen} onOpenChange={setCurrencyOpen}>
<PopoverTrigger asChild>
<Button
@@ -185,13 +187,13 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
>
{currency
? CURRENCIES.find((curr) => curr.code === currency)?.code + " - " + CURRENCIES.find((curr) => curr.code === currency)?.name
: "Select currency..."}
: t.walletDialog.selectCurrency}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="Search currency..." />
<CommandInput placeholder={t.common.search + " currency..."} />
<CommandList>
<CommandEmpty>No currency found.</CommandEmpty>
<CommandGroup>
@@ -222,37 +224,37 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
) : (
<>
<div className="grid gap-2">
<Label htmlFor="unit">Unit</Label>
<Label htmlFor="unit">{t.walletDialog.unit}</Label>
<Input
id="unit"
value={unit}
onChange={(e) => setUnit(e.target.value)}
placeholder="e.g., shares, kg, pieces"
placeholder={t.walletDialog.unitPlaceholder}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="pricePerUnit">Price per Unit (IDR)</Label>
<Label htmlFor="pricePerUnit">{t.walletDialog.pricePerUnit}</Label>
<Input
id="pricePerUnit"
type="number"
step="0.01"
value={pricePerUnit}
onChange={(e) => setPricePerUnit(e.target.value)}
placeholder="0.00"
placeholder={t.walletDialog.pricePerUnitPlaceholder}
/>
</div>
</>
)}
<div className="grid gap-2">
<Label htmlFor="initialAmount">Initial Amount (Optional)</Label>
<Label htmlFor="initialAmount">{t.walletDialog.initialAmount}</Label>
<Input
id="initialAmount"
type="number"
step="0.01"
value={initialAmount}
onChange={(e) => setInitialAmount(e.target.value)}
placeholder="0.00"
placeholder={t.walletDialog.initialAmountPlaceholder}
/>
</div>
@@ -264,10 +266,10 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
Cancel
{t.common.cancel}
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Saving..." : isEditing ? "Update" : "Create"}
{loading ? t.common.loading : isEditing ? t.common.save : t.common.add}
</Button>
</DialogFooter>
</form>

View File

@@ -1,5 +1,6 @@
import { Home, Wallet, Receipt, User, LogOut } from "lucide-react"
import { Logo } from "../Logo"
import { LanguageToggle } from "../LanguageToggle"
import {
Sidebar,
SidebarContent,
@@ -13,31 +14,9 @@ import {
useSidebar,
} from "@/components/ui/sidebar"
import { useAuth } from "@/contexts/AuthContext"
import { useLanguage } from "@/contexts/LanguageContext"
import { getAvatarUrl } from "@/lib/utils"
const items = [
{
title: "Overview",
url: "/",
icon: Home,
},
{
title: "Wallets",
url: "/wallets",
icon: Wallet,
},
{
title: "Transactions",
url: "/transactions",
icon: Receipt,
},
{
title: "Profile",
url: "/profile",
icon: User,
},
]
interface AppSidebarProps {
currentPage: string
onNavigate: (page: string) => void
@@ -46,6 +25,30 @@ interface AppSidebarProps {
export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
const { user, logout } = useAuth()
const { isMobile, setOpenMobile } = useSidebar()
const { t } = useLanguage()
const items = [
{
title: t.nav.overview,
url: "/",
icon: Home,
},
{
title: t.nav.wallets,
url: "/wallets",
icon: Wallet,
},
{
title: t.nav.transactions,
url: "/transactions",
icon: Receipt,
},
{
title: t.nav.profile,
url: "/profile",
icon: User,
},
]
return (
<Sidebar>
@@ -109,13 +112,16 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
</div>
</div>
</div>
<button
onClick={logout}
className="w-full mt-3 flex items-center justify-center px-4 py-2 text-sm font-medium text-destructive bg-destructive/10 rounded-lg hover:bg-destructive/20 transition-colors cursor-pointer"
>
<LogOut className="h-4 w-4 mr-2" />
Logout
</button>
<div className="flex gap-2 mt-3">
<LanguageToggle />
<button
onClick={logout}
className="flex-1 flex items-center justify-center px-4 py-2 text-sm font-medium text-destructive bg-destructive/10 rounded-lg hover:bg-destructive/20 transition-colors cursor-pointer"
>
<LogOut className="h-4 w-4 mr-2" />
{t.nav.logout}
</button>
</div>
</SidebarFooter>
</Sidebar>
)

View File

@@ -0,0 +1,58 @@
import { createContext, useContext, useState, useEffect } from 'react'
import type { ReactNode } from 'react'
import { id } from '@/locales/id'
import { en } from '@/locales/en'
type Language = 'id' | 'en'
type Translations = typeof id
interface LanguageContextType {
language: Language
setLanguage: (lang: Language) => void
t: Translations
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined)
export function LanguageProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>(() => {
const stored = localStorage.getItem('language')
return (stored === 'en' || stored === 'id') ? stored : 'id' // Default to Indonesian
})
const translations: Record<Language, Translations> = {
id,
en,
}
const setLanguage = (lang: Language) => {
setLanguageState(lang)
localStorage.setItem('language', lang)
}
useEffect(() => {
// Save to localStorage whenever language changes
localStorage.setItem('language', language)
}, [language])
const value: LanguageContextType = {
language,
setLanguage,
t: translations[language],
}
return (
<LanguageContext.Provider value={value}>
{children}
</LanguageContext.Provider>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export function useLanguage() {
const context = useContext(LanguageContext)
if (context === undefined) {
throw new Error('useLanguage must be used within a LanguageProvider')
}
return context
}

210
apps/web/src/locales/en.ts Normal file
View File

@@ -0,0 +1,210 @@
export const en = {
common: {
search: 'Search',
filter: 'Filter',
add: 'Add',
edit: 'Edit',
delete: 'Delete',
cancel: 'Cancel',
save: 'Save',
close: 'Close',
loading: 'Loading...',
noData: 'No data',
confirm: 'Confirm',
success: 'Success',
error: 'Error',
total: 'Total',
date: 'Date',
amount: 'Amount',
status: 'Status',
actions: 'Actions',
all: 'All',
active: 'Active',
inactive: 'Inactive',
yes: 'Yes',
no: 'No',
},
nav: {
overview: 'Overview',
transactions: 'Transactions',
wallets: 'Wallets',
profile: 'Profile',
logout: 'Logout',
},
overview: {
title: 'Overview',
totalBalance: 'Total Balance',
totalIncome: 'Total Income',
totalExpense: 'Total Expense',
acrossWallets: 'Across {count} wallets',
income: 'income',
expense: 'expense',
recentTransactions: 'Recent Transactions',
viewAll: 'View All',
noTransactions: 'No transactions yet',
addFirstTransaction: 'Add your first transaction',
wallets: 'Wallets',
addWallet: 'Add Wallet',
noWallets: 'No wallets yet',
createFirstWallet: 'Create your first wallet',
incomeByCategory: 'Income by Category',
expenseByCategory: 'Expense by Category',
last30Days: 'Last 30 days',
last7Days: 'Last 7 days',
thisMonth: 'This month',
lastMonth: 'Last month',
thisYear: 'This year',
custom: 'Custom',
},
transactions: {
title: 'Transactions',
addTransaction: 'Add Transaction',
editTransaction: 'Edit Transaction',
deleteConfirm: 'Are you sure you want to delete this transaction?',
income: 'Income',
expense: 'Expense',
category: 'Category',
memo: 'Memo',
wallet: 'Wallet',
direction: 'Type',
filterByWallet: 'Filter by Wallet',
filterByDirection: 'Filter by Type',
filterByCategory: 'Filter by Category',
searchPlaceholder: 'Search transactions...',
noTransactions: 'No transactions',
stats: {
totalIncome: 'Total Income',
totalExpense: 'Total Expense',
netAmount: 'Net Amount',
},
},
wallets: {
title: 'Wallets',
addWallet: 'Add Wallet',
editWallet: 'Edit Wallet',
deleteConfirm: 'Are you sure you want to delete this wallet? All related transactions will be deleted.',
name: 'Name',
type: 'Type',
balance: 'Balance',
currency: 'Currency',
unit: 'Unit',
initialAmount: 'Initial Amount',
pricePerUnit: 'Price per Unit',
money: 'Money',
asset: 'Asset',
filterByType: 'Filter by Type',
searchPlaceholder: 'Search wallets...',
noWallets: 'No wallets yet',
createFirst: 'Create your first wallet to start tracking your finances',
totalBalance: 'Total Balance',
moneyWallets: 'Money Wallets',
assetWallets: 'Asset Wallets',
},
walletDialog: {
addTitle: 'Add Wallet',
editTitle: 'Edit Wallet',
name: 'Wallet Name',
namePlaceholder: 'e.g., Main Wallet, Savings',
type: 'Wallet Type',
money: 'Money',
asset: 'Asset',
currency: 'Currency',
selectCurrency: 'Select currency',
unit: 'Unit',
unitPlaceholder: 'e.g., grams, lots, shares',
initialAmount: 'Initial Amount (Optional)',
initialAmountPlaceholder: '0',
pricePerUnit: 'Price per Unit (Optional)',
pricePerUnitPlaceholder: '0',
pricePerUnitHelper: 'Price per {unit} in IDR',
},
transactionDialog: {
addTitle: 'Add Transaction',
editTitle: 'Edit Transaction',
amount: 'Amount',
amountPlaceholder: '0',
wallet: 'Wallet',
selectWallet: 'Select wallet',
direction: 'Transaction Type',
income: 'Income',
expense: 'Expense',
category: 'Category',
categoryPlaceholder: 'Select or type new category',
addCategory: 'Add',
memo: 'Memo (Optional)',
memoPlaceholder: 'Add a note...',
date: 'Date',
selectDate: 'Select date',
},
profile: {
title: 'Profile',
personalInfo: 'Personal Information',
name: 'Name',
email: 'Email',
emailVerified: 'Email Verified',
emailNotVerified: 'Email Not Verified',
avatar: 'Avatar',
changeAvatar: 'Change Avatar',
uploading: 'Uploading...',
security: 'Security',
password: 'Password',
currentPassword: 'Current Password',
newPassword: 'New Password',
confirmPassword: 'Confirm New Password',
changePassword: 'Change Password',
setPassword: 'Set Password',
noPassword: 'You logged in with Google and haven\'t set a password yet',
twoFactor: 'Two-Factor Authentication',
twoFactorDesc: 'Add an extra layer of security to your account',
phoneNumber: 'Phone Number',
phoneNumberPlaceholder: '+62812345678',
updatePhone: 'Update Phone',
emailOtp: 'Email OTP',
emailOtpDesc: 'Receive verification codes via email',
enable: 'Enable',
disable: 'Disable',
enabled: 'Enabled',
disabled: 'Disabled',
sendCode: 'Send Code',
verifyCode: 'Verify Code',
enterCode: 'Enter code',
whatsappOtp: 'WhatsApp OTP',
whatsappOtpDesc: 'Receive verification codes via WhatsApp',
authenticatorApp: 'Authenticator App',
authenticatorDesc: 'Use an authenticator app like Google Authenticator',
setup: 'Setup',
scanQr: 'Scan QR Code',
scanQrDesc: 'Scan this QR code with your authenticator app',
manualEntry: 'Or enter this code manually:',
enterAuthCode: 'Enter code from your authenticator app',
dangerZone: 'Danger Zone',
deleteAccount: 'Delete Account',
deleteAccountDesc: 'Permanently delete your account. This action cannot be undone.',
deleteAccountConfirm: 'Are you sure you want to delete your account? All data will be permanently lost.',
enterPasswordToDelete: 'Enter your password to confirm',
},
dateRange: {
last7Days: 'Last 7 days',
last30Days: 'Last 30 days',
thisMonth: 'This month',
lastMonth: 'Last month',
thisYear: 'This year',
custom: 'Custom',
from: 'From',
to: 'To',
},
}

210
apps/web/src/locales/id.ts Normal file
View File

@@ -0,0 +1,210 @@
export const id = {
common: {
search: 'Cari',
filter: 'Filter',
add: 'Tambah',
edit: 'Edit',
delete: 'Hapus',
cancel: 'Batal',
save: 'Simpan',
close: 'Tutup',
loading: 'Memuat...',
noData: 'Tidak ada data',
confirm: 'Konfirmasi',
success: 'Berhasil',
error: 'Gagal',
total: 'Total',
date: 'Tanggal',
amount: 'Jumlah',
status: 'Status',
actions: 'Aksi',
all: 'Semua',
active: 'Aktif',
inactive: 'Tidak Aktif',
yes: 'Ya',
no: 'Tidak',
},
nav: {
overview: 'Ringkasan',
transactions: 'Transaksi',
wallets: 'Dompet',
profile: 'Profil',
logout: 'Keluar',
},
overview: {
title: 'Ringkasan',
totalBalance: 'Total Saldo',
totalIncome: 'Total Pemasukan',
totalExpense: 'Total Pengeluaran',
acrossWallets: 'Dari {count} dompet',
income: 'pemasukan',
expense: 'pengeluaran',
recentTransactions: 'Transaksi Terkini',
viewAll: 'Lihat Semua',
noTransactions: 'Belum ada transaksi',
addFirstTransaction: 'Tambahkan transaksi pertama Anda',
wallets: 'Dompet',
addWallet: 'Tambah Dompet',
noWallets: 'Belum ada dompet',
createFirstWallet: 'Buat dompet pertama Anda',
incomeByCategory: 'Pemasukan per Kategori',
expenseByCategory: 'Pengeluaran per Kategori',
last30Days: '30 hari terakhir',
last7Days: '7 hari terakhir',
thisMonth: 'Bulan ini',
lastMonth: 'Bulan lalu',
thisYear: 'Tahun ini',
custom: 'Kustom',
},
transactions: {
title: 'Transaksi',
addTransaction: 'Tambah Transaksi',
editTransaction: 'Edit Transaksi',
deleteConfirm: 'Apakah Anda yakin ingin menghapus transaksi ini?',
income: 'Pemasukan',
expense: 'Pengeluaran',
category: 'Kategori',
memo: 'Catatan',
wallet: 'Dompet',
direction: 'Tipe',
filterByWallet: 'Filter berdasarkan Dompet',
filterByDirection: 'Filter berdasarkan Tipe',
filterByCategory: 'Filter berdasarkan Kategori',
searchPlaceholder: 'Cari transaksi...',
noTransactions: 'Tidak ada transaksi',
stats: {
totalIncome: 'Total Pemasukan',
totalExpense: 'Total Pengeluaran',
netAmount: 'Saldo Bersih',
},
},
wallets: {
title: 'Dompet',
addWallet: 'Tambah Dompet',
editWallet: 'Edit Dompet',
deleteConfirm: 'Apakah Anda yakin ingin menghapus dompet ini? Semua transaksi terkait akan ikut terhapus.',
name: 'Nama',
type: 'Tipe',
balance: 'Saldo',
currency: 'Mata Uang',
unit: 'Satuan',
initialAmount: 'Jumlah Awal',
pricePerUnit: 'Harga per Satuan',
money: 'Uang',
asset: 'Aset',
filterByType: 'Filter berdasarkan Tipe',
searchPlaceholder: 'Cari dompet...',
noWallets: 'Belum ada dompet',
createFirst: 'Buat dompet pertama Anda untuk mulai melacak keuangan',
totalBalance: 'Total Saldo',
moneyWallets: 'Dompet Uang',
assetWallets: 'Dompet Aset',
},
walletDialog: {
addTitle: 'Tambah Dompet',
editTitle: 'Edit Dompet',
name: 'Nama Dompet',
namePlaceholder: 'Contoh: Dompet Utama, Tabungan',
type: 'Tipe Dompet',
money: 'Uang',
asset: 'Aset',
currency: 'Mata Uang',
selectCurrency: 'Pilih mata uang',
unit: 'Satuan',
unitPlaceholder: 'Contoh: gram, lot, lembar',
initialAmount: 'Jumlah Awal (Opsional)',
initialAmountPlaceholder: '0',
pricePerUnit: 'Harga per Satuan (Opsional)',
pricePerUnitPlaceholder: '0',
pricePerUnitHelper: 'Harga per {unit} dalam IDR',
},
transactionDialog: {
addTitle: 'Tambah Transaksi',
editTitle: 'Edit Transaksi',
amount: 'Jumlah',
amountPlaceholder: '0',
wallet: 'Dompet',
selectWallet: 'Pilih dompet',
direction: 'Tipe Transaksi',
income: 'Pemasukan',
expense: 'Pengeluaran',
category: 'Kategori',
categoryPlaceholder: 'Pilih atau ketik kategori baru',
addCategory: 'Tambah',
memo: 'Catatan (Opsional)',
memoPlaceholder: 'Tambahkan catatan...',
date: 'Tanggal',
selectDate: 'Pilih tanggal',
},
profile: {
title: 'Profil',
personalInfo: 'Informasi Pribadi',
name: 'Nama',
email: 'Email',
emailVerified: 'Email Terverifikasi',
emailNotVerified: 'Email Belum Terverifikasi',
avatar: 'Avatar',
changeAvatar: 'Ubah Avatar',
uploading: 'Mengunggah...',
security: 'Keamanan',
password: 'Password',
currentPassword: 'Password Saat Ini',
newPassword: 'Password Baru',
confirmPassword: 'Konfirmasi Password Baru',
changePassword: 'Ubah Password',
setPassword: 'Atur Password',
noPassword: 'Anda login dengan Google dan belum mengatur password',
twoFactor: 'Autentikasi Dua Faktor',
twoFactorDesc: 'Tambahkan lapisan keamanan ekstra ke akun Anda',
phoneNumber: 'Nomor Telepon',
phoneNumberPlaceholder: '+62812345678',
updatePhone: 'Update Nomor',
emailOtp: 'Email OTP',
emailOtpDesc: 'Terima kode verifikasi via email',
enable: 'Aktifkan',
disable: 'Nonaktifkan',
enabled: 'Aktif',
disabled: 'Tidak Aktif',
sendCode: 'Kirim Kode',
verifyCode: 'Verifikasi Kode',
enterCode: 'Masukkan kode',
whatsappOtp: 'WhatsApp OTP',
whatsappOtpDesc: 'Terima kode verifikasi via WhatsApp',
authenticatorApp: 'Authenticator App',
authenticatorDesc: 'Gunakan aplikasi authenticator seperti Google Authenticator',
setup: 'Setup',
scanQr: 'Scan QR Code',
scanQrDesc: 'Scan QR code ini dengan aplikasi authenticator Anda',
manualEntry: 'Atau masukkan kode ini secara manual:',
enterAuthCode: 'Masukkan kode dari aplikator authenticator',
dangerZone: 'Zona Berbahaya',
deleteAccount: 'Hapus Akun',
deleteAccountDesc: 'Hapus akun Anda secara permanen. Tindakan ini tidak dapat dibatalkan.',
deleteAccountConfirm: 'Apakah Anda yakin ingin menghapus akun Anda? Semua data akan hilang permanen.',
enterPasswordToDelete: 'Masukkan password Anda untuk konfirmasi',
},
dateRange: {
last7Days: '7 hari terakhir',
last30Days: '30 hari terakhir',
thisMonth: 'Bulan ini',
lastMonth: 'Bulan lalu',
thisYear: 'Tahun ini',
custom: 'Kustom',
from: 'Dari',
to: 'Sampai',
},
}

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/breadcrumb.tsx","./src/components/dashboard.tsx","./src/components/logo.tsx","./src/components/themeprovider.tsx","./src/components/themetoggle.tsx","./src/components/admin/adminbreadcrumb.tsx","./src/components/admin/adminlayout.tsx","./src/components/admin/adminsidebar.tsx","./src/components/admin/pages/admindashboard.tsx","./src/components/admin/pages/adminpaymentmethods.tsx","./src/components/admin/pages/adminpayments.tsx","./src/components/admin/pages/adminplans.tsx","./src/components/admin/pages/adminsettings.tsx","./src/components/admin/pages/adminusers.tsx","./src/components/dialogs/transactiondialog.tsx","./src/components/dialogs/walletdialog.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/authlayout.tsx","./src/components/layout/dashboardlayout.tsx","./src/components/pages/authcallback.tsx","./src/components/pages/login.tsx","./src/components/pages/otpverification.tsx","./src/components/pages/overview.tsx","./src/components/pages/profile.tsx","./src/components/pages/register.tsx","./src/components/pages/transactions.tsx","./src/components/pages/wallets.tsx","./src/components/test/calendartest.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/chart.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/command.tsx","./src/components/ui/date-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-select.tsx","./src/components/ui/multiselector.tsx","./src/components/ui/popover.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/constants/currencies.ts","./src/contexts/authcontext.tsx","./src/hooks/use-mobile.ts","./src/hooks/usetheme.ts","./src/lib/utils.ts","./src/utils/exchangerate.ts","./src/utils/numberformat.ts"],"version":"5.9.2"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/breadcrumb.tsx","./src/components/dashboard.tsx","./src/components/languagetoggle.tsx","./src/components/logo.tsx","./src/components/themeprovider.tsx","./src/components/themetoggle.tsx","./src/components/admin/adminbreadcrumb.tsx","./src/components/admin/adminlayout.tsx","./src/components/admin/adminsidebar.tsx","./src/components/admin/pages/admindashboard.tsx","./src/components/admin/pages/adminpaymentmethods.tsx","./src/components/admin/pages/adminpayments.tsx","./src/components/admin/pages/adminplans.tsx","./src/components/admin/pages/adminsettings.tsx","./src/components/admin/pages/adminusers.tsx","./src/components/dialogs/transactiondialog.tsx","./src/components/dialogs/walletdialog.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/authlayout.tsx","./src/components/layout/dashboardlayout.tsx","./src/components/pages/authcallback.tsx","./src/components/pages/login.tsx","./src/components/pages/otpverification.tsx","./src/components/pages/overview.tsx","./src/components/pages/profile.tsx","./src/components/pages/register.tsx","./src/components/pages/transactions.tsx","./src/components/pages/wallets.tsx","./src/components/test/calendartest.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/chart.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/command.tsx","./src/components/ui/date-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-select.tsx","./src/components/ui/multiselector.tsx","./src/components/ui/popover.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/constants/currencies.ts","./src/contexts/authcontext.tsx","./src/contexts/languagecontext.tsx","./src/hooks/use-mobile.ts","./src/hooks/usetheme.ts","./src/lib/utils.ts","./src/locales/en.ts","./src/locales/id.ts","./src/utils/exchangerate.ts","./src/utils/numberformat.ts"],"version":"5.9.2"}