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:
14
apps/web/package-lock.json
generated
14
apps/web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
116
apps/web/src/components/ui/drawer.tsx
Normal file
116
apps/web/src/components/ui/drawer.tsx
Normal 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,
|
||||
}
|
||||
124
apps/web/src/components/ui/responsive-dialog.tsx
Normal file
124
apps/web/src/components/ui/responsive-dialog.tsx
Normal 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>
|
||||
}
|
||||
23
apps/web/src/hooks/use-media-query.ts
Normal file
23
apps/web/src/hooks/use-media-query.ts
Normal 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
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user