diff --git a/PROJECT_STANDARDS.md b/PROJECT_STANDARDS.md new file mode 100644 index 0000000..2226d3e --- /dev/null +++ b/PROJECT_STANDARDS.md @@ -0,0 +1,203 @@ +# Tabungin Project Standards + +## 🌐 Multilingual Support (COMPLETED) + +### Languages Supported: +- ✅ **English (EN)** - Full translation +- ✅ **Indonesian (ID)** - Full translation (Default) + +### Implementation: +- **Location:** `/apps/web/src/locales/` + - `en.ts` - English translations + - `id.ts` - Indonesian translations +- **Context:** `LanguageContext.tsx` - Language state management +- **Component:** `LanguageToggle.tsx` - Language switcher UI +- **Usage:** `const { t } = useLanguage()` then `t.section.key` + +### Translation Structure: +```typescript +{ + nav: { ... }, + overview: { ... }, + wallets: { ... }, + transactions: { ... }, + profile: { ... }, + walletDialog: { ... }, + transactionDialog: { ... }, + dateRange: { ... } +} +``` + +### Rules: +1. ✅ **ALL user-facing text MUST use translations** - No hardcoded strings +2. ✅ **Both EN and ID must have identical keys** - Type-safe with TypeScript +3. ✅ **Toast messages use translations** - `toast.success(t.section.key)` +4. ✅ **Error messages use translations** - Consistent UX + +--- + +## 🎨 UI Component Library (COMPLETED) + +### Framework: **shadcn/ui** +- Built on Radix UI primitives +- Tailwind CSS styling +- Fully customizable components + +### Components Used: +- ✅ Button, Input, Label, Badge +- ✅ Card, Alert, Separator, Tabs +- ✅ Dialog, Drawer (Responsive) +- ✅ Select, Popover, Calendar +- ✅ Sidebar, Breadcrumb +- ✅ Toast (Sonner) + +### Styling Convention: +- **Tailwind CSS** for all styling +- **Dark mode support** via `useTheme` hook +- **Responsive design** with `md:`, `lg:` breakpoints + +--- + +## 📱 Mobile UI/UX Optimization (COMPLETED ✅) + +### Status: +- ✅ **Profile Page** - Fully optimized +- ✅ **Overview Page** - Fully optimized +- ✅ **Wallets Page** - Fully optimized +- ✅ **Transactions Page** - Fully optimized +- ✅ **Responsive Dialogs** - Desktop (Dialog) / Mobile (Drawer) + +### Mobile Standards: + +#### 1. Touch Target Sizes +- **Buttons:** `h-11` (44px) on mobile, `h-10` on desktop +- **Inputs:** `h-11` (44px) on mobile, `h-10` on desktop +- **Minimum width:** `min-w-[100px]` for buttons +- **Padding:** `px-6` on mobile, `px-4` on desktop + +#### 2. Typography +- **Inputs/Labels:** `text-base` (16px) on mobile to prevent iOS zoom +- **Desktop:** `text-sm` (14px) for compact layout +- **Responsive:** `text-base md:text-sm` + +#### 3. Spacing +- **Form fields:** `space-y-3` on mobile, `space-y-2` on desktop +- **Touch targets:** Minimum 8px gap between tappable elements +- **Padding:** More generous on mobile (`p-6` vs `p-4`) + +#### 4. Responsive Patterns +```tsx +// Button sizing + + + + + + ) +} diff --git a/apps/web/src/components/dialogs/TransactionDialog.tsx b/apps/web/src/components/dialogs/TransactionDialog.tsx index 9d00fb5..3a5d501 100644 --- a/apps/web/src/components/dialogs/TransactionDialog.tsx +++ b/apps/web/src/components/dialogs/TransactionDialog.tsx @@ -231,11 +231,11 @@ export function TransactionDialog({ open, onOpenChange, transaction, walletId: i
-
+
- + setAmount(e.target.value)} - placeholder={t.transactionDialog.amountPlaceholder} - required + placeholder="0" + className="h-11 md:h-9 text-base md:text-sm" />
- + setMemo(e.target.value)} - placeholder={t.transactionDialog.memoPlaceholder} + placeholder={t.transactionDialog.addMemo} + className="h-11 md:h-9 text-base md:text-sm" />
@@ -312,10 +315,10 @@ export function TransactionDialog({ open, onOpenChange, transaction, walletId: i )}
- - diff --git a/apps/web/src/components/dialogs/WalletDialog.tsx b/apps/web/src/components/dialogs/WalletDialog.tsx index 69884f3..9e85df3 100644 --- a/apps/web/src/components/dialogs/WalletDialog.tsx +++ b/apps/web/src/components/dialogs/WalletDialog.tsx @@ -149,22 +149,23 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi -
+
- + setName(e.target.value)} placeholder={t.walletDialog.namePlaceholder} + className="h-11 md:h-9 text-base md:text-sm" required />
- + setUnit(e.target.value)} placeholder={t.walletDialog.unitPlaceholder} + className="h-11 md:h-9 text-base md:text-sm" />
- + setPricePerUnit(e.target.value)} - placeholder={t.walletDialog.pricePerUnitPlaceholder} + placeholder="0" + className="h-11 md:h-9 text-base md:text-sm" />
)}
- + setInitialAmount(e.target.value)} - placeholder={t.walletDialog.initialAmountPlaceholder} + placeholder="0" + className="h-11 md:h-9 text-base md:text-sm" />
@@ -265,10 +270,10 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi )}
- - diff --git a/apps/web/src/components/layout/AppSidebar.tsx b/apps/web/src/components/layout/AppSidebar.tsx index 2f836c1..9ecf06d 100644 --- a/apps/web/src/components/layout/AppSidebar.tsx +++ b/apps/web/src/components/layout/AppSidebar.tsx @@ -1,6 +1,5 @@ import { Home, Wallet, Receipt, User, LogOut } from "lucide-react" import { Logo } from "../Logo" -import { LanguageToggle } from "../LanguageToggle" import { Sidebar, SidebarContent, @@ -112,16 +111,13 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
-
- - -
+ ) diff --git a/apps/web/src/components/layout/DashboardLayout.tsx b/apps/web/src/components/layout/DashboardLayout.tsx index 5be745d..adf5b6a 100644 --- a/apps/web/src/components/layout/DashboardLayout.tsx +++ b/apps/web/src/components/layout/DashboardLayout.tsx @@ -1,15 +1,67 @@ +import { useState } from "react" import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar" import { AppSidebar } from "./AppSidebar" import { ThemeToggle } from "@/components/ThemeToggle" +import { LanguageToggle } from "@/components/LanguageToggle" import { Breadcrumb } from "@/components/Breadcrumb" +import { FloatingActionButton, FABTrendingUpIcon, FABWalletIcon, FABReceiptIcon } from "@/components/ui/floating-action-button" +import { AssetPriceUpdateDialog } from "@/components/dialogs/AssetPriceUpdateDialog" +import { WalletDialog } from "@/components/dialogs/WalletDialog" +import { TransactionDialog } from "@/components/dialogs/TransactionDialog" +import { useLanguage } from "@/contexts/LanguageContext" interface DashboardLayoutProps { children: React.ReactNode currentPage: string onNavigate: (page: string) => void + onOpenWalletDialog?: () => void + onOpenTransactionDialog?: () => void + fabWalletDialogOpen: boolean + setFabWalletDialogOpen: (open: boolean) => void + fabTransactionDialogOpen: boolean + setFabTransactionDialogOpen: (open: boolean) => void } -export function DashboardLayout({ children, currentPage, onNavigate }: DashboardLayoutProps) { +export function DashboardLayout({ + children, + currentPage, + onNavigate, + onOpenWalletDialog, + onOpenTransactionDialog, + fabWalletDialogOpen, + setFabWalletDialogOpen, + fabTransactionDialogOpen, + setFabTransactionDialogOpen +}: DashboardLayoutProps) { + const { t } = useLanguage() + const [assetPriceDialogOpen, setAssetPriceDialogOpen] = useState(false) + + const handleDialogSuccess = () => { + // Reload page to refresh data + window.location.reload() + } + + const fabActions = [ + { + icon: , + label: t.fab.updateAssetPrices, + onClick: () => setAssetPriceDialogOpen(true), + variant: "default" as const, + }, + { + icon: , + label: t.fab.quickTransaction, + onClick: () => onOpenTransactionDialog?.(), + variant: "secondary" as const, + }, + { + icon: , + label: t.fab.quickWallet, + onClick: () => onOpenWalletDialog?.(), + variant: "secondary" as const, + }, + ] + return ( @@ -27,7 +79,10 @@ export function DashboardLayout({ children, currentPage, onNavigate }: Dashboard year: 'numeric', })} - +
+ + +
@@ -35,6 +90,25 @@ export function DashboardLayout({ children, currentPage, onNavigate }: Dashboard
+ + {/* Floating Action Button */} + + + {/* FAB Dialogs */} + + +
) diff --git a/apps/web/src/components/pages/Overview.tsx b/apps/web/src/components/pages/Overview.tsx index e8e8684..08354d8 100644 --- a/apps/web/src/components/pages/Overview.tsx +++ b/apps/web/src/components/pages/Overview.tsx @@ -3,6 +3,7 @@ import { useLanguage } from "@/contexts/LanguageContext" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" +import { Label } from "@/components/ui/label" import { Plus, Wallet, TrendingUp, TrendingDown, Calendar } from "lucide-react" import { ChartContainer, ChartTooltip } from "@/components/ui/chart" import { Bar, XAxis, YAxis, ResponsiveContainer, PieChart as RechartsPieChart, Pie, Cell, Line, ComposedChart } from "recharts" @@ -14,6 +15,11 @@ import { SelectValue, } from "@/components/ui/select" import { DatePicker } from "@/components/ui/date-picker" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" import { Table, TableBody, @@ -117,20 +123,30 @@ function getDateRangeLabel(dateRange: DateRange, customStartDate?: Date, customE } // Helper function to format Y-axis values with k/m suffix -function formatYAxisValue(value: number): string { +function formatYAxisValue(value: number, language: string = 'en'): string { const absValue = Math.abs(value) const sign = value < 0 ? '-' : '' - if (absValue >= 1000000) { - return `${sign}${(absValue / 1000000).toFixed(1)}m` + // Get suffix based on language + const getSuffix = (type: 'thousand' | 'million' | 'billion') => { + if (language === 'id') { + return { thousand: 'rb', million: 'jt', billion: 'm' }[type] + } + return { thousand: 'k', million: 'm', billion: 'b' }[type] + } + + if (absValue >= 1000000000) { + return `${sign}${(absValue / 1000000000).toFixed(1)}${getSuffix('billion')}` + } else if (absValue >= 1000000) { + return `${sign}${(absValue / 1000000).toFixed(1)}${getSuffix('million')}` } else if (absValue >= 1000) { - return `${sign}${(absValue / 1000).toFixed(1)}k` + return `${sign}${(absValue / 1000).toFixed(1)}${getSuffix('thousand')}` } return value.toLocaleString() } export function Overview() { - const { t } = useLanguage() + const { t, language } = useLanguage() const [wallets, setWallets] = useState([]) const [transactions, setTransactions] = useState([]) const [exchangeRates, setExchangeRates] = useState>({}) @@ -309,10 +325,14 @@ export function Overview() { for (let i = periodsCount - 1; i >= 0; i--) { const date = new Date(now) date.setDate(date.getDate() - i) + const locale = language === 'id' ? 'id-ID' : 'en-US' + const label = language === 'id' + ? `${date.getDate()} ${date.toLocaleDateString(locale, { month: 'short' })}` + : date.toLocaleDateString(locale, { month: 'short', day: 'numeric' }) periods.push({ start: new Date(date.getFullYear(), date.getMonth(), date.getDate()), end: new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59), - label: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + label }) } break @@ -327,10 +347,15 @@ export function Overview() { weekEnd.setDate(weekStart.getDate() + 6) weekEnd.setHours(23, 59, 59) + const locale = language === 'id' ? 'id-ID' : 'en-US' + const label = language === 'id' + ? `${weekStart.getDate()} ${weekStart.toLocaleDateString(locale, { month: 'short' })}` + : weekStart.toLocaleDateString(locale, { month: 'short', day: 'numeric' }) + periods.push({ start: weekStart, end: weekEnd, - label: `${weekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}` + label }) } break @@ -341,10 +366,11 @@ export function Overview() { const monthStart = new Date(date.getFullYear(), date.getMonth(), 1) const monthEnd = new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59) + const locale = language === 'id' ? 'id-ID' : 'en-US' periods.push({ start: monthStart, end: monthEnd, - label: date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }) + label: date.toLocaleDateString(locale, { month: 'short', year: '2-digit' }) }) } break @@ -498,8 +524,8 @@ export function Overview() { if (loading) { return (
-
- {[...Array(4)].map((_, i) => ( +
+ {[...Array(3)].map((_, i) => (
@@ -518,7 +544,7 @@ export function Overview() { return (
{/* Header */} -
+

{t.overview.title}

@@ -529,15 +555,15 @@ export function Overview() {
{/* Date Range Filter */} -
+
-
+
- {/* Custom Date Fields */} + {/* Custom Date Range Popover */} {dateRange === 'custom' && ( -
- - -
+ + + + + +
+
+ + +
+
+ + +
+
+
+
)}
-
- -
- - -
-
@@ -647,10 +678,10 @@ export function Overview() { {t.overview.walletTheadName} - {t.overview.walletTheadCurrencyUnit} - {t.overview.walletTheadTransactions} - {t.overview.walletTheadTotalBalance} - {t.overview.walletTheadDomination} + {t.overview.walletTheadCurrencyUnit} + {t.overview.walletTheadTransactions} + {t.overview.walletTheadTotalBalance} + {t.overview.walletTheadDomination} @@ -726,7 +757,7 @@ export function Overview() {
{t.overview.incomeCategoryFor} {getDateRangeLabel(dateRange, customStartDate, customEndDate).toLowerCase()} - + @@ -990,7 +1021,7 @@ export function Overview() {
{t.overview.financialTrendDescription} - - )} -
-
-

{user?.name || t.profile.name}

-

{user?.email}

- {hasGoogleAuth ? ( -

- {t.profile.avatarSynced} -

- ) : ( -

- {t.profile.clickUploadAvatar} -

- )} - {avatarError && ( -

{avatarError}

- )} -
-
- - - - {/* Name Field */} -
- - {hasGoogleAuth ? ( - <> - -

- {t.profile.nameSynced} -

- - ) : ( - <> -
- setEditedName(e.target.value)} - disabled={!isEditingName || nameLoading} - className={!isEditingName ? "bg-muted" : ""} - /> - {isEditingName ? ( - <> - - - - ) : ( - - )} -
- {nameError && ( -

{nameError}

- )} - {nameSuccess && ( -

{nameSuccess}

- )} - - )} -
- - {/* Email Field */} -
- - -

- {t.profile.emailCannotBeChanged} -

-
- - {/* Phone Field */} -
- -
- setPhone(e.target.value)} - disabled={phoneLoading} + {/* Edit Profile Tab */} + + + + {t.profile.personalInfo} + {t.profile.description} + + + {/* Avatar Section */} +
+
+ {getAvatarUrl(user?.avatarUrl) ? ( + {user?.name -
+ )} + {!hasGoogleAuth && ( +
- {phoneError && ( - - - {phoneError} - + {avatarUploading ? ( + + ) : ( + + )} + + )} - {phoneSuccess && ( - - - {phoneSuccess} - - )} -

- {t.profile.phoneNumberDescription} -

- - - +
+

{user?.name || t.profile.name}

+

{user?.email}

+ {hasGoogleAuth ? ( +

+ {t.profile.avatarSynced} +

+ ) : ( +

+ {t.profile.clickUploadAvatar} +

+ )} + {avatarError && ( +

{avatarError}

+ )} +
+
+ + + + {/* Name Field */} +
+ + {!isEditingName ? ( + <> + +

+ {t.profile.nameSynced} +

+ + ) : ( + <> +
+ setEditedName(e.target.value)} + disabled={nameLoading} + className="h-11 md:h-9 text-base md:text-sm" + /> + + +
+ {nameError && ( +

{nameError}

+ )} + + )} +
+ + {/* Email Field */} +
+ + +

+ {t.profile.emailCannotBeChanged} +

+
+ + {/* Phone Number Field */} +
+ +
+ setPhone(e.target.value)} + placeholder={t.profile.phoneNumberPlaceholder} + disabled={phoneLoading} + className="h-11 md:h-9 text-base md:text-sm" + /> + +
+ {phoneError && ( + + + {phoneError} + + )} +
+ + + {/* Security Tab */} @@ -747,15 +714,9 @@ export function Profile() { {passwordError} )} - {passwordSuccess && ( - - - {passwordSuccess} - - )} {hasPassword && (
- + setCurrentPassword(e.target.value)} disabled={passwordLoading} + className="h-11 md:h-9 text-base md:text-sm" />
)}
- + setNewPassword(e.target.value)} disabled={passwordLoading} + className="h-11 md:h-9 text-base md:text-sm" />
- + setConfirmPassword(e.target.value)} disabled={passwordLoading} + className="h-11 md:h-9 text-base md:text-sm" />
- {/* Danger Zone */} + + + + {/* Danger Zone - Inside Security Tab */} @@ -1146,14 +1122,15 @@ export function Profile() { )}
- + setDeletePassword(e.target.value)} disabled={deleteLoading} + className="h-11 md:h-9 text-base md:text-sm" />
@@ -1161,6 +1138,7 @@ export function Profile() { variant="destructive" onClick={handleDeleteAccount} disabled={deleteLoading || !deletePassword} + className="h-11 md:h-9 text-base md:text-sm" > {deleteLoading ? ( <> @@ -1182,6 +1160,7 @@ export function Profile() { setDeleteError("") }} disabled={deleteLoading} + className="h-11 md:h-9 text-base md:text-sm" > Cancel @@ -1191,6 +1170,7 @@ export function Profile() {
+
) } diff --git a/apps/web/src/components/pages/Transactions.tsx b/apps/web/src/components/pages/Transactions.tsx index afde6c4..cd85441 100644 --- a/apps/web/src/components/pages/Transactions.tsx +++ b/apps/web/src/components/pages/Transactions.tsx @@ -272,11 +272,11 @@ export function Transactions() {

- - @@ -333,7 +333,7 @@ export function Transactions() { variant="ghost" size="sm" onClick={clearFilters} - className="h-8 text-xs" + className="h-9 md:h-7 px-3 md:px-2 text-sm" > {t.common.clearAll} @@ -345,23 +345,23 @@ export function Transactions() {
{/* Search */}
- +
- + setSearchTerm(e.target.value)} - className="pl-9 h-9" + className="pl-9 h-11 md:h-9 text-base md:text-sm" />
{/* Wallet Filter */}
- + - + @@ -394,24 +394,24 @@ export function Transactions() { {/* Row 2: Amount Range */}
- + setAmountMin(e.target.value)} - className="h-9" + className="h-11 md:h-9 text-base md:text-sm" />
- + setAmountMax(e.target.value)} - className="h-9" + className="h-11 md:h-9 text-base md:text-sm" />
@@ -506,11 +506,11 @@ export function Transactions() { {t.transactions.tableTheadDate} {t.transactions.tableTheadWallet} - {t.transactions.tableTheadDirection} - {t.transactions.tableTheadAmount} - {t.transactions.tableTheadCategory} - {t.transactions.tableTheadMemo} - {t.transactions.tableTheadActions} + {t.transactions.tableTheadDirection} + {t.transactions.tableTheadAmount} + {t.transactions.tableTheadCategory} + {t.transactions.tableTheadMemo} + {t.transactions.tableTheadActions} @@ -541,7 +541,7 @@ export function Transactions() {
- + {formatCurrency(transaction.amount, wallet?.currency || wallet?.unit || 'IDR')} - + {transaction.category && ( {transaction.category} )} @@ -560,7 +560,7 @@ export function Transactions() { {transaction.memo} - +
- - @@ -223,7 +223,7 @@ export function Wallets() { variant="ghost" size="sm" onClick={clearFilters} - className="h-7 px-2" + className="h-9 md:h-7 px-3 md:px-2 text-sm" > {t.common.clearAll} @@ -235,23 +235,23 @@ export function Wallets() {
{/* Search */}
- +
- + setSearchTerm(e.target.value)} - className="pl-9 h-9" + className="pl-9 h-11 md:h-9 text-base md:text-sm" />
{/* Type Filter */}
- + - + @@ -330,11 +330,11 @@ export function Wallets() { - {t.wallets.name} - {t.wallets.currency}/{t.wallets.unit} - {t.wallets.type} - {t.common.date} - {t.common.actions} + {t.wallets.name} + {t.wallets.currency}/{t.wallets.unit} + {t.wallets.type} + {t.common.date} + {t.common.actions} @@ -351,14 +351,14 @@ export function Wallets() { filteredWallets.map((wallet) => ( {wallet.name} - + {wallet.kind === 'money' ? ( {wallet.currency} ) : ( {wallet.unit} )} - + - + {new Date(wallet.createdAt).toLocaleDateString()} - +
+ + {/* Action Menu - Above main button */} + {isOpen && ( +
+ {actions.map((action, index) => ( +
+ {/* Label */} + + {action.label} + + {/* Action Button */} + +
+ ))} +
+ )} +
+ + ) +} + +// Export icons for convenience +export { TrendingUp as FABTrendingUpIcon, Wallet as FABWalletIcon, Receipt as FABReceiptIcon } diff --git a/apps/web/src/components/ui/multi-select.tsx b/apps/web/src/components/ui/multi-select.tsx index e88480d..b48157c 100644 --- a/apps/web/src/components/ui/multi-select.tsx +++ b/apps/web/src/components/ui/multi-select.tsx @@ -63,7 +63,7 @@ function MultiSelect({ onKeyDown={handleKeyDown} className={cn("overflow-visible bg-transparent", className)} > -
+
{selected.map((item) => { const option = options.find((opt) => opt.value === item) @@ -120,7 +120,7 @@ function MultiSelect({ handleSelect(inputValue) setOpen(false) }} - className="cursor-pointer" + className="cursor-pointer min-h-[44px] md:min-h-0 py-2.5 md:py-1.5 text-base md:text-sm" > Create "{inputValue}" @@ -143,7 +143,7 @@ function MultiSelect({ handleSelect(option.value) setOpen(false) }} - className="cursor-pointer" + className="cursor-pointer min-h-[44px] md:min-h-0 py-2.5 md:py-1.5 text-base md:text-sm" > {option.label} diff --git a/apps/web/src/components/ui/multiselector.tsx b/apps/web/src/components/ui/multiselector.tsx index 9ec1ad4..26f86fa 100644 --- a/apps/web/src/components/ui/multiselector.tsx +++ b/apps/web/src/components/ui/multiselector.tsx @@ -118,6 +118,7 @@ export function MultipleSelector({ handleSetValue(inputValue) setInputValue("") }} + className="min-h-[44px] md:min-h-0 py-2.5 md:py-1.5 text-base md:text-sm" > { handleSetValue(option.value) }} + className="min-h-[44px] md:min-h-0 py-2.5 md:py-1.5 text-base md:text-sm" > [role=checkbox]]:translate-y-[2px]", + "h-12 md:h-10 px-3 md:px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] text-base md:text-sm", className )} {...props} @@ -88,7 +88,7 @@ const TableCell = React.forwardRef<
[role=checkbox]]:translate-y-[2px]", + "p-3 md:p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] text-base md:text-sm", className )} {...props} diff --git a/apps/web/src/constants/currencies.ts b/apps/web/src/constants/currencies.ts index a930dc9..365a3e4 100644 --- a/apps/web/src/constants/currencies.ts +++ b/apps/web/src/constants/currencies.ts @@ -41,8 +41,9 @@ export const getCurrencyByCode = (code: string) => { }; export const formatCurrency = (amount: number, currencyCode: string) => { + const useLanguage = localStorage.getItem('language') || 'en'; const currency = getCurrencyByCode(currencyCode); - if (!currency) return `${amount} ${(amount === 1) ? currencyCode : currencyCode + 's'}`; + if (!currency) return `${amount} ${(amount === 1) ? currencyCode : currencyCode + (useLanguage == 'en' ? 's' : '')}`; // For IDR, format without decimals if (currencyCode === 'IDR') { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 7825f39..f1d0ec4 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -419,3 +419,28 @@ body { .rdp-vhidden { display: none; } + +@media only screen and (max-width: 48rem) { + [data-radix-popper-content-wrapper] { + width: 90%; + } + [data-radix-popper-content-wrapper] .rdp-months, + [data-radix-popper-content-wrapper] .rdp-months .rdp-month, + [data-radix-popper-content-wrapper] table{ + width: 100%; + max-width: unset; + } + [data-radix-popper-content-wrapper] .rdp-dropdown_root{ + height: 2.75rem; + } + [data-radix-popper-content-wrapper] table tbody tr { + display: flex; + } + [data-radix-popper-content-wrapper] table *:is(th, td){ + flex-grow: 1; + } + [data-radix-popper-content-wrapper] table .rdp-day_button { + height: 2.75rem; + width: 2.75rem; + } +} \ No newline at end of file diff --git a/apps/web/src/locales/en.ts b/apps/web/src/locales/en.ts index b0d6e4d..d62e9c9 100644 --- a/apps/web/src/locales/en.ts +++ b/apps/web/src/locales/en.ts @@ -2,18 +2,17 @@ export const en = { common: { search: 'Search', filter: 'Filter', - clearAll: 'Clear All', - add: 'Add', - edit: 'Edit', - delete: 'Delete', cancel: 'Cancel', save: 'Save', + delete: 'Delete', + edit: 'Edit', + add: 'Add', close: 'Close', - loading: 'Loading...', - noData: 'No data', confirm: 'Confirm', + loading: 'Loading...', + noData: 'No data available', + error: 'An error occurred', success: 'Success', - error: 'Error', total: 'Total', date: 'Date', amount: 'Amount', @@ -28,6 +27,12 @@ export const en = { showFilters: 'Show Filters', hideFilters: 'Hide Filters', }, + + numberFormat: { + thousand: 'k', + million: 'm', + billion: 'b', + }, nav: { overview: 'Overview', @@ -44,6 +49,7 @@ export const en = { overviewPeriodPlaceholder: 'Select period', customStartDatePlaceholder: 'Pick start date', customEndDatePlaceholder: 'Pick end date', + selectDateRange: 'Select date range', totalBalance: 'Total Balance', totalIncome: 'Total Income', totalExpense: 'Total Expense', @@ -200,9 +206,11 @@ export const en = { expense: 'Expense', category: 'Category', categoryPlaceholder: 'Select or type new category', + selectCategory: 'Select or type new category', addCategory: 'Add', memo: 'Memo (Optional)', memoPlaceholder: 'Add a note...', + addMemo: 'Add a note...', date: 'Date', selectDate: 'Select date', addSuccess: 'Transaction added successfully', @@ -227,16 +235,25 @@ export const en = { save: 'Save', update: 'Update', cancel: 'Cancel', + nameSaved: 'Name saved successfully', + nameError: 'Name cannot be empty', + nameSuccess: 'Name updated successfully', + nameLoading: 'Updating name...', + nameLoadingError: 'Failed to update name', + email: 'Email', emailVerified: 'Email Verified', emailNotVerified: 'Email Not Verified', emailCannotBeChanged: 'Email cannot be changed', + avatar: 'Avatar', changeAvatar: 'Change Avatar', uploadAvatar: 'Upload Avatar', avatarSynced: 'Avatar is synced from your Google account', clickUploadAvatar: 'Click the upload button to change your avatar', uploading: 'Uploading...', + avatarSuccess: 'Avatar updated successfully', + avatarError: 'Failed to update avatar', security: 'Security', password: 'Password', @@ -252,6 +269,10 @@ export const en = { updating: 'Updating...', setPassword: 'Set Password', updatePassword: 'Update Password', + passwordSetSuccess: 'Password set successfully', + passwordChangeSuccess: 'Password changed successfully', + passwordError: 'Failed to set password', + enterPassword: 'Please enter your password', twoFactor: 'Two-Factor Authentication', twoFactorDesc: 'Add an extra layer of security to your account', @@ -259,12 +280,20 @@ export const en = { phoneNumberPlaceholder: '+62812345678', updatePhone: 'Update Phone', phoneNumberDescription: 'Required for WhatsApp OTP verification', + phoneInvalid: 'Invalid phone number', + phoneNotRegistered: 'This number is not registered on WhatsApp. Please try another number.', + phoneSuccess: 'Phone number updated successfully', + phoneError: 'Failed to update phone number', emailOtp: 'Email OTP', emailOtpDesc: 'Receive verification codes via email', enableEmailOtp: 'Enable Email OTP', disableEmailOtp: 'Disable Email OTP', checkYourEmailForTheVerificationCode: 'Check your email for the verification code', + emailOtpSent: 'OTP code has been sent to your email', + emailOtpEnabled: 'Email OTP enabled successfully', + emailOtpDisabled: 'Email OTP disabled successfully', + emailOtpError: 'Failed to send OTP code', enable: 'Enable', disable: 'Disable', enabled: 'Enabled', @@ -280,6 +309,10 @@ export const en = { pleaseAddYourPhoneNumberInTheEditProfileTabFirst: 'Please add your phone number in the Edit Profile tab first', checkYourWhatsAppForTheVerificationCodeOrCheckConsoleInTestMode: 'Check your WhatsApp for the verification code', enterVerificationCode: 'Enter 6 digit code', + whatsappOtpSent: 'OTP code has been sent to WhatsApp', + whatsappOtpEnabled: 'WhatsApp OTP enabled successfully', + whatsappOtpDisabled: 'WhatsApp OTP disabled successfully', + whatsappOtpError: 'Failed to send OTP code', authenticatorApp: 'Authenticator App', authenticatorDesc: 'Use an authenticator app like Google Authenticator', @@ -290,6 +323,9 @@ export const en = { setupSecretKey: 'Secret Key (if you can\'t scan QR code):', enableAuthenticatorApp: 'Enable Authenticator App', disableAuthenticatorApp: 'Disable Authenticator App', + totpEnabled: 'Authenticator App enabled successfully', + totpDisabled: 'Authenticator App disabled successfully', + totpError: 'Failed to enable Authenticator App', scanQr: 'Scan QR Code', scanQrDesc: 'Scan this QR code with your authenticator app', manualEntry: 'Or enter this code manually:', @@ -302,8 +338,11 @@ export const en = { deletePasswordRequired: 'You must set a password first before you can delete your account. Go to "Set Password" above.', deleteAccountConfirm: 'Are you sure you want to delete your account? All data will be permanently lost.', enterPasswordToDelete: 'Enter your password to confirm', + enterPasswordToDeletePlaceholder: 'Enter your password', deleting: 'Deleting...', yesDeleteMyAccount: 'Yes, Delete My Account', + deleteSuccess: 'Account deleted successfully', + deleteError: 'Failed to delete account', }, dateRange: { @@ -313,7 +352,30 @@ export const en = { lastMonth: 'Last month', thisYear: 'This year', custom: 'Custom', - from: 'From', - to: 'To', + from: 'Start Date', + to: 'End Date', + }, + + fab: { + updateAssetPrices: 'Update Asset Prices', + quickTransaction: 'Quick Transaction', + quickWallet: 'Quick Wallet', + }, + + assetPriceUpdate: { + title: 'Update Asset Prices', + description: 'Update the price per unit for your asset wallets', + noAssets: 'You don\'t have any asset wallets yet. Create an asset wallet first.', + noChanges: 'No price changes detected', + pricePerUnit: 'Price per {unit}', + currentPrice: 'Current price', + lastUpdated: 'Last updated', + justNow: 'Just now', + minutesAgo: '{minutes} minutes ago', + hoursAgo: '{hours} hours ago', + daysAgo: '{days} days ago', + updateAll: 'Update All', + updateSuccess: '{count} asset price(s) updated successfully', + updateError: 'Failed to update asset prices', }, } diff --git a/apps/web/src/locales/id.ts b/apps/web/src/locales/id.ts index 9768597..401146c 100644 --- a/apps/web/src/locales/id.ts +++ b/apps/web/src/locales/id.ts @@ -28,6 +28,12 @@ export const id = { showFilters: 'Tampilkan Filter', hideFilters: 'Sembunyikan Filter', }, + + numberFormat: { + thousand: 'rb', + million: 'jt', + billion: 'm', + }, nav: { overview: 'Ringkasan', @@ -42,8 +48,9 @@ export const id = { description: 'Ringkasan keuangan dan tindakan cepat', overviewPeriod: 'Periode Ringkasan', overviewPeriodPlaceholder: 'Pilih Periode', - customStartDatePlaceholder: 'Pilih Tanggal Mulai', - customEndDatePlaceholder: 'Pilih Tanggal Selesai', + customStartDatePlaceholder: 'Pilih tanggal mulai', + customEndDatePlaceholder: 'Pilih tanggal akhir', + selectDateRange: 'Pilih rentang tanggal', totalBalance: 'Total Saldo', totalIncome: 'Total Pemasukan', totalExpense: 'Total Pengeluaran', @@ -200,9 +207,11 @@ export const id = { expense: 'Pengeluaran', category: 'Kategori', categoryPlaceholder: 'Pilih atau ketik kategori baru', + selectCategory: 'Pilih atau ketik kategori baru', addCategory: 'Tambah', memo: 'Catatan (Opsional)', memoPlaceholder: 'Tambahkan catatan...', + addMemo: 'Tambahkan catatan...', date: 'Tanggal', selectDate: 'Pilih tanggal', addSuccess: 'Transaksi berhasil ditambahkan', @@ -227,16 +236,25 @@ export const id = { save: 'Simpan', update: 'Update', cancel: 'Batal', + nameSaved: 'Nama berhasil disimpan', + nameError: 'Nama tidak boleh kosong', + nameSuccess: 'Nama berhasil diupdate', + nameLoading: 'Mengupdate nama...', + nameLoadingError: 'Gagal mengupdate nama', + email: 'Email', emailVerified: 'Email Terverifikasi', emailNotVerified: 'Email Belum Terverifikasi', emailCannotBeChanged: 'Email tidak dapat diubah', + avatar: 'Avatar', changeAvatar: 'Ubah Avatar', uploadAvatar: 'Unggah Avatar', avatarSynced: 'Avatar disinkronkan dari akun Google Anda', clickUploadAvatar: 'Klik tombol unggah untuk mengubah avatar Anda', uploading: 'Mengunggah...', + avatarSuccess: 'Avatar berhasil diupdate', + avatarError: 'Gagal mengupdate avatar', security: 'Keamanan', password: 'Password', @@ -252,6 +270,10 @@ export const id = { updating: 'Updating...', setPassword: 'Buat Password', updatePassword: 'Ubah Password', + passwordSetSuccess: 'Password berhasil diatur', + passwordChangeSuccess: 'Password berhasil diubah', + passwordError: 'Gagal mengatur password', + enterPassword: 'Silakan masukkan password', twoFactor: 'Autentikasi Dua Faktor', twoFactorDesc: 'Tambahkan lapisan keamanan ekstra ke akun Anda', @@ -259,12 +281,20 @@ export const id = { phoneNumberPlaceholder: '+62812345678', updatePhone: 'Update Nomor', phoneNumberDescription: 'Diperlukan untuk verifikasi WhatsApp OTP', + phoneInvalid: 'Nomor telepon tidak valid', + phoneNotRegistered: 'Nomor ini tidak terdaftar di WhatsApp. Silakan coba nomor lain.', + phoneSuccess: 'Nomor telepon berhasil diupdate', + phoneError: 'Gagal mengupdate nomor telepon', emailOtp: 'Email OTP', emailOtpDesc: 'Terima kode verifikasi via email', enableEmailOtp: 'Aktifkan Email OTP', disableEmailOtp: 'Nonaktifkan Email OTP', checkYourEmailForTheVerificationCode: 'Cek email Anda untuk kode verifikasi', + emailOtpSent: 'Kode OTP telah dikirim ke email', + emailOtpEnabled: 'Email OTP berhasil diaktifkan', + emailOtpDisabled: 'Email OTP berhasil dinonaktifkan', + emailOtpError: 'Gagal mengirim kode OTP', enable: 'Aktifkan', disable: 'Nonaktifkan', enabled: 'Aktif', @@ -280,6 +310,10 @@ export const id = { pleaseAddYourPhoneNumberInTheEditProfileTabFirst: 'Tambahkan nomor telepon Anda di tab Edit Profil terlebih dahulu', checkYourWhatsAppForTheVerificationCodeOrCheckConsoleInTestMode: 'Cek WhatsApp Anda untuk kode verifikasi', enterVerificationCode: 'Masukkan 6 digit kode', + whatsappOtpSent: 'Kode OTP telah dikirim ke WhatsApp', + whatsappOtpEnabled: 'WhatsApp OTP berhasil diaktifkan', + whatsappOtpDisabled: 'WhatsApp OTP berhasil dinonaktifkan', + whatsappOtpError: 'Gagal mengirim kode OTP', authenticatorApp: 'Authenticator App', authenticatorDesc: 'Gunakan aplikasi authenticator seperti Google Authenticator', @@ -290,6 +324,9 @@ export const id = { setupSecretKey: 'Secret Key (jika tidak bisa scan QR code):', enableAuthenticatorApp: 'Aktifkan Authenticator App', disableAuthenticatorApp: 'Nonaktifkan Authenticator App', + totpEnabled: 'Authenticator App berhasil diaktifkan', + totpDisabled: 'Authenticator App berhasil dinonaktifkan', + totpError: 'Gagal mengaktifkan Authenticator App', scanQr: 'Scan QR Code', scanQrDesc: 'Scan QR code ini dengan aplikasi authenticator Anda', manualEntry: 'Atau masukkan kode ini secara manual:', @@ -302,8 +339,11 @@ export const id = { deletePasswordRequired: 'Anda harus membuat password terlebih dahulu sebelum Anda dapat menghapus akun Anda. Buka "Buat Password" di atas.', deleteAccountConfirm: 'Apakah Anda yakin ingin menghapus akun Anda? Semua data akan hilang permanen.', enterPasswordToDelete: 'Masukkan password Anda untuk konfirmasi', + enterPasswordToDeletePlaceholder: 'Masukkan password Anda', deleting: 'Menghapus...', yesDeleteMyAccount: 'Ya, Hapus Akun Saya', + deleteSuccess: 'Akun berhasil dihapus', + deleteError: 'Gagal menghapus akun', }, dateRange: { @@ -313,7 +353,30 @@ export const id = { lastMonth: 'Bulan lalu', thisYear: 'Tahun ini', custom: 'Kustom', - from: 'Dari', - to: 'Sampai', + from: 'Tanggal Mulai', + to: 'Tanggal Akhir', + }, + + fab: { + updateAssetPrices: 'Perbarui Harga Aset', + quickTransaction: 'Transaksi Cepat', + quickWallet: 'Dompet Cepat', + }, + + assetPriceUpdate: { + title: 'Perbarui Harga Aset', + description: 'Perbarui harga per unit untuk dompet aset Anda', + noAssets: 'Anda belum memiliki dompet aset. Buat dompet aset terlebih dahulu.', + noChanges: 'Tidak ada perubahan harga yang terdeteksi', + pricePerUnit: 'Harga per {unit}', + currentPrice: 'Harga saat ini', + lastUpdated: 'Terakhir diperbarui', + justNow: 'Baru saja', + minutesAgo: '{minutes} menit yang lalu', + hoursAgo: '{hours} jam yang lalu', + daysAgo: '{days} hari yang lalu', + updateAll: 'Perbarui Semua', + updateSuccess: '{count} harga aset berhasil diperbarui', + updateError: 'Gagal memperbarui harga aset', }, }