ui fixes, input fixes, better dev workflow
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
VITE_API_URL=http://localhost:8081
|
||||
VITE_API_URL=/api
|
||||
VITE_PROXY_TARGET=http://localhost:8081
|
||||
VITE_APP_NAME=SkyMoney
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function CurrencyInput({
|
||||
}: Props) {
|
||||
const mergedClass = ["input", className].filter(Boolean).join(" ");
|
||||
const formatString = (raw: string) => {
|
||||
const cleanedRaw = raw.replace(/[^0-9.]/g, "");
|
||||
const cleanedRaw = raw.replace(/,/g, ".").replace(/[^0-9.]/g, "");
|
||||
const parts = cleanedRaw.split(".");
|
||||
return parts.length === 1
|
||||
? parts[0]
|
||||
|
||||
@@ -16,8 +16,9 @@ function fmt(cents: number) {
|
||||
}
|
||||
|
||||
function parseCurrencyToCents(value: string) {
|
||||
const cleaned = value.replace(/[^0-9.]/g, "");
|
||||
const [whole, fraction = ""] = cleaned.split(".");
|
||||
const cleaned = value.replace(/,/g, ".").replace(/[^0-9.]/g, "");
|
||||
const [whole, ...fractionParts] = cleaned.split(".");
|
||||
const fraction = fractionParts.join("");
|
||||
const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole;
|
||||
const parsed = Number.parseFloat(normalized || "0");
|
||||
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import CurrencyInput from "../components/CurrencyInput";
|
||||
@@ -60,8 +60,9 @@ const formatCentsForInput = (cents: number) => {
|
||||
return value.replace(/\.00$/, "");
|
||||
};
|
||||
const parseCurrencyToCents = (value: string) => {
|
||||
const cleaned = value.replace(/[^0-9.]/g, "");
|
||||
const [whole, fraction = ""] = cleaned.split(".");
|
||||
const cleaned = value.replace(/,/g, ".").replace(/[^0-9.]/g, "");
|
||||
const [whole, ...fractionParts] = cleaned.split(".");
|
||||
const fraction = fractionParts.join("");
|
||||
const normalized =
|
||||
fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole;
|
||||
const parsed = Number.parseFloat(normalized || "0");
|
||||
@@ -548,6 +549,23 @@ export default function OnboardingPage() {
|
||||
}
|
||||
|
||||
// ── UI
|
||||
const topNavBack = step > 1 ? { label: "Back", onClick: back } : undefined;
|
||||
const topNavNext = step === 6
|
||||
? {
|
||||
label: saving ? "Setting up your budget..." : "Complete Setup",
|
||||
onClick: handleFinish,
|
||||
disabled: saving,
|
||||
}
|
||||
: step === 5
|
||||
? { label: "Review Setup", onClick: next, disabled: !canNext5 }
|
||||
: step === 4
|
||||
? { label: "Continue", onClick: next, disabled: !canNext4 }
|
||||
: step === 3
|
||||
? { label: "Continue", onClick: next, disabled: !canNext3 }
|
||||
: step === 2
|
||||
? { label: "Continue", onClick: next, disabled: !canNext2 }
|
||||
: { label: "Continue", onClick: next, disabled: !canNext1 };
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[--color-bg] fade-in onboarding-page">
|
||||
{/* Hero Header */}
|
||||
@@ -569,6 +587,10 @@ export default function OnboardingPage() {
|
||||
<Stepper step={step} />
|
||||
</div>
|
||||
|
||||
<div className="onboarding-top-nav card">
|
||||
<Actions back={topNavBack} next={topNavNext} />
|
||||
</div>
|
||||
|
||||
{/* Recovery Banner */}
|
||||
{hasPartialServerState && (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
@@ -630,7 +652,9 @@ export default function OnboardingPage() {
|
||||
<div className="card stack fade-in onboarding-card">
|
||||
<div className="text-center mb-4 sm:mb-6">
|
||||
<h2 className="section-title text-xl">How do you receive income?</h2>
|
||||
<p className="muted text-sm">This helps us optimize your budget allocation</p>
|
||||
<p className="muted text-sm">
|
||||
This helps us optimize your budget allocation. Next, you'll enter your total budget amount.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:gap-4 md:grid-cols-2 max-w-4xl mx-auto">
|
||||
@@ -705,7 +729,7 @@ export default function OnboardingPage() {
|
||||
<div className="card stack fade-in text-center onboarding-card">
|
||||
<h2 className="section-title text-xl">What's your total budget?</h2>
|
||||
<p className="muted text-sm mb-4">
|
||||
This is the total amount you plan to allocate across all your expenses and savings
|
||||
This is the total amount you plan to allocate across all your expenses and savings.
|
||||
</p>
|
||||
|
||||
{incomeType === "regular" && (
|
||||
@@ -787,6 +811,9 @@ export default function OnboardingPage() {
|
||||
)}
|
||||
|
||||
<div className="w-full px-2">
|
||||
<p className="muted text-xs mb-2">
|
||||
Enter your total budget in the amount field below (decimals are supported, e.g. 2450.75).
|
||||
</p>
|
||||
<CurrencyInput
|
||||
className="input text-center text-xl font-bold w-full"
|
||||
value={budgetInput}
|
||||
|
||||
@@ -17,8 +17,9 @@ const LS_KEY = "spend.lastKind";
|
||||
const OTHER_CATEGORY_ID = "__other__";
|
||||
|
||||
function parseCurrencyToCents(value: string) {
|
||||
const cleaned = value.replace(/[^0-9.]/g, "");
|
||||
const [whole, fraction = ""] = cleaned.split(".");
|
||||
const cleaned = value.replace(/,/g, ".").replace(/[^0-9.]/g, "");
|
||||
const [whole, ...fractionParts] = cleaned.split(".");
|
||||
const fraction = fractionParts.join("");
|
||||
const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole;
|
||||
const parsed = Number.parseFloat(normalized || "0");
|
||||
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { Money } from "../../components/ui";
|
||||
import CurrencyInput from "../../components/CurrencyInput";
|
||||
import { useDashboard } from "../../hooks/useDashboard";
|
||||
import { useToast } from "../../components/Toast";
|
||||
import {
|
||||
@@ -41,6 +42,15 @@ type FixedPlan = {
|
||||
|
||||
type LocalPlan = FixedPlan & { _isNew?: boolean; _isDeleted?: boolean };
|
||||
|
||||
function parseCurrencyToCents(value: string): number {
|
||||
const cleaned = value.replace(/,/g, ".").replace(/[^0-9.]/g, "");
|
||||
const [whole, ...fractionParts] = cleaned.split(".");
|
||||
const fraction = fractionParts.join("");
|
||||
const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole;
|
||||
const parsed = Number.parseFloat(normalized || "0");
|
||||
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
|
||||
}
|
||||
|
||||
export type PlansSettingsHandle = {
|
||||
save: () => Promise<boolean>;
|
||||
};
|
||||
@@ -132,14 +142,14 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
|
||||
// Form state for adding new plan
|
||||
const [name, setName] = useState("");
|
||||
const [total, setTotal] = useState("");
|
||||
const [totalInput, setTotalInput] = useState("");
|
||||
const [priority, setPriority] = useState("");
|
||||
const [due, setDue] = useState(getTodayInTimezone(userTimezone));
|
||||
const [frequency, setFrequency] = useState<"" | FixedPlan["frequency"]>("monthly");
|
||||
const [autoPayEnabled, setAutoPayEnabled] = useState(false);
|
||||
const [amountMode, setAmountMode] = useState<"fixed" | "estimated">("fixed");
|
||||
|
||||
const totalCents = Math.max(0, Math.round((Number(total) || 0) * 100));
|
||||
const totalCents = Math.max(0, parseCurrencyToCents(totalInput));
|
||||
const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
|
||||
const addDisabled = !name.trim() || totalCents <= 0 || !due || isSaving;
|
||||
|
||||
@@ -188,7 +198,7 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
};
|
||||
setLocalPlans((prev) => [...prev, newPlan]);
|
||||
setName("");
|
||||
setTotal("");
|
||||
setTotalInput("");
|
||||
setPriority("");
|
||||
setDue(getTodayInTimezone(userTimezone));
|
||||
setFrequency("monthly");
|
||||
@@ -540,8 +550,8 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
push("err", "Save this plan before applying an actual amount.");
|
||||
return;
|
||||
}
|
||||
const raw = actualInputs[plan.id];
|
||||
const actualCents = Math.max(0, Math.round((Number(raw) || 0) * 100));
|
||||
const raw = actualInputs[plan.id] ?? "";
|
||||
const actualCents = Math.max(0, parseCurrencyToCents(raw));
|
||||
setApplyingActualByPlan((prev) => ({ ...prev, [plan.id]: true }));
|
||||
setTrueUpMessages((prev) => ({ ...prev, [plan.id]: "" }));
|
||||
try {
|
||||
@@ -629,18 +639,15 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
<CurrencyInput
|
||||
className="input"
|
||||
placeholder={amountMode === "estimated" ? "Estimated $" : "Total $"}
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={total}
|
||||
onChange={(e) => setTotal(e.target.value)}
|
||||
placeholder={amountMode === "estimated" ? "Estimated amount" : "Total amount"}
|
||||
value={totalInput}
|
||||
onValue={setTotalInput}
|
||||
/>
|
||||
{amountMode === "estimated" && (
|
||||
<div className="text-xs muted col-span-full">
|
||||
Tip: Always over-estimate variable bills to avoid due-date shortfalls.
|
||||
<div className="settings-add-form-tip">
|
||||
Tip: For variable bills (utilities, usage-based charges), set a slightly higher estimate to avoid due-date shortfalls.
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
@@ -777,14 +784,11 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="stack gap-1">
|
||||
<span className="label">Actual this cycle</span>
|
||||
<input
|
||||
<CurrencyInput
|
||||
className="input w-36"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={actualInputs[plan.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setActualInputs((prev) => ({ ...prev, [plan.id]: e.target.value }))
|
||||
onValue={(nextValue) =>
|
||||
setActualInputs((prev) => ({ ...prev, [plan.id]: nextValue }))
|
||||
}
|
||||
placeholder={((plan.totalCents ?? 0) / 100).toFixed(2)}
|
||||
/>
|
||||
@@ -799,7 +803,7 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs muted mt-2">
|
||||
Using a slightly higher estimate helps prevent last-minute shortages.
|
||||
Keep this estimate slightly high for variable bills, then true-up with the actual amount when posted.
|
||||
</div>
|
||||
{trueUpMessages[plan.id] ? (
|
||||
<div className="text-xs mt-2">{trueUpMessages[plan.id]}</div>
|
||||
@@ -958,7 +962,7 @@ function InlineEditMoney({
|
||||
useEffect(() => setV((cents / 100).toFixed(2)), [cents]);
|
||||
|
||||
const commit = () => {
|
||||
const newCents = Math.max(0, Math.round((Number(v) || 0) * 100));
|
||||
const newCents = Math.max(0, parseCurrencyToCents(v));
|
||||
if (newCents !== cents) onChange(newCents);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
@@ -1355,6 +1355,14 @@ ol > li.bg-\[--color-panel\] { color: var(--color-muted); }
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.onboarding-top-nav {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.onboarding-top-nav .onboarding-actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.onboarding-btn-next {
|
||||
width: 100%;
|
||||
padding: 0.875rem 2rem;
|
||||
@@ -1389,6 +1397,10 @@ ol > li.bg-\[--color-panel\] { color: var(--color-muted); }
|
||||
width: auto;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.onboarding-top-nav .onboarding-actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
/* sm: breakpoint utilities */
|
||||
@@ -1812,6 +1824,13 @@ html[data-theme="light"].scheme-orange body {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.settings-add-form-tip {
|
||||
flex: 1 1 100%;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted);
|
||||
margin-top: -0.1rem;
|
||||
}
|
||||
|
||||
.settings-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const proxyTarget = env.VITE_PROXY_TARGET || "http://localhost:8081";
|
||||
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: true,
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: proxyTarget,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user