feat: Add WhatsApp verification & responsive dialogs

 COMPLETED FEATURES:

1. WhatsApp Number Verification
   - Verify phone number is registered on WhatsApp before saving
   - Use OTP webhook with check_number mode
   - Show error if number not registered
   - Translated error messages

2. Responsive Dialog/Drawer System
   - Created ResponsiveDialog component
   - Desktop: Uses Dialog (modal)
   - Mobile: Uses Drawer (bottom sheet)
   - Applied to WalletDialog & TransactionDialog
   - Better UX on mobile devices

3. Translation Fixes
   - Fixed editProfile key placement
   - All translation keys now consistent
   - Build passing without errors

📱 MOBILE IMPROVEMENTS:
- Form dialogs now slide up from bottom on mobile
- Better touch interaction
- More native mobile feel

🔧 TECHNICAL:
- Added shadcn Drawer component
- Created useMediaQuery hook
- Responsive context wrapper
- Type-safe implementation
This commit is contained in:
dwindown
2025-10-12 17:07:16 +07:00
parent d626c7d8de
commit 46488a09e2
15 changed files with 914 additions and 239 deletions

View File

@@ -39,6 +39,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "^4.1.12"
},
"devDependencies": {
@@ -5988,6 +5989,19 @@
}
}
},
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",

View File

@@ -41,6 +41,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "^4.1.12"
},
"devDependencies": {

View File

@@ -3,13 +3,13 @@ import { toast } from "sonner"
import { useLanguage } from "@/contexts/LanguageContext"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
@@ -171,17 +171,17 @@ export function TransactionDialog({ open, onOpenChange, transaction, walletId: i
if (isEditing) {
await axios.put(`${API}/wallets/${walletId}/transactions/${transaction.id}`, data)
toast.success('Transaksi berhasil diupdate')
toast.success(t.transactionDialog.editSuccess)
} else {
await axios.post(`${API}/wallets/${walletId}/transactions`, data)
toast.success('Transaksi berhasil ditambahkan')
toast.success(t.transactionDialog.addSuccess)
}
onSuccess()
onOpenChange(false)
} catch (error) {
console.error("Failed to save transaction:", error)
toast.error('Gagal menyimpan transaksi')
toast.error(t.transactionDialog.saveError)
} finally {
setLoading(false)
}
@@ -222,14 +222,14 @@ export function TransactionDialog({ open, onOpenChange, transaction, walletId: i
}, [open, transaction])
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{isEditing ? t.transactionDialog.editTitle : t.transactionDialog.addTitle}</DialogTitle>
<DialogDescription>
{isEditing ? t.transactionDialog.editTitle : t.transactionDialog.addTitle}
</DialogDescription>
</DialogHeader>
<ResponsiveDialog open={open} onOpenChange={handleOpenChange}>
<ResponsiveDialogContent className="sm:max-w-[425px]">
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>{isEditing ? t.transactionDialog.editTitle : t.transactionDialog.addTitle}</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
{t.transactionDialog.description}
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
@@ -311,16 +311,16 @@ export function TransactionDialog({ open, onOpenChange, transaction, walletId: i
</div>
)}
</div>
<DialogFooter>
<ResponsiveDialogFooter>
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
{t.common.cancel}
</Button>
<Button type="submit" disabled={loading}>
{loading ? t.common.loading : isEditing ? t.common.save : t.common.add}
</Button>
</DialogFooter>
</ResponsiveDialogFooter>
</form>
</DialogContent>
</Dialog>
</ResponsiveDialogContent>
</ResponsiveDialog>
)
}

View File

@@ -3,13 +3,13 @@ import { toast } from "sonner"
import { useLanguage } from "@/contexts/LanguageContext"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
@@ -93,17 +93,17 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
if (isEditing) {
await axios.put(`${API}/wallets/${wallet.id}`, data)
toast.success('Wallet berhasil diupdate')
toast.success(t.walletDialog.editSuccess)
} else {
await axios.post(`${API}/wallets`, data)
toast.success('Wallet berhasil ditambahkan')
toast.success(t.walletDialog.addSuccess)
}
onSuccess()
onOpenChange(false)
} catch (error) {
console.error("Failed to save wallet:", error)
toast.error('Gagal menyimpan wallet')
toast.error(t.walletDialog.saveError)
} finally {
setLoading(false)
}
@@ -140,14 +140,14 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
}, [open, wallet])
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{isEditing ? t.walletDialog.editTitle : t.walletDialog.addTitle}</DialogTitle>
<DialogDescription>
{isEditing ? t.walletDialog.editTitle : t.walletDialog.addTitle}
</DialogDescription>
</DialogHeader>
<ResponsiveDialog open={open} onOpenChange={handleOpenChange}>
<ResponsiveDialogContent className="sm:max-w-[425px]">
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>{isEditing ? t.walletDialog.editTitle : t.walletDialog.addTitle}</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
{t.walletDialog.description}
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
@@ -264,16 +264,16 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
</div>
)}
</div>
<DialogFooter>
<ResponsiveDialogFooter>
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
{t.common.cancel}
</Button>
<Button type="submit" disabled={loading}>
{loading ? t.common.loading : isEditing ? t.common.save : t.common.add}
</Button>
</DialogFooter>
</ResponsiveDialogFooter>
</form>
</DialogContent>
</Dialog>
</ResponsiveDialogContent>
</ResponsiveDialog>
)
}

View File

@@ -100,18 +100,19 @@ function getFilteredTransactions(transactions: Transaction[], dateRange: DateRan
// Helper function to get date range label
function getDateRangeLabel(dateRange: DateRange, customStartDate?: Date, customEndDate?: Date): string {
const { t } = useLanguage()
switch (dateRange) {
case 'this_month': return 'This Month'
case 'last_month': return 'Last Month'
case 'this_year': return 'This Year'
case 'last_year': return 'Last Year'
case 'all_time': return 'All Time'
case 'this_month': return t.overview.thisMonth
case 'last_month': return t.overview.lastMonth
case 'this_year': return t.overview.thisYear
case 'last_year': return t.overview.lastYear
case 'all_time': return t.overview.allTime
case 'custom':
if (customStartDate && customEndDate) {
return `${customStartDate.toLocaleDateString()} - ${customEndDate.toLocaleDateString()}`
}
return 'Custom Range'
default: return 'All Time'
return t.overview.custom
default: return t.overview.allTime
}
}
@@ -520,9 +521,9 @@ export function Overview() {
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Overview</h1>
<h1 className="text-3xl font-bold tracking-tight">{t.overview.title}</h1>
<p className="text-muted-foreground">
Your financial dashboard and quick actions
{t.overview.description}
</p>
</div>
</div>
@@ -533,19 +534,19 @@ export function Overview() {
<div className="flex flex-col sm:flex-row gap-3">
<label className="text-xs font-medium text-muted-foreground flex flex-row flex-nowrap items-center gap-1">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Overview Period</span>
<span className="text-sm font-medium">{t.overview.overviewPeriod}</span>
</label>
<Select value={dateRange} onValueChange={(value: DateRange) => setDateRange(value)}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Select period" />
<SelectValue placeholder={t.overview.overviewPeriodPlaceholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value="this_month">This Month</SelectItem>
<SelectItem value="last_month">Last Month</SelectItem>
<SelectItem value="this_year">This Year</SelectItem>
<SelectItem value="last_year">Last Year</SelectItem>
<SelectItem value="all_time">All Time</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
<SelectItem value="this_month">{t.overview.thisMonth}</SelectItem>
<SelectItem value="last_month">{t.overview.lastMonth}</SelectItem>
<SelectItem value="this_year">{t.overview.thisYear}</SelectItem>
<SelectItem value="last_year">{t.overview.lastYear}</SelectItem>
<SelectItem value="all_time">{t.overview.allTime}</SelectItem>
<SelectItem value="custom">{t.overview.custom}</SelectItem>
</SelectContent>
</Select>
@@ -555,13 +556,13 @@ export function Overview() {
<DatePicker
date={customStartDate}
onDateChange={setCustomStartDate}
placeholder="Pick start date"
placeholder={t.overview.customStartDatePlaceholder}
className="w-50 sm:w-[200px]"
/>
<DatePicker
date={customEndDate}
onDateChange={setCustomEndDate}
placeholder="Pick end date"
placeholder={t.overview.customEndDatePlaceholder}
className="w-50 sm:w-[200px]"
/>
</div>
@@ -577,7 +578,7 @@ export function Overview() {
</Button>
<Button variant="outline" onClick={() => setTransactionDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t.overview.addFirstTransaction}
{t.overview.addTransaction}
</Button>
</div>
@@ -611,7 +612,7 @@ export function Overview() {
{formatLargeNumber(totals.totalIncome, 'IDR')}
</div>
<p className="text-xs text-muted-foreground">
{getDateRangeLabel(dateRange, customStartDate, customEndDate)} {t.overview.income}
{localStorage.getItem('language') === 'id' ? t.overview.income + ' ' + getDateRangeLabel(dateRange, customStartDate, customEndDate) : t.overview.income + ' ' + getDateRangeLabel(dateRange, customStartDate, customEndDate)}
</p>
</CardContent>
</Card>
@@ -626,7 +627,7 @@ export function Overview() {
{formatLargeNumber(totals.totalExpense, 'IDR')}
</div>
<p className="text-xs text-muted-foreground">
{getDateRangeLabel(dateRange, customStartDate, customEndDate)} {t.overview.expense}
{localStorage.getItem('language') === 'id' ? t.overview.expense + ' ' + getDateRangeLabel(dateRange, customStartDate, customEndDate) : t.overview.expense + ' ' + getDateRangeLabel(dateRange, customStartDate, customEndDate)}
</p>
</CardContent>
</Card>
@@ -638,18 +639,18 @@ export function Overview() {
<Card>
<CardHeader>
<CardTitle>{t.overview.wallets}</CardTitle>
<CardDescription>Balance distribution across wallets</CardDescription>
<CardDescription>{t.overview.walletsDescription}</CardDescription>
</CardHeader>
<CardContent className="px-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="text-center">Currency/Unit</TableHead>
<TableHead className="text-center">Transactions</TableHead>
<TableHead className="text-right">Total Balance</TableHead>
<TableHead className="text-right">Domination</TableHead>
<TableHead>{t.overview.walletTheadName}</TableHead>
<TableHead className="text-center">{t.overview.walletTheadCurrencyUnit}</TableHead>
<TableHead className="text-center">{t.overview.walletTheadTransactions}</TableHead>
<TableHead className="text-right">{t.overview.walletTheadTotalBalance}</TableHead>
<TableHead className="text-right">{t.overview.walletTheadDomination}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -720,16 +721,16 @@ export function Overview() {
{/* Income by Category */}
<Card className="mb-5 md:mb-0">
<CardHeader>
<CardTitle>Income by Category</CardTitle>
<CardTitle>{t.overview.incomeByCategory}</CardTitle>
<CardDescription>
<div className="flex flex-col gap-2">
<span>Category breakdown for {getDateRangeLabel(dateRange, customStartDate, customEndDate).toLowerCase()}</span>
<span>{t.overview.incomeCategoryFor} {getDateRangeLabel(dateRange, customStartDate, customEndDate).toLowerCase()}</span>
<Select value={incomeChartWallet} onValueChange={setIncomeChartWallet}>
<SelectTrigger className="w-full max-w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Wallets</SelectItem>
<SelectItem value="all">{t.overview.categoryAllWalletOption}</SelectItem>
{wallets.map(wallet => (
<SelectItem key={wallet.id} value={wallet.id}>
{wallet.name}
@@ -851,16 +852,16 @@ export function Overview() {
{/* Expense by Category */}
<Card>
<CardHeader>
<CardTitle>Expense by Category</CardTitle>
<CardTitle>{t.overview.expenseByCategory}</CardTitle>
<CardDescription>
<div className="flex flex-col gap-2">
<span>Category breakdown for {getDateRangeLabel(dateRange, customStartDate, customEndDate).toLowerCase()}</span>
<span>{t.overview.expenseCategoryFor} {getDateRangeLabel(dateRange, customStartDate, customEndDate).toLowerCase()}</span>
<Select value={expenseChartWallet} onValueChange={setExpenseChartWallet}>
<SelectTrigger className="w-full max-w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Wallets</SelectItem>
<SelectItem value="all">{t.overview.categoryAllWalletOption}</SelectItem>
{wallets.map(wallet => (
<SelectItem key={wallet.id} value={wallet.id}>
{wallet.name}
@@ -984,19 +985,19 @@ export function Overview() {
<div className="gap-4">
<Card>
<CardHeader>
<CardTitle>Financial Trend</CardTitle>
<CardTitle>{t.overview.financialTrend}</CardTitle>
<CardDescription>
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<span>Income vs Expense over time</span>
<span>{t.overview.financialTrendDescription}</span>
<Select value={trendPeriod} onValueChange={(value: TrendPeriod) => setTrendPeriod(value)}>
<SelectTrigger className="w-full max-w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
<SelectItem value="daily">{t.overview.financialTrendOverTimeDaily}</SelectItem>
<SelectItem value="weekly">{t.overview.financialTrendOverTimeWeekly}</SelectItem>
<SelectItem value="monthly">{t.overview.financialTrendOverTimeMonthly}</SelectItem>
<SelectItem value="yearly">{t.overview.financialTrendOverTimeYearly}</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from "react"
import { useLanguage } from "@/contexts/LanguageContext"
import axios from "axios"
import { toast } from "sonner"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@@ -39,6 +40,7 @@ interface OtpStatus {
}
export function Profile() {
const { t } = useLanguage()
const { user } = useAuth()
const [loading, setLoading] = useState(true)
const [otpStatus, setOtpStatus] = useState<OtpStatus>({
@@ -258,21 +260,26 @@ export function Profile() {
setPhoneSuccess("")
if (!phone || phone.length < 10) {
setPhoneError("Please enter a valid phone number")
setPhoneError(t.profile.phoneNumber + " tidak valid")
return
}
// Check if number is valid on WhatsApp
const checkResponse = await axios.post(`${API}/otp/whatsapp/check`, { phone })
if (!checkResponse.data.isRegistered) {
setPhoneError("This number is not registered on WhatsApp")
// Check if number is registered on WhatsApp using webhook
const checkResponse = await axios.post(`${API}/otp/send`, {
method: 'whatsapp',
mode: 'check_number',
to: phone
})
if (checkResponse.data.code === 'SUCCESS' && checkResponse.data.results?.is_on_whatsapp === false) {
setPhoneError("Nomor ini tidak terdaftar di WhatsApp. Silakan coba nomor lain.")
return
}
// Update phone
await axios.put(`${API}/users/profile`, { phone })
toast.success('Nomor telepon berhasil diupdate')
setPhoneSuccess("Phone number updated successfully!")
toast.success(t.profile.phoneNumber + ' berhasil diupdate')
setPhoneSuccess(t.profile.phoneNumber + " updated successfully!")
// Reload OTP status
await loadOtpStatus()
@@ -503,9 +510,9 @@ export function Profile() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Profile</h1>
<h1 className="text-3xl font-bold">{t.profile.title}</h1>
<p className="text-muted-foreground">
Manage your account settings and security preferences
{t.profile.description}
</p>
</div>
@@ -513,11 +520,11 @@ export function Profile() {
<TabsList className="grid w-full grid-cols-2 max-w-md">
<TabsTrigger value="profile" className="flex items-center gap-2">
<UserCircle className="h-4 w-4" />
Edit Profile
{t.profile.editProfile}
</TabsTrigger>
<TabsTrigger value="security" className="flex items-center gap-2">
<Lock className="h-4 w-4" />
Security
{t.profile.security}
</TabsTrigger>
</TabsList>
@@ -525,8 +532,8 @@ export function Profile() {
<TabsContent value="profile" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
<CardDescription>Update your personal information</CardDescription>
<CardTitle>{t.profile.personalInfo}</CardTitle>
<CardDescription>{t.profile.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Avatar Section */}
@@ -565,15 +572,15 @@ export function Profile() {
)}
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">{user?.name || "User"}</h3>
<h3 className="text-lg font-semibold">{user?.name || t.profile.name}</h3>
<p className="text-sm text-muted-foreground mb-2">{user?.email}</p>
{hasGoogleAuth ? (
<p className="text-xs text-muted-foreground">
Avatar is synced from your Google account
{t.profile.avatarSynced}
</p>
) : (
<p className="text-xs text-muted-foreground">
Click the upload button to change your avatar
{t.profile.clickUploadAvatar}
</p>
)}
{avatarError && (
@@ -586,7 +593,7 @@ export function Profile() {
{/* Name Field */}
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Label htmlFor="name">{t.profile.name}</Label>
{hasGoogleAuth ? (
<>
<Input
@@ -597,7 +604,7 @@ export function Profile() {
className="bg-muted"
/>
<p className="text-xs text-muted-foreground">
Name is synced from your Google account
{t.profile.nameSynced}
</p>
</>
) : (
@@ -618,7 +625,7 @@ export function Profile() {
disabled={nameLoading}
size="sm"
>
{nameLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : "Save"}
{nameLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : t.profile.save}
</Button>
<Button
variant="outline"
@@ -630,7 +637,7 @@ export function Profile() {
disabled={nameLoading}
size="sm"
>
Cancel
{t.profile.cancel}
</Button>
</>
) : (
@@ -639,7 +646,7 @@ export function Profile() {
onClick={() => setIsEditingName(true)}
size="sm"
>
Edit
{t.profile.edit}
</Button>
)}
</div>
@@ -655,7 +662,7 @@ export function Profile() {
{/* Email Field */}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor="email">{t.profile.email}</Label>
<Input
id="email"
type="email"
@@ -664,13 +671,13 @@ export function Profile() {
className="bg-muted"
/>
<p className="text-xs text-muted-foreground">
Email cannot be changed
{t.profile.emailCannotBeChanged}
</p>
</div>
{/* Phone Field */}
<div className="space-y-2">
<Label htmlFor="phone">Phone Number</Label>
<Label htmlFor="phone">{t.profile.phoneNumber}</Label>
<div className="flex gap-2">
<Input
id="phone"
@@ -684,7 +691,7 @@ export function Profile() {
onClick={handleUpdatePhone}
disabled={phoneLoading || !phone}
>
{phoneLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : "Update"}
{phoneLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : t.profile.update}
</Button>
</div>
{phoneError && (
@@ -700,7 +707,7 @@ export function Profile() {
</Alert>
)}
<p className="text-xs text-muted-foreground">
Required for WhatsApp OTP verification
{t.profile.phoneNumberDescription}
</p>
</div>
</CardContent>
@@ -715,12 +722,12 @@ export function Profile() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
{!hasPassword ? "Set Password" : "Change Password"}
{!hasPassword ? t.profile.setPassword : t.profile.changePassword}
</CardTitle>
<CardDescription>
{!hasPassword
? "Set a password to enable password-based login and account deletion"
: "Update your password to keep your account secure"
? t.profile.setPasswordDesc
: t.profile.changePasswordDesc
}
</CardDescription>
</CardHeader>
@@ -729,7 +736,7 @@ export function Profile() {
<Alert className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Your account uses Google Sign-In. Setting a password will allow you to login with email/password and delete your account if needed.
{t.profile.googleAuthDesc}
</AlertDescription>
</Alert>
) : null}
@@ -748,11 +755,11 @@ export function Profile() {
)}
{hasPassword && (
<div>
<Label htmlFor="current-password">Current Password</Label>
<Label htmlFor="current-password">{t.profile.currentPassword}</Label>
<Input
id="current-password"
type="password"
placeholder="Enter current password"
placeholder="******"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
disabled={passwordLoading}
@@ -760,22 +767,22 @@ export function Profile() {
</div>
)}
<div>
<Label htmlFor="new-password">New Password</Label>
<Label htmlFor="new-password">{t.profile.newPassword}</Label>
<Input
id="new-password"
type="password"
placeholder="Enter new password"
placeholder="******"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={passwordLoading}
/>
</div>
<div>
<Label htmlFor="confirm-password">Confirm New Password</Label>
<Label htmlFor="confirm-password">{t.profile.confirmPassword}</Label>
<Input
id="confirm-password"
type="password"
placeholder="Confirm new password"
placeholder="******"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={passwordLoading}
@@ -789,10 +796,10 @@ export function Profile() {
{passwordLoading ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
{!hasPassword ? 'Setting...' : 'Updating...'}
{!hasPassword ? t.profile.setting : t.profile.updating}
</>
) : (
!hasPassword ? 'Set Password' : 'Update Password'
!hasPassword ? t.profile.setPassword : t.profile.updatePassword
)}
</Button>
</div>
@@ -803,10 +810,10 @@ export function Profile() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Two-Factor Authentication
{t.profile.twoFactor}
</CardTitle>
<CardDescription>
Add an extra layer of security to your account with OTP verification
{t.profile.twoFactorDesc}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@@ -817,14 +824,14 @@ export function Profile() {
<div className="flex items-center gap-3">
<Smartphone className="h-5 w-5" />
<div>
<h4 className="font-medium">WhatsApp OTP</h4>
<h4 className="font-medium">{t.profile.whatsappOtp}</h4>
<p className="text-sm text-muted-foreground">
Receive verification codes via WhatsApp
{t.profile.whatsappOtpDesc}
</p>
</div>
</div>
<Badge variant={otpStatus.whatsappEnabled ? "default" : "secondary"}>
{otpStatus.whatsappEnabled ? "Enabled" : "Disabled"}
{otpStatus.whatsappEnabled ? t.profile.enabled : t.profile.disabled}
</Badge>
</div>
@@ -832,14 +839,14 @@ export function Profile() {
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Please add your phone number in the Edit Profile tab first
{t.profile.pleaseAddYourPhoneNumberInTheEditProfileTabFirst}
</AlertDescription>
</Alert>
)}
{otpStatus.phone && (
<div className="text-sm text-muted-foreground">
Phone: {otpStatus.phone}
{t.profile.phoneNumber}: {otpStatus.phone}
</div>
)}
@@ -856,16 +863,16 @@ export function Profile() {
) : (
<Smartphone className="h-4 w-4 mr-2" />
)}
Enable WhatsApp OTP
{t.profile.enableWhatsAppOtp}
</Button>
) : (
<div className="space-y-3">
<Alert>
<AlertDescription>
Check your WhatsApp for the verification code (or check console in test mode)
{t.profile.checkYourWhatsAppForTheVerificationCodeOrCheckConsoleInTestMode}
</AlertDescription>
</Alert>
<Label htmlFor="whatsapp-otp">Enter verification code</Label>
<Label htmlFor="whatsapp-otp">{t.profile.enterVerificationCode}</Label>
<div className="flex gap-2">
<Input
id="whatsapp-otp"
@@ -903,7 +910,7 @@ export function Profile() {
) : (
<AlertTriangle className="h-4 w-4 mr-2" />
)}
Disable WhatsApp OTP
{t.profile.disableWhatsAppOtp}
</Button>
)}
</div>
@@ -916,14 +923,14 @@ export function Profile() {
<div className="flex items-center gap-3">
<Mail className="h-5 w-5" />
<div>
<h4 className="font-medium">Email Verification</h4>
<h4 className="font-medium">{t.profile.emailOtp}</h4>
<p className="text-sm text-muted-foreground">
Receive OTP codes via email
{t.profile.emailOtpDesc}
</p>
</div>
</div>
<Badge variant={otpStatus.emailEnabled ? "default" : "secondary"}>
{otpStatus.emailEnabled ? "Enabled" : "Disabled"}
{otpStatus.emailEnabled ? t.profile.enabled : t.profile.disabled}
</Badge>
</div>
@@ -940,16 +947,16 @@ export function Profile() {
) : (
<Mail className="h-4 w-4 mr-2" />
)}
Enable Email OTP
{t.profile.enableEmailOtp}
</Button>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Check your email for the verification code
{t.profile.checkYourEmailForTheVerificationCode}
</p>
<div className="flex gap-2">
<Input
placeholder="Enter 6-digit code"
placeholder={t.profile.enterVerificationCode}
value={emailOtpCode}
onChange={(e) => setEmailOtpCode(e.target.value)}
maxLength={6}
@@ -979,7 +986,7 @@ export function Profile() {
) : (
<AlertTriangle className="h-4 w-4 mr-2" />
)}
Disable Email OTP
{t.profile.disableEmailOtp}
</Button>
)}
</div>
@@ -994,12 +1001,12 @@ export function Profile() {
<div>
<h4 className="font-medium">Authenticator App</h4>
<p className="text-sm text-muted-foreground">
Use Google Authenticator or similar apps
{t.profile.authenticatorDesc}
</p>
</div>
</div>
<Badge variant={otpStatus.totpEnabled ? "default" : "secondary"}>
{otpStatus.totpEnabled ? "Enabled" : "Disabled"}
{otpStatus.totpEnabled ? t.profile.enabled : t.profile.disabled}
</Badge>
</div>
@@ -1016,16 +1023,16 @@ export function Profile() {
) : (
<Key className="h-4 w-4 mr-2" />
)}
Setup Authenticator App
{t.profile.enableAuthenticatorApp}
</Button>
) : (
<div className="space-y-4">
<div className="p-4 border rounded-lg space-y-3">
<h5 className="font-medium">Setup Instructions:</h5>
<h5 className="font-medium">{t.profile.authenticatorSetupInstruction}</h5>
<ol className="text-sm space-y-1 list-decimal list-inside text-muted-foreground">
<li>Open your authenticator app (Google Authenticator, Authy, etc.)</li>
<li>Scan the QR code or manually enter the secret key</li>
<li>Enter the 6-digit code from your app below</li>
<li>{t.profile.autentucatorSetupInstruction_1}</li>
<li>{t.profile.autentucatorSetupInstruction_2}</li>
<li>{t.profile.autentucatorSetupInstruction_3}</li>
</ol>
{otpStatus.totpQrCode && (
@@ -1043,7 +1050,7 @@ export function Profile() {
{otpStatus.totpSecret && (
<div className="space-y-2">
<Label>Secret Key (if you can't scan QR code):</Label>
<Label>{t.profile.setupSecretKey}</Label>
<div className="flex gap-2">
<Input
value={otpStatus.totpSecret}
@@ -1098,7 +1105,7 @@ export function Profile() {
) : (
<AlertTriangle className="h-4 w-4 mr-2" />
)}
Disable Authenticator App
{t.profile.disableAuthenticatorApp}
</Button>
)}
</div>
@@ -1110,24 +1117,24 @@ export function Profile() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
Danger Zone
{t.profile.dangerZone}
</CardTitle>
<CardDescription>
Irreversible actions that will permanently affect your account
{t.profile.dangerZoneDesc}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="p-4 border border-destructive/50 rounded-lg bg-destructive/5">
<h4 className="font-semibold text-destructive mb-2">Delete Account</h4>
<h4 className="font-semibold text-destructive mb-2">{t.profile.deleteAccount}</h4>
<p className="text-sm text-muted-foreground mb-4">
Once you delete your account, there is no going back. This will permanently delete your account, all your data, transactions, and settings.
{t.profile.deleteAccountDesc}
</p>
{!hasPassword ? (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
You must set a password first before you can delete your account. Go to "Set Password" above.
{t.profile.deletePasswordRequired}
</AlertDescription>
</Alert>
) : showDeleteDialog ? (
@@ -1139,7 +1146,7 @@ export function Profile() {
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="delete-password">Enter your password to confirm</Label>
<Label htmlFor="delete-password">{t.profile.enterPasswordToDelete}</Label>
<Input
id="delete-password"
type="password"
@@ -1158,12 +1165,12 @@ export function Profile() {
{deleteLoading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
Deleting...
{t.profile.deleting}
</>
) : (
<>
<Trash2 className="h-4 w-4 mr-2" />
Yes, Delete My Account
{t.profile.yesDeleteMyAccount}
</>
)}
</Button>
@@ -1186,7 +1193,7 @@ export function Profile() {
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Account
{t.profile.deleteAccount}
</Button>
)}
</div>

View File

@@ -148,11 +148,11 @@ export function Transactions() {
try {
await axios.delete(`${API}/wallets/${walletId}/transactions/${transactionId}`)
toast.success('Transaksi berhasil dihapus')
toast.success(t.transactionDialog.deleteSuccess)
await loadData()
} catch (error) {
console.error('Failed to delete transaction:', error)
toast.error('Gagal menghapus transaksi')
toast.error(t.transactionDialog.deleteError)
}
}
@@ -244,8 +244,8 @@ export function Transactions() {
return (
<div className="space-y-6">
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse" />
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<div className="grid gap-4 lg:grid-cols-3">
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardHeader>
<div className="h-4 w-20 bg-gray-200 rounded animate-pulse" />
@@ -268,13 +268,13 @@ export function Transactions() {
<div>
<h1 className="text-3xl font-bold tracking-tight">{t.transactions.title}</h1>
<p className="text-muted-foreground">
View and manage all your transactions
{t.transactions.description}
</p>
</div>
<div className="flex gap-2 sm:flex-shrink-0">
<Button variant="outline" onClick={() => setShowFilters(!showFilters)}>
<Filter className="mr-2 h-4 w-4" />
{showFilters ? 'Hide Filters' : 'Show Filters'}
{showFilters ? t.common.hideFilters : t.common.showFilters}
</Button>
<Button onClick={() => setTransactionDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
@@ -336,7 +336,7 @@ export function Transactions() {
className="h-8 text-xs"
>
<X className="h-3 w-3 mr-1" />
Clear All
{t.common.clearAll}
</Button>
</div>
</CardHeader>
@@ -345,11 +345,11 @@ export function Transactions() {
<div className="grid gap-3 md:grid-cols-3">
{/* Search */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Search Memo</Label>
<Label className="text-xs font-medium text-muted-foreground">{t.transactions.filter.searchMemo}</Label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search in memo..."
placeholder={t.transactions.filter.searchMemoPlaceholder}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9"
@@ -359,13 +359,13 @@ export function Transactions() {
{/* Wallet Filter */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Wallet</Label>
<Label className="text-xs font-medium text-muted-foreground">{t.transactions.filter.wallet}</Label>
<Select value={walletFilter} onValueChange={setWalletFilter}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All wallets" />
<SelectValue placeholder={t.transactions.filter.walletPlaceholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Wallets</SelectItem>
<SelectContent>
<SelectItem value="all">{t.transactions.filter.walletAllWallets}</SelectItem>
{wallets.map(wallet => (
<SelectItem key={wallet.id} value={wallet.id}>
{wallet.name}
@@ -380,12 +380,12 @@ export function Transactions() {
<Label className="text-xs font-medium text-muted-foreground">Direction</Label>
<Select value={directionFilter} onValueChange={setDirectionFilter}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All directions" />
<SelectValue placeholder={t.transactions.filter.directionPlaceholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Directions</SelectItem>
<SelectItem value="in">Income</SelectItem>
<SelectItem value="out">Expense</SelectItem>
<SelectItem value="all">{t.transactions.filter.directionPlaceholder}</SelectItem>
<SelectItem value="in">{t.transactions.income}</SelectItem>
<SelectItem value="out">{t.transactions.expense}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -394,10 +394,10 @@ export function Transactions() {
{/* Row 2: Amount Range */}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Min Amount</Label>
<Label className="text-xs font-medium text-muted-foreground">{t.transactions.filter.minAmount}</Label>
<Input
type="number"
placeholder="0"
placeholder={t.transactions.filter.minAmountPlaceholder}
value={amountMin}
onChange={(e) => setAmountMin(e.target.value)}
className="h-9"
@@ -405,10 +405,10 @@ export function Transactions() {
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Max Amount</Label>
<Label className="text-xs font-medium text-muted-foreground">{t.transactions.filter.maxAmount}</Label>
<Input
type="number"
placeholder="No limit"
placeholder={t.transactions.filter.maxAmountPlaceholder}
value={amountMax}
onChange={(e) => setAmountMax(e.target.value)}
className="h-9"
@@ -419,21 +419,21 @@ export function Transactions() {
{/* Row 3: Date Range */}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">From Date</Label>
<Label className="text-xs font-medium text-muted-foreground">{t.transactions.filter.fromDate}</Label>
<DatePicker
date={dateFrom}
onDateChange={setDateFrom}
placeholder="Select start date"
placeholder={t.transactions.filter.fromDatePlaceholder}
className="w-full h-9"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">To Date</Label>
<Label className="text-xs font-medium text-muted-foreground">{t.transactions.filter.toDate}</Label>
<DatePicker
date={dateTo}
onDateChange={setDateTo}
placeholder="Select end date"
placeholder={t.transactions.filter.toDatePlaceholder}
className="w-full h-9"
/>
</div>
@@ -464,7 +464,7 @@ export function Transactions() {
)}
{directionFilter !== "all" && (
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
{directionFilter === "in" ? "Income" : "Expense"}
{directionFilter === "in" ? t.transactions.income : t.transactions.expense}
<button onClick={() => setDirectionFilter("all")} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
<X className="h-3 w-3" />
</button>
@@ -492,11 +492,11 @@ export function Transactions() {
{/* Transactions Table */}
<Card>
<CardHeader>
<CardTitle>Transactions ({filteredTransactions.length})</CardTitle>
<CardTitle>{t.transactions.tableTitle} ({filteredTransactions.length})</CardTitle>
<CardDescription>
{filteredTransactions.length !== transactions.length
? `Filtered from ${transactions.length} total transactions`
: "All your transactions"
? t.transactions.tableFiltered.replace("{count}", transactions.length.toString())
: t.transactions.tableDescription
}
</CardDescription>
</CardHeader>
@@ -504,13 +504,13 @@ export function Transactions() {
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead className="text-nowrap">Wallet</TableHead>
<TableHead className="text-center">Direction</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead>Category</TableHead>
<TableHead>Memo</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead>{t.transactions.tableTheadDate}</TableHead>
<TableHead className="text-nowrap">{t.transactions.tableTheadWallet}</TableHead>
<TableHead className="text-center">{t.transactions.tableTheadDirection}</TableHead>
<TableHead className="text-right">{t.transactions.tableTheadAmount}</TableHead>
<TableHead>{t.transactions.tableTheadCategory}</TableHead>
<TableHead>{t.transactions.tableTheadMemo}</TableHead>
<TableHead className="text-right">{t.transactions.tableTheadActions}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -546,7 +546,7 @@ export function Transactions() {
variant={`outline`}
className={transaction.direction === 'in' ? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)] stroke-[var(--color-primary)] ring-1 ring-[var(--color-primary)]/75' : 'bg-[var(--color-destructive)]/10 text-[var(--color-destructive)] stroke-[var(--color-destructive)] ring-1 ring-[var(--color-destructive)]/75'}
>
{transaction.direction === 'in' ? 'Income' : 'Expense'}
{transaction.direction === 'in' ? t.transactionDialog.income : t.transactionDialog.expense}
</Badge>
</TableCell>
<TableCell className="font-mono text-right text-nowrap">
@@ -573,15 +573,15 @@ export function Transactions() {
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Transaction</AlertDialogTitle>
<AlertDialogTitle>{t.transactionDialog.deleteConfirmTitle}</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this transaction? This action cannot be undone.
{t.transactionDialog.deleteConfirm}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t.transactionDialog.deleteConfirmCancel}</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteTransaction(transaction.walletId, transaction.id)}>
Delete
{t.transactionDialog.deleteConfirmDelete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -79,11 +79,11 @@ export function Wallets() {
const deleteWallet = async (id: string) => {
try {
await axios.delete(`${API}/wallets/${id}`)
toast.success('Wallet berhasil dihapus')
toast.success(t.walletDialog.deleteSuccess)
await loadWallets()
} catch (error) {
console.error('Failed to delete wallet:', error)
toast.error('Gagal menghapus wallet')
toast.error(t.walletDialog.deleteError)
}
}
@@ -155,15 +155,15 @@ export function Wallets() {
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Wallets</h1>
<h1 className="text-3xl font-bold tracking-tight">{t.wallets.title}</h1>
<p className="text-muted-foreground">
Manage your wallets and accounts
{t.wallets.description}
</p>
</div>
<div className="flex gap-2 sm:flex-shrink-0">
<Button variant="outline" onClick={() => setShowFilters(!showFilters)}>
<Filter className="mr-2 h-4 w-4" />
{showFilters ? 'Hide Filters' : 'Show Filters'}
{showFilters ? t.common.hideFilters : t.common.showFilters}
</Button>
<Button onClick={() => setWalletDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
@@ -226,7 +226,7 @@ export function Wallets() {
className="h-7 px-2"
>
<X className="h-3 w-3 mr-1" />
Clear All
{t.common.clearAll}
</Button>
</div>
</CardHeader>
@@ -298,7 +298,7 @@ export function Wallets() {
)}
{kindFilter !== "all" && (
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
Type: {kindFilter === "money" ? "Money" : "Asset"}
{t.common.type}: {kindFilter === "money" ? t.wallets.money : t.wallets.asset}
<button onClick={() => setKindFilter("all")} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
<X className="h-3 w-3" />
</button>
@@ -306,7 +306,7 @@ export function Wallets() {
)}
{currencyFilter !== "all" && (
<div className="inline-flex items-center gap-1 px-2.5 py-1 bg-primary/10 text-primary rounded-md text-xs">
Currency: {currencyFilter}
{t.wallets.currency}/{t.wallets.unit}: {currencyFilter}
<button onClick={() => setCurrencyFilter("all")} className="ml-1 hover:bg-primary/20 rounded-full p-0.5">
<X className="h-3 w-3" />
</button>
@@ -321,8 +321,8 @@ export function Wallets() {
<CardTitle>{t.wallets.title} ({filteredWallets.length})</CardTitle>
<CardDescription>
{filteredWallets.length !== wallets.length
? `Filtered from ${wallets.length} total wallets`
: "All your wallets"
? t.wallets.filterDesc.replace("{count}", wallets.length.toString())
: t.wallets.allWallets
}
</CardDescription>
</CardHeader>
@@ -363,7 +363,7 @@ export function Wallets() {
variant="outline"
className={`text-nowrap ${wallet.kind === 'money' ? 'bg-[var(--chart-1)]/20 text-[var(--chart-1)] ring-1 ring-[var(--chart-1)]' : 'bg-[var(--chart-2)]/20 text-[var(--chart-2)] ring-1 ring-[var(--chart-2)]'}`}
>
{wallet.kind}
{wallet.kind === 'money' ? t.wallets.money : t.wallets.asset}
</Badge>
</TableCell>
<TableCell>
@@ -382,15 +382,15 @@ export function Wallets() {
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.common.delete} {t.wallets.title}</AlertDialogTitle>
<AlertDialogTitle>{t.walletDialog.deleteConfirmTitle}</AlertDialogTitle>
<AlertDialogDescription>
{t.wallets.deleteConfirm}
{t.walletDialog.deleteConfirm}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t.common.cancel}</AlertDialogCancel>
<AlertDialogCancel>{t.walletDialog.deleteConfirmCancel}</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteWallet(wallet.id)}>
{t.common.delete}
{t.walletDialog.deleteConfirmDelete}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -0,0 +1,116 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -0,0 +1,124 @@
import * as React from "react"
import { useMediaQuery } from "@/hooks/use-media-query"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerFooter,
} from "@/components/ui/drawer"
interface ResponsiveDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
children: React.ReactNode
}
interface ResponsiveDialogContentProps {
children: React.ReactNode
className?: string
}
interface ResponsiveDialogHeaderProps {
children: React.ReactNode
}
interface ResponsiveDialogTitleProps {
children: React.ReactNode
}
interface ResponsiveDialogDescriptionProps {
children: React.ReactNode
}
interface ResponsiveDialogFooterProps {
children: React.ReactNode
}
const ResponsiveDialogContext = React.createContext<{ isDesktop: boolean } | undefined>(undefined)
export function ResponsiveDialog({ open, onOpenChange, children }: ResponsiveDialogProps) {
const isDesktop = useMediaQuery("(min-width: 768px)")
if (isDesktop) {
return (
<ResponsiveDialogContext.Provider value={{ isDesktop }}>
<Dialog open={open} onOpenChange={onOpenChange}>
{children}
</Dialog>
</ResponsiveDialogContext.Provider>
)
}
return (
<ResponsiveDialogContext.Provider value={{ isDesktop }}>
<Drawer open={open} onOpenChange={onOpenChange}>
{children}
</Drawer>
</ResponsiveDialogContext.Provider>
)
}
export function ResponsiveDialogContent({ children, className }: ResponsiveDialogContentProps) {
const context = React.useContext(ResponsiveDialogContext)
if (!context) throw new Error("ResponsiveDialogContent must be used within ResponsiveDialog")
if (context.isDesktop) {
return <DialogContent className={className}>{children}</DialogContent>
}
return <DrawerContent className={className}>{children}</DrawerContent>
}
export function ResponsiveDialogHeader({ children }: ResponsiveDialogHeaderProps) {
const context = React.useContext(ResponsiveDialogContext)
if (!context) throw new Error("ResponsiveDialogHeader must be used within ResponsiveDialog")
if (context.isDesktop) {
return <DialogHeader>{children}</DialogHeader>
}
return <DrawerHeader className="text-left">{children}</DrawerHeader>
}
export function ResponsiveDialogTitle({ children }: ResponsiveDialogTitleProps) {
const context = React.useContext(ResponsiveDialogContext)
if (!context) throw new Error("ResponsiveDialogTitle must be used within ResponsiveDialog")
if (context.isDesktop) {
return <DialogTitle>{children}</DialogTitle>
}
return <DrawerTitle>{children}</DrawerTitle>
}
export function ResponsiveDialogDescription({ children }: ResponsiveDialogDescriptionProps) {
const context = React.useContext(ResponsiveDialogContext)
if (!context) throw new Error("ResponsiveDialogDescription must be used within ResponsiveDialog")
if (context.isDesktop) {
return <DialogDescription>{children}</DialogDescription>
}
return <DrawerDescription>{children}</DrawerDescription>
}
export function ResponsiveDialogFooter({ children }: ResponsiveDialogFooterProps) {
const context = React.useContext(ResponsiveDialogContext)
if (!context) throw new Error("ResponsiveDialogFooter must be used within ResponsiveDialog")
if (context.isDesktop) {
return <DialogFooter>{children}</DialogFooter>
}
return <DrawerFooter className="pt-2">{children}</DrawerFooter>
}

View File

@@ -0,0 +1,23 @@
import { useState, useEffect } from 'react'
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false)
useEffect(() => {
const media = window.matchMedia(query)
// Set initial value
setMatches(media.matches)
// Create event listener
const listener = (e: MediaQueryListEvent) => setMatches(e.matches)
// Add listener
media.addEventListener('change', listener)
// Cleanup
return () => media.removeEventListener('change', listener)
}, [query])
return matches
}

View File

@@ -2,6 +2,7 @@ export const en = {
common: {
search: 'Search',
filter: 'Filter',
clearAll: 'Clear All',
add: 'Add',
edit: 'Edit',
delete: 'Delete',
@@ -23,6 +24,9 @@ export const en = {
inactive: 'Inactive',
yes: 'Yes',
no: 'No',
type: 'Type',
showFilters: 'Show Filters',
hideFilters: 'Hide Filters',
},
nav: {
@@ -35,6 +39,11 @@ export const en = {
overview: {
title: 'Overview',
description: 'Your financial dashboard and quick actions',
overviewPeriod: 'Overview Period',
overviewPeriodPlaceholder: 'Select period',
customStartDatePlaceholder: 'Pick start date',
customEndDatePlaceholder: 'Pick end date',
totalBalance: 'Total Balance',
totalIncome: 'Total Income',
totalExpense: 'Total Expense',
@@ -44,23 +53,41 @@ export const en = {
recentTransactions: 'Recent Transactions',
viewAll: 'View All',
noTransactions: 'No transactions yet',
addFirstTransaction: 'Add your first transaction',
addTransaction: 'Add transaction',
wallets: 'Wallets',
walletsDescription: 'Balance distribution across wallets',
walletTheadName: 'Name',
walletTheadCurrencyUnit: 'Currency/Unit',
walletTheadTransactions: 'Transactions',
walletTheadTotalBalance: 'Total Balance',
walletTheadDomination: 'Domination',
addWallet: 'Add Wallet',
noWallets: 'No wallets yet',
createFirstWallet: 'Create your first wallet',
incomeByCategory: 'Income by Category',
incomeCategoryFor: 'Income category for',
expenseByCategory: 'Expense by Category',
expenseCategoryFor: 'Expense category for',
categoryAllWalletOption: 'All Wallets',
last30Days: 'Last 30 days',
last7Days: 'Last 7 days',
thisMonth: 'This month',
lastMonth: 'Last month',
thisYear: 'This year',
lastYear: 'Last year',
allTime: 'All time',
custom: 'Custom',
financialTrend: 'Financial Trend',
financialTrendDescription: 'Income vs Expense over time',
financialTrendOverTimeMonthly: 'Monthly',
financialTrendOverTimeWeekly: 'Weekly',
financialTrendOverTimeDaily: 'Daily',
financialTrendOverTimeYearly: 'Yearly',
},
transactions: {
title: 'Transactions',
description: 'View and manage all your transactions',
addTransaction: 'Add Transaction',
editTransaction: 'Edit Transaction',
deleteConfirm: 'Are you sure you want to delete this transaction?',
@@ -69,11 +96,34 @@ export const en = {
category: 'Category',
memo: 'Memo',
wallet: 'Wallet',
direction: 'Type',
filterByWallet: 'Filter by Wallet',
filterByDirection: 'Filter by Type',
filterByCategory: 'Filter by Category',
searchPlaceholder: 'Search transactions...',
direction: 'Direction',
tableTitle: 'Transactions',
tableDescription: 'All your transactions',
tableFiltered: 'Filtered from {count} transactions',
tableTheadDate: 'Date',
tableTheadAmount: 'Amount',
tableTheadDirection: 'Direction',
tableTheadCategory: 'Category',
tableTheadMemo: 'Memo',
tableTheadWallet: 'Wallet',
tableTheadActions: 'Actions',
filter: {
searchMemo: 'Search Memo',
searchMemoPlaceholder: 'Search in memo...',
wallet: 'Wallet',
walletPlaceholder: 'Select wallet',
walletAllWallets: 'All Wallets',
direction: 'Direction',
directionPlaceholder: 'Select direction',
minAmount: 'Min Amount',
minAmountPlaceholder: '0',
maxAmount: 'Max Amount',
maxAmountPlaceholder: 'No limit',
fromDate: 'From Date',
toDate: 'To Date',
fromDatePlaceholder: 'Select start date',
toDatePlaceholder: 'Select end date',
},
noTransactions: 'No transactions',
stats: {
totalIncome: 'Total Income',
@@ -84,6 +134,7 @@ export const en = {
wallets: {
title: 'Wallets',
description: 'Manage your wallets and accounts',
addWallet: 'Add Wallet',
editWallet: 'Edit Wallet',
deleteConfirm: 'Are you sure you want to delete this wallet? All related transactions will be deleted.',
@@ -103,11 +154,14 @@ export const en = {
totalBalance: 'Total Balance',
moneyWallets: 'Money Wallets',
assetWallets: 'Asset Wallets',
allWallets: 'All Wallets',
filterDesc: 'Filtered from {count} total wallets',
},
walletDialog: {
addTitle: 'Add Wallet',
editTitle: 'Edit Wallet',
description: 'Fill in the details of your wallet',
name: 'Wallet Name',
namePlaceholder: 'e.g., Main Wallet, Savings',
type: 'Wallet Type',
@@ -122,11 +176,21 @@ export const en = {
pricePerUnit: 'Price per Unit (Optional)',
pricePerUnitPlaceholder: '0',
pricePerUnitHelper: 'Price per {unit} in IDR',
addSuccess: 'Wallet added successfully',
editSuccess: 'Wallet updated successfully',
saveError: 'Failed to save wallet',
deleteSuccess: 'Wallet deleted successfully',
deleteError: 'Failed to delete wallet',
deleteConfirm: 'Are you sure you want to delete this wallet? All related transactions will be deleted.',
deleteConfirmTitle: 'Delete Wallet',
deleteConfirmCancel: 'Cancel',
deleteConfirmDelete: 'Delete',
},
transactionDialog: {
addTitle: 'Add Transaction',
editTitle: 'Edit Transaction',
description: 'Fill in the details of your transaction',
amount: 'Amount',
amountPlaceholder: '0',
wallet: 'Wallet',
@@ -141,17 +205,37 @@ export const en = {
memoPlaceholder: 'Add a note...',
date: 'Date',
selectDate: 'Select date',
addSuccess: 'Transaction added successfully',
editSuccess: 'Transaction updated successfully',
saveError: 'Failed to save transaction',
deleteSuccess: 'Transaction deleted successfully',
deleteError: 'Failed to delete transaction',
deleteConfirm: 'Are you sure you want to delete this transaction? This action cannot be undone.',
deleteConfirmTitle: 'Delete Transaction',
deleteConfirmCancel: 'Cancel',
deleteConfirmDelete: 'Delete',
},
profile: {
title: 'Profile',
description: 'Manage your account settings and security preferences',
editProfile: 'Edit Profile',
personalInfo: 'Personal Information',
name: 'Name',
nameSynced: 'Name is synced from your Google account',
edit: 'Edit',
save: 'Save',
update: 'Update',
cancel: 'Cancel',
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...',
security: 'Security',
@@ -160,17 +244,27 @@ export const en = {
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',
setPasswordDesc: 'Set a password to enable password-based login and account deletion',
changePasswordDesc: 'Update your password to keep your account secure',
googleAuthDesc: 'Your account uses Google Sign-In. Setting a password will allow you to login with email/password and delete your account if needed.',
setting: 'Setting...',
updating: 'Updating...',
setPassword: 'Set Password',
updatePassword: 'Update Password',
twoFactor: 'Two-Factor Authentication',
twoFactorDesc: 'Add an extra layer of security to your account',
phoneNumber: 'Phone Number',
phoneNumberPlaceholder: '+62812345678',
updatePhone: 'Update Phone',
phoneNumberDescription: 'Required for WhatsApp OTP verification',
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',
enable: 'Enable',
disable: 'Disable',
enabled: 'Enabled',
@@ -181,20 +275,35 @@ export const en = {
whatsappOtp: 'WhatsApp OTP',
whatsappOtpDesc: 'Receive verification codes via WhatsApp',
enableWhatsAppOtp: 'Enable WhatsApp OTP',
disableWhatsAppOtp: 'Disable WhatsApp OTP',
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',
authenticatorApp: 'Authenticator App',
authenticatorDesc: 'Use an authenticator app like Google Authenticator',
setup: 'Setup',
authenticatorSetupInstruction: 'Setup Instructions:',
autentucatorSetupInstruction_1: 'Open your authenticator app (Google Authenticator, Authy, etc.)',
autentucatorSetupInstruction_2: 'Scan the QR code or manually enter the secret key',
autentucatorSetupInstruction_3: 'Enter the 6-digit code from your app below',
setupSecretKey: 'Secret Key (if you can\'t scan QR code):',
enableAuthenticatorApp: 'Enable Authenticator App',
disableAuthenticatorApp: 'Disable Authenticator App',
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',
dangerZoneDesc: 'Irreversible actions that will permanently affect your account',
deleteAccount: 'Delete Account',
deleteAccountDesc: 'Permanently delete your account. This action cannot be undone.',
deleteAccountDesc: 'Once you delete your account, there is no going back. This will permanently delete your account, all your data, transactions, and settings.',
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',
deleting: 'Deleting...',
yesDeleteMyAccount: 'Yes, Delete My Account',
},
dateRange: {

View File

@@ -2,6 +2,7 @@ export const id = {
common: {
search: 'Cari',
filter: 'Filter',
clearAll: 'Reset',
add: 'Tambah',
edit: 'Edit',
delete: 'Hapus',
@@ -23,6 +24,9 @@ export const id = {
inactive: 'Tidak Aktif',
yes: 'Ya',
no: 'Tidak',
type: 'Tipe',
showFilters: 'Tampilkan Filter',
hideFilters: 'Sembunyikan Filter',
},
nav: {
@@ -35,32 +39,55 @@ export const id = {
overview: {
title: 'Ringkasan',
description: 'Ringkasan keuangan dan tindakan cepat',
overviewPeriod: 'Periode Ringkasan',
overviewPeriodPlaceholder: 'Pilih Periode',
customStartDatePlaceholder: 'Pilih Tanggal Mulai',
customEndDatePlaceholder: 'Pilih Tanggal Selesai',
totalBalance: 'Total Saldo',
totalIncome: 'Total Pemasukan',
totalExpense: 'Total Pengeluaran',
acrossWallets: 'Dari {count} dompet',
income: 'pemasukan',
expense: 'pengeluaran',
acrossWallets: 'Dari {count} Dompet',
income: 'Pemasukan',
expense: 'Pengeluaran',
recentTransactions: 'Transaksi Terkini',
viewAll: 'Lihat Semua',
noTransactions: 'Belum ada transaksi',
addFirstTransaction: 'Tambahkan transaksi pertama Anda',
addTransaction: 'Tambah Transaksi',
wallets: 'Dompet',
walletsDescription: 'Distribusi saldo di antara dompet',
walletTheadName: 'Nama',
walletTheadCurrencyUnit: 'Mata Uang/Unit',
walletTheadTransactions: 'Transaksi',
walletTheadTotalBalance: 'Total Saldo',
walletTheadDomination: 'Dominasi',
addWallet: 'Tambah Dompet',
noWallets: 'Belum ada dompet',
createFirstWallet: 'Buat dompet pertama Anda',
incomeByCategory: 'Pemasukan per Kategori',
incomeCategoryFor: 'Pemasukan kategori untuk',
expenseByCategory: 'Pengeluaran per Kategori',
expenseCategoryFor: 'Pengeluaran kategori untuk',
categoryAllWalletOption: 'Semua Dompet',
last30Days: '30 hari terakhir',
last7Days: '7 hari terakhir',
thisMonth: 'Bulan ini',
lastMonth: 'Bulan lalu',
thisYear: 'Tahun ini',
lastYear: 'Tahun lalu',
allTime: 'Semua Waktu',
custom: 'Kustom',
financialTrend: 'Tren Keuangan',
financialTrendDescription: 'Pemasukan vs Pengeluaran sepanjang waktu',
financialTrendOverTimeMonthly: 'Bulanan',
financialTrendOverTimeWeekly: 'Mingguan',
financialTrendOverTimeDaily: 'Harian',
financialTrendOverTimeYearly: 'Tahunan',
},
transactions: {
title: 'Transaksi',
description: 'Lihat dan kelola semua transaksi Anda',
addTransaction: 'Tambah Transaksi',
editTransaction: 'Edit Transaksi',
deleteConfirm: 'Apakah Anda yakin ingin menghapus transaksi ini?',
@@ -69,12 +96,35 @@ export const id = {
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',
direction: 'Arah Transaksi',
tableTitle: 'Transaksi',
tableDescription: 'Semua transaksi Anda',
tableFiltered: 'Difilter dari {count} transaksi',
tableTheadDate: 'Tanggal',
tableTheadAmount: 'Jumlah',
tableTheadDirection: 'Arah Transaksi',
tableTheadCategory: 'Kategori',
tableTheadMemo: 'Catatan',
tableTheadWallet: 'Dompet',
tableTheadActions: 'Aksi',
filter: {
searchMemo: 'Cari Catatan',
searchMemoPlaceholder: 'Cari dalam catatan...',
wallet: 'Dompet',
walletPlaceholder: 'Pilih dompet',
walletAllWallets: 'Semua Dompet',
direction: 'Arah Transaksi',
directionPlaceholder: 'Pilih arah transaksi',
minAmount: 'Min Jumlah',
maxAmount: 'Max Jumlah',
minAmountPlaceholder: '0',
maxAmountPlaceholder: 'Tidak ada batas',
fromDate: 'Dari Tanggal',
fromDatePlaceholder: 'Pilih tanggal',
toDate: 'Sampai Tanggal',
toDatePlaceholder: 'Pilih tanggal',
},
noTransactions: 'Belum ada transaksi',
stats: {
totalIncome: 'Total Pemasukan',
totalExpense: 'Total Pengeluaran',
@@ -84,6 +134,7 @@ export const id = {
wallets: {
title: 'Dompet',
description: 'Kelola dompet dan akun Anda',
addWallet: 'Tambah Dompet',
editWallet: 'Edit Dompet',
deleteConfirm: 'Apakah Anda yakin ingin menghapus dompet ini? Semua transaksi terkait akan ikut terhapus.',
@@ -103,11 +154,14 @@ export const id = {
totalBalance: 'Total Saldo',
moneyWallets: 'Dompet Uang',
assetWallets: 'Dompet Aset',
allWallets: 'Semua Dompet',
filterDesc: 'Difilter dari {count} dompet',
},
walletDialog: {
addTitle: 'Tambah Dompet',
editTitle: 'Edit Dompet',
description: 'Isikan detail dompet Anda',
name: 'Nama Dompet',
namePlaceholder: 'Contoh: Dompet Utama, Tabungan',
type: 'Tipe Dompet',
@@ -122,16 +176,26 @@ export const id = {
pricePerUnit: 'Harga per Satuan (Opsional)',
pricePerUnitPlaceholder: '0',
pricePerUnitHelper: 'Harga per {unit} dalam IDR',
addSuccess: 'Dompet berhasil ditambahkan',
editSuccess: 'Dompet berhasil diupdate',
saveError: 'Gagal menyimpan dompet',
deleteSuccess: 'Dompet berhasil dihapus',
deleteError: 'Gagal menghapus dompet',
deleteConfirm: 'Apakah Anda yakin ingin menghapus dompet ini? Semua transaksi terkait akan ikut terhapus.',
deleteConfirmTitle: 'Hapus Dompet',
deleteConfirmCancel: 'Batal',
deleteConfirmDelete: 'Hapus',
},
transactionDialog: {
addTitle: 'Tambah Transaksi',
editTitle: 'Edit Transaksi',
description: 'Isikan detail transaksi Anda',
amount: 'Jumlah',
amountPlaceholder: '0',
wallet: 'Dompet',
selectWallet: 'Pilih dompet',
direction: 'Tipe Transaksi',
direction: 'Arah Transaksi',
income: 'Pemasukan',
expense: 'Pengeluaran',
category: 'Kategori',
@@ -141,17 +205,37 @@ export const id = {
memoPlaceholder: 'Tambahkan catatan...',
date: 'Tanggal',
selectDate: 'Pilih tanggal',
addSuccess: 'Transaksi berhasil ditambahkan',
editSuccess: 'Transaksi berhasil diupdate',
saveError: 'Gagal menyimpan transaksi',
deleteSuccess: 'Transaksi berhasil dihapus',
deleteError: 'Gagal menghapus transaksi',
deleteConfirm: 'Apakah Anda yakin ingin menghapus transaksi ini? Tindakan ini tidak dapat dibatalkan.',
deleteConfirmTitle: 'Hapus Transaksi',
deleteConfirmCancel: 'Batal',
deleteConfirmDelete: 'Hapus',
},
profile: {
title: 'Profil',
description: 'Kelola pengaturan akun dan preferensi keamanan Anda',
editProfile: 'Edit Profil',
personalInfo: 'Informasi Pribadi',
name: 'Nama',
nameSynced: 'Nama disinkronkan dari akun Google Anda',
edit: 'Edit',
save: 'Simpan',
update: 'Update',
cancel: 'Batal',
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...',
security: 'Keamanan',
@@ -160,41 +244,66 @@ export const id = {
newPassword: 'Password Baru',
confirmPassword: 'Konfirmasi Password Baru',
changePassword: 'Ubah Password',
setPassword: 'Atur Password',
noPassword: 'Anda login dengan Google dan belum mengatur password',
setPasswordDesc: 'Atur password untuk mengaktifkan login berdasarkan password dan penghapusan akun',
changePasswordDesc: 'Ubah password untuk mempertahankan akun Anda aman',
googleAuthDesc: 'Akun Anda menggunakan Google Sign-In. Mengatur password akan memungkinkan Anda untuk login dengan email/password dan menghapus akun jika diperlukan.',
setting: 'Setting...',
updating: 'Updating...',
setPassword: 'Buat Password',
updatePassword: 'Ubah Password',
twoFactor: 'Autentikasi Dua Faktor',
twoFactorDesc: 'Tambahkan lapisan keamanan ekstra ke akun Anda',
phoneNumber: 'Nomor Telepon',
phoneNumberPlaceholder: '+62812345678',
updatePhone: 'Update Nomor',
phoneNumberDescription: 'Diperlukan untuk verifikasi WhatsApp OTP',
emailOtp: 'Email OTP',
emailOtpDesc: 'Terima kode verifikasi via email',
enableEmailOtp: 'Aktifkan Email OTP',
disableEmailOtp: 'Nonaktifkan Email OTP',
checkYourEmailForTheVerificationCode: 'Cek email Anda untuk kode verifikasi',
enable: 'Aktifkan',
disable: 'Nonaktifkan',
enabled: 'Aktif',
disabled: 'Tidak Aktif',
disabled: 'Non-Aktif',
sendCode: 'Kirim Kode',
verifyCode: 'Verifikasi Kode',
enterCode: 'Masukkan kode',
whatsappOtp: 'WhatsApp OTP',
whatsappOtpDesc: 'Terima kode verifikasi via WhatsApp',
enableWhatsAppOtp: 'Aktifkan WhatsApp OTP',
disableWhatsAppOtp: 'Nonaktifkan WhatsApp OTP',
pleaseAddYourPhoneNumberInTheEditProfileTabFirst: 'Tambahkan nomor telepon Anda di tab Edit Profil terlebih dahulu',
checkYourWhatsAppForTheVerificationCodeOrCheckConsoleInTestMode: 'Cek WhatsApp Anda untuk kode verifikasi',
enterVerificationCode: 'Masukkan 6 digit kode',
authenticatorApp: 'Authenticator App',
authenticatorDesc: 'Gunakan aplikasi authenticator seperti Google Authenticator',
setup: 'Setup',
authenticatorSetupInstruction: 'Instruksi Setup:',
autentucatorSetupInstruction_1: 'Buka aplikasi authenticator Anda (Google Authenticator, Authy, dll.)',
autentucatorSetupInstruction_2: 'Scan QR code ini atau masukkan kode rahasia secara manual',
autentucatorSetupInstruction_3: 'Masukkan kode 6 digit dari aplikasi Anda di bawah ini',
setupSecretKey: 'Secret Key (jika tidak bisa scan QR code):',
enableAuthenticatorApp: 'Aktifkan Authenticator App',
disableAuthenticatorApp: 'Nonaktifkan Authenticator App',
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',
dangerZoneDesc: 'Tindakan yang tidak dapat diurungkan yang akan mempengaruhi akun Anda secara permanen',
deleteAccount: 'Hapus Akun',
deleteAccountDesc: 'Hapus akun Anda secara permanen. Tindakan ini tidak dapat dibatalkan.',
deleteAccountDesc: 'Setelah Anda menghapus akun, tidak ada jalan kembali. Ini akan menghapus akun Anda secara permanen, termasuk semua data, transaksi, dan pengaturan Anda.',
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',
deleting: 'Menghapus...',
yesDeleteMyAccount: 'Ya, Hapus Akun Saya',
},
dateRange: {

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/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"}
{"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/drawer.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/responsive-dialog.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-media-query.ts","./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"}