Skip to content

Commit 66f1a9c

Browse files
authored
Merge pull request #16 from HellBus1/staging
v1.2.3
2 parents 82a13a0 + 771ea7f commit 66f1a9c

File tree

15 files changed

+595
-31
lines changed

15 files changed

+595
-31
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "investcount",
33
"private": false,
4-
"version": "1.1.3",
4+
"version": "1.2.3",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { motion } from 'framer-motion'
2+
import useBudgetPlannerSection from './hooks/useBudgetPlannerSecion'
3+
import InputField from '../DepositSection/DepositCalculation/InputField'
4+
import { formatNumberWithCommas, parseAmountInputFromCommas } from '@/services/inputServices'
5+
import { useEffect, useState } from 'react'
6+
import NeedsSection from './NeedsSection/NeedsSection'
7+
import DepositDropdownSection from './DepositDropdownSection/DepositDropdownSection'
8+
import RecommendationSection from './RecommendationSection/RecommendationSection'
9+
10+
const BudgetPlannerSection = () => {
11+
const {
12+
needInput,
13+
setNeedInput,
14+
priceInput,
15+
setPriceInput,
16+
handleAddNeed,
17+
needs,
18+
depositInput,
19+
setDepositInput,
20+
filteredBanks,
21+
handleBankSelection,
22+
handleRemoveNeed,
23+
recommendation
24+
} = useBudgetPlannerSection()
25+
const EMPTY_STRING = ''
26+
const [errors, setErrors] = useState({
27+
needInput: EMPTY_STRING,
28+
priceInput: EMPTY_STRING,
29+
depositInput: EMPTY_STRING
30+
})
31+
const [, setIsFormValid] = useState(false)
32+
33+
const newErrors = {
34+
needInput: EMPTY_STRING,
35+
priceInput: EMPTY_STRING,
36+
depositInput: EMPTY_STRING
37+
}
38+
39+
const isNumberAndDecimalRegex = /^\d+(\.\d+)?$/
40+
41+
useEffect(() => {
42+
setIsFormValid(
43+
needInput !== EMPTY_STRING && priceInput !== EMPTY_STRING && depositInput !== EMPTY_STRING
44+
)
45+
}, [needInput, priceInput, depositInput])
46+
47+
const containerVariants = {
48+
hidden: { opacity: 0, y: 20 },
49+
visible: {
50+
opacity: 1,
51+
y: 0,
52+
transition: { duration: 0.8, ease: 'easeOut', staggerChildren: 0.2 }
53+
}
54+
}
55+
56+
const childVariants = {
57+
hidden: { opacity: 0, y: 20 },
58+
visible: { opacity: 1, y: 0, transition: { duration: 0.6, ease: 'easeOut' } }
59+
}
60+
61+
const validateDepositAmount = () => {
62+
if (!depositInput.trim()) {
63+
return 'Deposit amount is required.'
64+
}
65+
if (isNaN(Number(depositInput.replace(/,/g, '')))) {
66+
return 'Deposit amount must be numeric.'
67+
}
68+
return null
69+
}
70+
71+
const handleInputChange = (
72+
e: React.ChangeEvent<HTMLInputElement>,
73+
setField: (value: string) => void,
74+
fieldName: string
75+
) => {
76+
const value = e.target.value
77+
if (fieldName === 'priceInput') {
78+
const formattedValue = formatNumberWithCommas(value)
79+
setField(formattedValue)
80+
} else if (fieldName == 'depositInput') {
81+
if (!depositInput) {
82+
newErrors.depositInput = 'Deposit is required'
83+
} else if (!isNumberAndDecimalRegex.test(parseAmountInputFromCommas(depositInput))) {
84+
newErrors.depositInput = 'Deposit should be a valid number'
85+
}
86+
const formattedValue = formatNumberWithCommas(value)
87+
setField(formattedValue)
88+
} else {
89+
setField(value)
90+
}
91+
92+
setErrors((prev) => ({ ...prev, [fieldName]: EMPTY_STRING }))
93+
}
94+
95+
const validateField = () => {
96+
let isValid = true
97+
98+
if (!needInput) {
99+
newErrors.needInput = 'Need is required'
100+
isValid = false
101+
}
102+
103+
if (!priceInput) {
104+
newErrors.priceInput = 'Price is required'
105+
isValid = false
106+
} else if (!isNumberAndDecimalRegex.test(parseAmountInputFromCommas(priceInput))) {
107+
newErrors.priceInput = 'Price should be a valid number'
108+
isValid = false
109+
}
110+
111+
setErrors(newErrors)
112+
return isValid
113+
}
114+
115+
const handleAddNeedWithValidation = () => {
116+
const isValid = validateField()
117+
if (isValid) {
118+
handleAddNeed(needInput, parseAmountInputFromCommas(priceInput))
119+
}
120+
}
121+
122+
return (
123+
<motion.div
124+
className='pt-10 pb-20 w-full'
125+
initial='hidden'
126+
whileInView='visible'
127+
viewport={{ once: true, amount: 0.2 }}
128+
variants={containerVariants}
129+
>
130+
<motion.h1
131+
className='text-center text-2xl md:text-3xl font-bold text-charter-blue-600 mt-8 mb-4'
132+
variants={childVariants}
133+
>
134+
Budget Planner
135+
</motion.h1>
136+
137+
<motion.p
138+
className='text-center text-charter-blue text-lg md:text-xl mx-4 md:mx-10 mb-8'
139+
variants={childVariants}
140+
>
141+
Plan your monthly expenses and see how deposit returns can help cover your needs.
142+
</motion.p>
143+
144+
<motion.div
145+
className='card shadow-xl border-s-8 border-charter-blue mx-4 md:mx-20 lg:mx-36'
146+
variants={childVariants}
147+
>
148+
<div className='card-body'>
149+
<motion.div
150+
className='grid grid-cols-1 md:grid-cols-2 gap-4 mt-10'
151+
variants={childVariants}
152+
>
153+
<InputField
154+
label='Need'
155+
placeholder='Enter need (e.g., Rent)'
156+
type='text'
157+
value={needInput}
158+
onChange={(e) => handleInputChange(e, setNeedInput, 'needInput')}
159+
error={errors.needInput}
160+
/>
161+
162+
<InputField
163+
label='Price'
164+
placeholder='Enter price (e.g., 1200000)'
165+
type='text'
166+
value={priceInput}
167+
onChange={(e) => handleInputChange(e, setPriceInput, 'priceInput')}
168+
error={errors.priceInput}
169+
/>
170+
171+
<div className='col-span-1 md:col-span-2'>
172+
<button
173+
onClick={handleAddNeedWithValidation}
174+
className='btn btn-primary w-full text-[#ffffff]'
175+
>
176+
Add Need
177+
</button>
178+
</div>
179+
</motion.div>
180+
181+
{needs.length > 0 && <NeedsSection needs={needs} onRemoveNeed={handleRemoveNeed} />}
182+
183+
{needs.length > 0 && (
184+
<div className='mt-8'>
185+
<h3 className='text-lg font-semibold text-charter-blue-600 mb-2'>
186+
Enter Deposit Amount:
187+
</h3>
188+
<InputField
189+
label='Deposit Amount'
190+
placeholder='Enter deposit amount (e.g., 5000000)'
191+
type='text'
192+
value={depositInput}
193+
onChange={(e) => handleInputChange(e, setDepositInput, 'depositInput')}
194+
error={errors.depositInput}
195+
/>
196+
</div>
197+
)}
198+
199+
{depositInput.trim() && !validateDepositAmount() && (
200+
<DepositDropdownSection
201+
filteredBanks={filteredBanks}
202+
handleBankSelection={handleBankSelection}
203+
/>
204+
)}
205+
206+
{recommendation && <RecommendationSection recommendation={recommendation} />}
207+
</div>
208+
</motion.div>
209+
</motion.div>
210+
)
211+
}
212+
213+
export default BudgetPlannerSection
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { getImagePath, getProductName } from '@/services/inputServices'
2+
3+
interface FilteredBank {
4+
bankName: string
5+
logoUrl: string
6+
website: string
7+
rate: number
8+
minimumDeposit: number
9+
}
10+
11+
interface DepositDropdownSectionProps {
12+
filteredBanks: FilteredBank[]
13+
handleBankSelection: (bankName: string) => void
14+
}
15+
16+
const DepositDropdownSection = (props: DepositDropdownSectionProps) => {
17+
const { filteredBanks, handleBankSelection } = props
18+
19+
const renderBankNameAndRate = (filteredBank: FilteredBank) => {
20+
const { productName } = getProductName(filteredBank.bankName)
21+
return productName
22+
}
23+
24+
return (
25+
<div className='mt-4'>
26+
<h3 className='text-lg font-semibold text-charter-blue-600 mb-2'>Select a Bank:</h3>
27+
<div className='dropdown'>
28+
<div tabIndex={0} role='button' className='btn btn-primary text-[#ffffff]'>
29+
Select a bank
30+
</div>
31+
<div
32+
tabIndex={0}
33+
className='dropdown-content card bg-base-100 z-10 shadow-md mt-2 w-96 max-h-64 overflow-y-auto'
34+
>
35+
<div className='card-body'>
36+
{filteredBanks.map((bank, index) => (
37+
<div
38+
key={index}
39+
onClick={() => handleBankSelection(bank.bankName)}
40+
className='flex items-center gap-4 p-2 hover:bg-gray-100 rounded cursor-pointer'
41+
>
42+
<img
43+
src={getImagePath(bank.logoUrl)}
44+
alt={`${bank.bankName} logo`}
45+
className='w-8 h-8 object-contain'
46+
/>
47+
<span className='text-sm font-medium'>
48+
{renderBankNameAndRate(bank)} ({bank.rate}%)
49+
</span>
50+
</div>
51+
))}
52+
</div>
53+
</div>
54+
</div>
55+
</div>
56+
)
57+
}
58+
59+
export default DepositDropdownSection
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
interface Need {
2+
need: string
3+
price: string
4+
}
5+
6+
interface NeedsSectionProps {
7+
needs: Need[]
8+
onRemoveNeed: (index: number) => void // Callback to remove a need
9+
}
10+
11+
const NeedsSection = (props: NeedsSectionProps) => {
12+
const { needs, onRemoveNeed } = props
13+
14+
return (
15+
<div className='mt-8'>
16+
<h3 className='text-lg font-semibold text-charter-blue-600 mb-2'>Your Needs:</h3>
17+
<div className='flex flex-wrap gap-2'>
18+
{needs.map((item, index) => (
19+
<div
20+
key={index}
21+
className='badge badge-outline badge-lg flex items-center gap-2 px-4 py-2'
22+
>
23+
<span>{item.need}</span>
24+
<span className='text-charter-blue font-bold'>
25+
{Number(item.price).toLocaleString()} IDR
26+
</span>
27+
<button
28+
onClick={() => onRemoveNeed(index)}
29+
className='text-red-500 hover:text-red-700'
30+
aria-label='Remove need'
31+
>
32+
<svg
33+
xmlns='http://www.w3.org/2000/svg'
34+
fill='none'
35+
viewBox='0 0 24 24'
36+
strokeWidth={2}
37+
stroke='currentColor'
38+
className='w-4 h-4'
39+
>
40+
<path strokeLinecap='round' strokeLinejoin='round' d='M6 18L18 6M6 6l12 12' />
41+
</svg>
42+
</button>
43+
</div>
44+
))}
45+
</div>
46+
</div>
47+
)
48+
}
49+
50+
export default NeedsSection

0 commit comments

Comments
 (0)