diff --git a/frontend/src/page/CreateInvoice.jsx b/frontend/src/page/CreateInvoice.jsx
index ba9eb779..93d6100b 100644
--- a/frontend/src/page/CreateInvoice.jsx
+++ b/frontend/src/page/CreateInvoice.jsx
@@ -48,6 +48,12 @@ import TokenPicker, { ToggleSwitch } from "@/components/TokenPicker";
import { CopyButton } from "@/components/ui/copyButton";
import CountryPicker from "@/components/CountryPicker";
import { useTokenList } from "@/hooks/useTokenList";
+import {
+ getFromStorage,
+ saveToStorage,
+ clearStorage,
+ StorageKeys,
+} from "@/utils/localStorage";
function CreateInvoice() {
const { data: walletClient } = useWalletClient();
@@ -65,6 +71,20 @@ function CreateInvoice() {
const [userCountry, setUserCountry] = useState("");
const [clientCountry, setClientCountry] = useState("");
+ // User form fields
+ const [userFname, setUserFname] = useState("");
+ const [userLname, setUserLname] = useState("");
+ const [userEmail, setUserEmail] = useState("");
+ const [userCity, setUserCity] = useState("");
+ const [userPostalcode, setUserPostalcode] = useState("");
+
+ // Client form fields
+ const [clientFname, setClientFname] = useState("");
+ const [clientLname, setClientLname] = useState("");
+ const [clientEmail, setClientEmail] = useState("");
+ const [clientCity, setClientCity] = useState("");
+ const [clientPostalcode, setClientPostalcode] = useState("");
+
// Token selection state
const [selectedToken, setSelectedToken] = useState(null);
const [customTokenAddress, setCustomTokenAddress] = useState("");
@@ -89,8 +109,67 @@ function CreateInvoice() {
]);
const [totalAmountDue, setTotalAmountDue] = useState(0);
+ const [isInitialLoad, setIsInitialLoad] = useState(true);
+ // Load data from localStorage on mount
useEffect(() => {
+ const savedData = getFromStorage(StorageKeys.CREATE_INVOICE);
+ if (savedData) {
+ // Restore dates
+ if (savedData.dueDate) {
+ setDueDate(new Date(savedData.dueDate));
+ }
+ if (savedData.issueDate) {
+ setIssueDate(new Date(savedData.issueDate));
+ }
+
+ // Restore user info
+ if (savedData.userFname) setUserFname(savedData.userFname);
+ if (savedData.userLname) setUserLname(savedData.userLname);
+ if (savedData.userEmail) setUserEmail(savedData.userEmail);
+ if (savedData.userCountry) setUserCountry(savedData.userCountry);
+ if (savedData.userCity) setUserCity(savedData.userCity);
+ if (savedData.userPostalcode) setUserPostalcode(savedData.userPostalcode);
+
+ // Restore client info (only if no URL params)
+ const urlClientAddress = searchParams.get("clientAddress");
+ if (!urlClientAddress) {
+ if (savedData.clientAddress) setClientAddress(savedData.clientAddress);
+ if (savedData.clientFname) setClientFname(savedData.clientFname);
+ if (savedData.clientLname) setClientLname(savedData.clientLname);
+ if (savedData.clientEmail) setClientEmail(savedData.clientEmail);
+ if (savedData.clientCountry) setClientCountry(savedData.clientCountry);
+ if (savedData.clientCity) setClientCity(savedData.clientCity);
+ if (savedData.clientPostalcode)
+ setClientPostalcode(savedData.clientPostalcode);
+ }
+
+ // Restore token selection
+ if (savedData.selectedToken) {
+ setSelectedToken(savedData.selectedToken);
+ setUseCustomToken(false);
+ }
+ if (savedData.useCustomToken && savedData.customTokenAddress) {
+ setUseCustomToken(true);
+ setCustomTokenAddress(savedData.customTokenAddress);
+ if (savedData.verifiedToken) {
+ setVerifiedToken(savedData.verifiedToken);
+ setTokenVerificationState("success");
+ }
+ }
+
+ // Restore item data
+ if (savedData.itemData && savedData.itemData.length > 0) {
+ setItemData(savedData.itemData);
+ }
+ }
+ setIsInitialLoad(false);
+ }, []);
+
+ // Handle URL params (should override localStorage)
+ useEffect(() => {
+ if (isInitialLoad) return;
+
console.log("account address : ", account.address);
const urlClientAddress = searchParams.get("clientAddress");
const urlTokenAddress = searchParams.get("tokenAddress");
@@ -157,7 +236,7 @@ function CreateInvoice() {
};
processUrlToken();
- }, [searchParams, walletClient, tokens, loadingTokens, account.address]);
+ }, [searchParams, walletClient, tokens, loadingTokens, account.address, isInitialLoad]);
useEffect(() => {
const total = itemData.reduce((sum, item) => {
@@ -192,6 +271,64 @@ function CreateInvoice() {
setShowWalletAlert(!isConnected);
}, [isConnected]);
+ // Save form data to localStorage (debounced)
+ useEffect(() => {
+ if (isInitialLoad) return;
+
+ const saveData = () => {
+ const dataToSave = {
+ dueDate: dueDate?.toISOString(),
+ issueDate: issueDate?.toISOString(),
+ clientAddress,
+ userFname,
+ userLname,
+ userEmail,
+ userCountry,
+ userCity,
+ userPostalcode,
+ clientFname,
+ clientLname,
+ clientEmail,
+ clientCountry,
+ clientCity,
+ clientPostalcode,
+ selectedToken,
+ customTokenAddress,
+ useCustomToken,
+ verifiedToken,
+ itemData,
+ };
+
+ saveToStorage(StorageKeys.CREATE_INVOICE, dataToSave);
+ };
+
+ // Debounce save operations
+ const timeoutId = setTimeout(saveData, 500);
+ return () => clearTimeout(timeoutId);
+ }, [
+ dueDate,
+ issueDate,
+ clientAddress,
+ userFname,
+ userLname,
+ userEmail,
+ userCountry,
+ userCity,
+ userPostalcode,
+ clientFname,
+ clientLname,
+ clientEmail,
+ clientCountry,
+ clientCity,
+ clientPostalcode,
+ selectedToken,
+ customTokenAddress,
+ useCustomToken,
+ verifiedToken,
+ itemData,
+ isInitialLoad,
+ ]);
+
const handleItemData = (e, index) => {
const { name, value } = e.target;
@@ -417,6 +554,7 @@ const verifyToken = async (address, targetChainId = null) => {
throw new Error("Missing chainId: wallet connected but chain not configured");
}
+ const chainId = account?.chainId;
const contractAddress = import.meta.env[
`VITE_CONTRACT_ADDRESS_${account.chainId}`
];
@@ -436,6 +574,10 @@ const verifyToken = async (address, targetChainId = null) => {
);
const receipt = await tx.wait();
+
+ // Clear localStorage on successful submission
+ clearStorage(StorageKeys.CREATE_INVOICE);
+
setTimeout(() => navigate("/dashboard/sent"), 4000);
} catch (err) {
console.error("Encryption or transaction failed:", err);
@@ -450,20 +592,20 @@ const verifyToken = async (address, targetChainId = null) => {
const formData = new FormData(e.target);
const data = {
- userAddress: formData.get("userAddress"),
- userFname: formData.get("userFname"),
- userLname: formData.get("userLname"),
- userEmail: formData.get("userEmail"),
- userCountry: userCountry || formData.get("userCountry") || "",
- userCity: formData.get("userCity"),
- userPostalcode: formData.get("userPostalcode"),
- clientAddress: formData.get("clientAddress"),
- clientFname: formData.get("clientFname"),
- clientLname: formData.get("clientLname"),
- clientEmail: formData.get("clientEmail"),
- clientCountry: clientCountry || formData.get("clientCountry") || "",
- clientCity: formData.get("clientCity"),
- clientPostalcode: formData.get("clientPostalcode"),
+ userAddress: account?.address?.toString(),
+ userFname,
+ userLname,
+ userEmail,
+ userCountry,
+ userCity,
+ userPostalcode,
+ clientAddress,
+ clientFname,
+ clientLname,
+ clientEmail,
+ clientCountry,
+ clientCity,
+ clientPostalcode,
itemData,
};
await createInvoiceRequest(data);
@@ -595,6 +737,8 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="Your First Name"
className="w-full mt-1 border-gray-300 text-black "
name="userFname"
+ value={userFname}
+ onChange={(e) => setUserFname(e.target.value)}
/>
@@ -606,6 +750,8 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="Your Last Name"
className="w-full mt-1 border-gray-300 text-black"
name="userLname"
+ value={userLname}
+ onChange={(e) => setUserLname(e.target.value)}
/>
@@ -620,6 +766,8 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="Email"
className="w-full mt-1 border-gray-300 text-black"
name="userEmail"
+ value={userEmail}
+ onChange={(e) => setUserEmail(e.target.value)}
/>
@@ -653,6 +801,8 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="City"
className="w-full mt-1 border-gray-300 text-black"
name="userCity"
+ value={userCity}
+ onChange={(e) => setUserCity(e.target.value)}
/>
@@ -664,6 +814,8 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="Postal Code"
className="w-full mt-1 border-gray-300 text-black"
name="userPostalcode"
+ value={userPostalcode}
+ onChange={(e) => setUserPostalcode(e.target.value)}
/>
@@ -693,6 +845,8 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="Client First Name"
className="w-full mt-1 border-gray-300 text-black"
name="clientFname"
+ value={clientFname}
+ onChange={(e) => setClientFname(e.target.value)}
/>
@@ -704,6 +858,8 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="Client Last Name"
className="w-full mt-1 border-gray-300 text-black"
name="clientLname"
+ value={clientLname}
+ onChange={(e) => setClientLname(e.target.value)}
/>
@@ -718,6 +874,8 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="Email"
className="w-full mt-1 border-gray-300 text-black"
name="clientEmail"
+ value={clientEmail}
+ onChange={(e) => setClientEmail(e.target.value)}
/>
@@ -751,6 +909,8 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="City"
className="w-full mt-1 border-gray-300 text-black"
name="clientCity"
+ value={clientCity}
+ onChange={(e) => setClientCity(e.target.value)}
/>
@@ -762,6 +922,8 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="Postal Code"
className="w-full mt-1 border-gray-300 text-black"
name="clientPostalcode"
+ value={clientPostalcode}
+ onChange={(e) => setClientPostalcode(e.target.value)}
/>
@@ -1025,6 +1187,7 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="Enter Description"
className="w-full border-gray-300 text-black"
name="description"
+ value={itemData[index].description || ""}
onChange={(e) => handleItemData(e, index)}
/>
@@ -1039,6 +1202,7 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="0"
className="w-full border-gray-300 text-black"
name="qty"
+ value={itemData[index].qty || ""}
onChange={(e) => handleItemData(e, index)}
/>
@@ -1051,6 +1215,7 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="0"
className="w-full border-gray-300 text-black"
name="unitPrice"
+ value={itemData[index].unitPrice || ""}
onChange={(e) => handleItemData(e, index)}
/>
@@ -1066,6 +1231,7 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="0"
className="w-full border-gray-300 text-black"
name="discount"
+ value={itemData[index].discount || ""}
onChange={(e) => handleItemData(e, index)}
/>
@@ -1078,6 +1244,7 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="0"
className="w-full border-gray-300 text-black"
name="tax"
+ value={itemData[index].tax || ""}
onChange={(e) => handleItemData(e, index)}
/>
@@ -1139,6 +1306,7 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="Enter Description"
className="w-full border-gray-300 text-black"
name="description"
+ value={itemData[index].description || ""}
onChange={(e) => handleItemData(e, index)}
/>
@@ -1148,6 +1316,7 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="0"
className="w-full border-gray-300 text-black py-2"
name="qty"
+ value={itemData[index].qty || ""}
onChange={(e) => handleItemData(e, index)}
/>
@@ -1157,6 +1326,7 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="0"
className="w-full border-gray-300 text-black py-2"
name="unitPrice"
+ value={itemData[index].unitPrice || ""}
onChange={(e) => handleItemData(e, index)}
/>
@@ -1166,6 +1336,7 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="0"
className="w-full border-gray-300 text-black py-2"
name="discount"
+ value={itemData[index].discount || ""}
onChange={(e) => handleItemData(e, index)}
/>
@@ -1175,6 +1346,7 @@ const verifyToken = async (address, targetChainId = null) => {
placeholder="0"
className="w-full border-gray-300 text-black py-2"
name="tax"
+ value={itemData[index].tax || ""}
onChange={(e) => handleItemData(e, index)}
/>
diff --git a/frontend/src/page/CreateInvoicesBatch.jsx b/frontend/src/page/CreateInvoicesBatch.jsx
index fb1c9ddf..7a3385b1 100644
--- a/frontend/src/page/CreateInvoicesBatch.jsx
+++ b/frontend/src/page/CreateInvoicesBatch.jsx
@@ -54,6 +54,12 @@ import WalletConnectionAlert from "../components/WalletConnectionAlert";
import TokenPicker, { ToggleSwitch } from "@/components/TokenPicker";
import { CopyButton } from "@/components/ui/copyButton";
import CountryPicker from "@/components/CountryPicker";
+import {
+ getFromStorage,
+ saveToStorage,
+ clearStorage,
+ StorageKeys,
+} from "@/utils/localStorage";
function CreateInvoicesBatch() {
const { data: walletClient } = useWalletClient();
@@ -75,6 +81,7 @@ function CreateInvoicesBatch() {
// UI state for collapsible invoices
const [expandedInvoice, setExpandedInvoice] = useState(0);
+ const [isInitialLoad, setIsInitialLoad] = useState(true);
// Batch invoice data
const [invoiceRows, setInvoiceRows] = useState([
@@ -132,6 +139,45 @@ function CreateInvoicesBatch() {
);
}, [invoiceRows.map((r) => JSON.stringify(r.itemData)).join(",")]);
+ // Load data from localStorage on mount
+ useEffect(() => {
+ const savedData = getFromStorage(StorageKeys.CREATE_INVOICES_BATCH);
+ if (savedData) {
+ // Restore dates
+ if (savedData.dueDate) {
+ setDueDate(new Date(savedData.dueDate));
+ }
+ if (savedData.issueDate) {
+ setIssueDate(new Date(savedData.issueDate));
+ }
+
+ // Restore user info
+ if (savedData.userInfo) {
+ setUserInfo(savedData.userInfo);
+ }
+
+ // Restore invoice rows
+ if (savedData.invoiceRows && savedData.invoiceRows.length > 0) {
+ setInvoiceRows(savedData.invoiceRows);
+ }
+
+ // Restore token selection
+ if (savedData.selectedToken) {
+ setSelectedToken(savedData.selectedToken);
+ setUseCustomToken(false);
+ }
+ if (savedData.useCustomToken && savedData.customTokenAddress) {
+ setUseCustomToken(true);
+ setCustomTokenAddress(savedData.customTokenAddress);
+ if (savedData.verifiedToken) {
+ setVerifiedToken(savedData.verifiedToken);
+ setTokenVerificationState("success");
+ }
+ }
+ }
+ setIsInitialLoad(false);
+ }, []);
+
// Initialize Lit
useEffect(() => {
const initLit = async () => {
@@ -151,6 +197,41 @@ function CreateInvoicesBatch() {
setShowWalletAlert(!isConnected);
}, [isConnected]);
+ // Save form data to localStorage (debounced)
+ useEffect(() => {
+ if (isInitialLoad) return;
+
+ const saveData = () => {
+ const dataToSave = {
+ dueDate: dueDate?.toISOString(),
+ issueDate: issueDate?.toISOString(),
+ userInfo,
+ invoiceRows,
+ selectedToken,
+ customTokenAddress,
+ useCustomToken,
+ verifiedToken,
+ };
+
+ saveToStorage(StorageKeys.CREATE_INVOICES_BATCH, dataToSave);
+ };
+
+ // Debounce save operations
+ const timeoutId = setTimeout(saveData, 500);
+ return () => clearTimeout(timeoutId);
+ }, [
+ dueDate,
+ issueDate,
+ // Use JSON.stringify to detect deep changes in nested objects
+ JSON.stringify(userInfo),
+ JSON.stringify(invoiceRows),
+ selectedToken,
+ customTokenAddress,
+ useCustomToken,
+ verifiedToken,
+ isInitialLoad,
+ ]);
+
// Invoice management
const addInvoiceRow = () => {
const newIndex = invoiceRows.length;
@@ -480,6 +561,7 @@ function CreateInvoicesBatch() {
toast.info("Submitting batch transaction to blockchain...");
// Send to contract
+ const chainId = account?.chainId;
const contractAddress = import.meta.env[
`VITE_CONTRACT_ADDRESS_${chainId}`
];
@@ -501,6 +583,9 @@ function CreateInvoicesBatch() {
toast.info("Transaction submitted! Waiting for confirmation...");
const receipt = await tx.wait();
+ // Clear localStorage on successful submission
+ clearStorage(StorageKeys.CREATE_INVOICES_BATCH);
+
toast.success(
`Successfully created ${validInvoices.length} invoices in batch!`
);
diff --git a/frontend/src/page/GenerateLink.jsx b/frontend/src/page/GenerateLink.jsx
index 40b02e9b..c19266aa 100644
--- a/frontend/src/page/GenerateLink.jsx
+++ b/frontend/src/page/GenerateLink.jsx
@@ -24,6 +24,12 @@ import { Badge } from "@/components/ui/badge";
import { BrowserProvider, ethers } from "ethers";
import { ERC20_ABI } from "@/contractsABI/ERC20_ABI";
import { useTokenList } from "../hooks/useTokenList";
+import {
+ getFromStorage,
+ saveToStorage,
+ StorageKeys,
+ sanitizeDataForStorage,
+} from "@/utils/sessionStorage";
const GenerateLink = () => {
const { address, isConnected, chainId } = useAccount();
@@ -43,6 +49,8 @@ const GenerateLink = () => {
const [tokenVerificationState, setTokenVerificationState] = useState("idle");
const [verifiedToken, setVerifiedToken] = useState(null);
const [loading, setLoading] = useState(false);
+ const [isInitialLoad, setIsInitialLoad] = useState(true);
+ const [savedTokenState, setSavedTokenState] = useState(null);
// Set default token when tokens are loaded
useEffect(() => {
@@ -64,6 +72,139 @@ const GenerateLink = () => {
setShowWalletAlert(!isConnected);
}, [isConnected]);
+ // Load persisted data from sessionStorage on mount
+ useEffect(() => {
+ const savedData = getFromStorage(StorageKeys.GENERATE_LINK);
+
+ if (savedData) {
+ if (savedData.amount) setAmount(savedData.amount);
+ if (savedData.description) setDescription(savedData.description);
+
+ // Defer token restoration until we can validate against current chain/token list
+ const tokenState = {
+ selectedToken: savedData.selectedToken || null,
+ useCustomToken: !!savedData.useCustomToken,
+ customTokenAddress: savedData.customTokenAddress || "",
+ verifiedToken: savedData.verifiedToken || null,
+ savedChainId: savedData.chainId ?? null,
+ };
+ setSavedTokenState(tokenState);
+ }
+
+ setIsInitialLoad(false);
+ }, []);
+
+ // Validate and restore token state against current chain/token list
+ useEffect(() => {
+ if (!savedTokenState) return;
+
+ const clearTokenState = () => {
+ setSelectedToken(null);
+ setUseCustomToken(false);
+ setCustomTokenAddress("");
+ setVerifiedToken(null);
+ setTokenVerificationState("idle");
+ };
+
+ const currentChainId = chainId || 1;
+ const chainMatches =
+ !savedTokenState.savedChainId ||
+ savedTokenState.savedChainId === currentChainId;
+
+ // If the saved data was for a different chain, do not restore token state
+ if (!chainMatches) {
+ clearTokenState();
+ return;
+ }
+
+ // 1. Try to restore a listed token, but only if it still exists for this chain
+ if (savedTokenState.selectedToken) {
+ const savedAddress =
+ (savedTokenState.selectedToken.address ||
+ savedTokenState.selectedToken.contract_address ||
+ "").toLowerCase();
+
+ const matchingToken = tokens.find((token) => {
+ const tokenAddress =
+ (token.contract_address || token.address || "").toLowerCase();
+ return tokenAddress === savedAddress;
+ });
+
+ if (matchingToken) {
+ setUseCustomToken(false);
+ setSelectedToken({
+ address: matchingToken.contract_address,
+ symbol: matchingToken.symbol,
+ name: matchingToken.name,
+ logo: matchingToken.image,
+ decimals: matchingToken.decimals ?? 18,
+ });
+ return;
+ }
+
+ // Saved selectedToken no longer valid on this chain
+ clearTokenState();
+ return;
+ }
+
+ // 2. Try to restore a custom token if the address is valid
+ if (savedTokenState.useCustomToken && savedTokenState.customTokenAddress) {
+ const addr = savedTokenState.customTokenAddress;
+
+ if (ethers.isAddress(addr)) {
+ setUseCustomToken(true);
+ setCustomTokenAddress(addr);
+
+ if (savedTokenState.verifiedToken) {
+ setVerifiedToken(savedTokenState.verifiedToken);
+ setTokenVerificationState("success");
+ } else {
+ setVerifiedToken(null);
+ setTokenVerificationState("idle");
+ }
+ return;
+ }
+
+ // Invalid custom token address for this chain; clear state
+ clearTokenState();
+ return;
+ }
+
+ // No valid token information to restore
+ clearTokenState();
+ }, [savedTokenState, tokens, chainId]);
+
+ // Persist form state to sessionStorage (debounced)
+ useEffect(() => {
+ if (isInitialLoad) return;
+
+ const saveData = () => {
+ const dataToSave = sanitizeDataForStorage({
+ amount,
+ description,
+ selectedToken,
+ customTokenAddress,
+ useCustomToken,
+ verifiedToken,
+ chainId,
+ });
+
+ saveToStorage(StorageKeys.GENERATE_LINK, dataToSave);
+ };
+
+ const timeoutId = setTimeout(saveData, 500);
+ return () => clearTimeout(timeoutId);
+ }, [
+ amount,
+ description,
+ selectedToken,
+ customTokenAddress,
+ useCustomToken,
+ verifiedToken,
+ chainId,
+ isInitialLoad,
+ ]);
+
const generateLink = () => {
const tokenToUse = useCustomToken ? verifiedToken : selectedToken;
diff --git a/frontend/src/utils/localStorage.js b/frontend/src/utils/localStorage.js
new file mode 100644
index 00000000..6037a164
--- /dev/null
+++ b/frontend/src/utils/localStorage.js
@@ -0,0 +1,202 @@
+/**
+ * Utility functions for sessionStorage operations with TTL and PII protection
+ * Uses sessionStorage (session-only, cleared on tab close) with TTL expiry
+ */
+
+const STORAGE_KEYS = {
+ CREATE_INVOICE: 'chainvoice_create_invoice',
+ CREATE_INVOICES_BATCH: 'chainvoice_create_invoices_batch',
+};
+
+// Default TTL: 24 hours (in milliseconds)
+const DEFAULT_TTL = 24 * 60 * 60 * 1000;
+
+/**
+ * Get storage instance (sessionStorage for session-only storage)
+ */
+const getStorage = () => {
+ if (typeof window === 'undefined') return null;
+ return window.sessionStorage;
+};
+
+/**
+ * Safe sessionStorage getter with TTL expiry check
+ * Automatically deletes expired entries on read
+ * Session-only: cleared when browser tab closes
+ */
+export const getFromStorage = (key, defaultValue = null) => {
+ try {
+ const storage = getStorage();
+ if (!storage) return defaultValue;
+
+ const item = storage.getItem(key);
+ if (!item) return defaultValue;
+
+ const parsed = JSON.parse(item);
+
+ // Check if entry has TTL and if it's expired
+ if (parsed.expiresAt && parsed.expiresAt < Date.now()) {
+ // Entry expired, delete it
+ storage.removeItem(key);
+ return defaultValue;
+ }
+
+ // Return the actual data (without TTL metadata)
+ return parsed.data !== undefined ? parsed.data : parsed;
+ } catch (error) {
+ console.error(`Error reading from sessionStorage (${key}):`, error);
+ return defaultValue;
+ }
+};
+
+/**
+ * Safe sessionStorage setter with TTL
+ * Wraps data with expiry timestamp
+ * Session-only: cleared when browser tab closes
+ */
+export const saveToStorage = (key, value, ttl = DEFAULT_TTL) => {
+ try {
+ const storage = getStorage();
+ if (!storage) return;
+
+ const expiresAt = Date.now() + ttl;
+ const dataToStore = {
+ data: value,
+ expiresAt,
+ createdAt: Date.now(),
+ };
+
+ storage.setItem(key, JSON.stringify(dataToStore));
+ } catch (error) {
+ console.error(`Error saving to sessionStorage (${key}):`, error);
+ // Handle quota exceeded error
+ if (error.name === 'QuotaExceededError') {
+ console.warn('sessionStorage quota exceeded. Clearing old data...');
+ clearStorage(key);
+ // Try to clean up expired entries
+ cleanupExpiredEntries();
+ }
+ }
+};
+
+/**
+ * Clear specific key from sessionStorage
+ */
+export const clearStorage = (key) => {
+ try {
+ const storage = getStorage();
+ if (!storage) return;
+ storage.removeItem(key);
+ } catch (error) {
+ console.error(`Error clearing sessionStorage (${key}):`, error);
+ }
+};
+
+/**
+ * Cleanup expired entries from sessionStorage
+ */
+const cleanupExpiredEntries = () => {
+ try {
+ const storage = getStorage();
+ if (!storage) return;
+
+ const keysToRemove = [];
+ for (let i = 0; i < storage.length; i++) {
+ const key = storage.key(i);
+ if (key && key.startsWith('chainvoice_')) {
+ try {
+ const item = storage.getItem(key);
+ if (item) {
+ const parsed = JSON.parse(item);
+ if (parsed.expiresAt && parsed.expiresAt < Date.now()) {
+ keysToRemove.push(key);
+ }
+ }
+ } catch (e) {
+ // Invalid entry, remove it
+ keysToRemove.push(key);
+ }
+ }
+ }
+
+ keysToRemove.forEach((key) => storage.removeItem(key));
+ } catch (error) {
+ console.error('Error cleaning up expired entries:', error);
+ }
+};
+
+// Maximum number of invoice rows to persist (to prevent quota issues)
+const MAX_PERSISTED_INVOICES = 10;
+
+/**
+ * Remove PII (emails, names, addresses, countries) from data object before saving
+ * Only keeps essential non-PII data needed for form restoration
+ */
+export const sanitizeDataForStorage = (data) => {
+ if (!data || typeof data !== 'object') return data;
+
+ const sanitized = { ...data };
+
+ // Remove top-level PII fields
+ delete sanitized.userEmail;
+ delete sanitized.clientEmail;
+ delete sanitized.userFname;
+ delete sanitized.userLname;
+ delete sanitized.userCountry;
+ delete sanitized.userCity;
+ delete sanitized.userPostalcode;
+ delete sanitized.clientFname;
+ delete sanitized.clientLname;
+ delete sanitized.clientCountry;
+ delete sanitized.clientCity;
+ delete sanitized.clientPostalcode;
+
+ // Handle nested userInfo object (for batch invoices)
+ if (sanitized.userInfo && typeof sanitized.userInfo === 'object') {
+ sanitized.userInfo = { ...sanitized.userInfo };
+ // Remove all PII from userInfo - only keep non-PII fields if any
+ delete sanitized.userInfo.userEmail;
+ delete sanitized.userInfo.userFname;
+ delete sanitized.userInfo.userLname;
+ delete sanitized.userInfo.userCountry;
+ delete sanitized.userInfo.userCity;
+ delete sanitized.userInfo.userPostalcode;
+ }
+
+ // Handle invoiceRows array (for batch invoices)
+ if (sanitized.invoiceRows && Array.isArray(sanitized.invoiceRows)) {
+ // Limit batch size to prevent quota issues
+ const limitedRows = sanitized.invoiceRows.slice(0, MAX_PERSISTED_INVOICES);
+
+ sanitized.invoiceRows = limitedRows.map((row) => {
+ const sanitizedRow = { ...row };
+ // Remove all client PII - only keep essential non-PII data
+ delete sanitizedRow.clientEmail;
+ delete sanitizedRow.clientFname;
+ delete sanitizedRow.clientLname;
+ delete sanitizedRow.clientCountry;
+ delete sanitizedRow.clientCity;
+ delete sanitizedRow.clientPostalcode;
+ // Keep: clientAddress (needed for functionality), itemData, totalAmountDue
+ return sanitizedRow;
+ });
+ }
+
+ return sanitized;
+};
+
+/**
+ * Get maximum number of invoices that can be persisted
+ */
+export const getMaxPersistedInvoices = () => MAX_PERSISTED_INVOICES;
+
+/**
+ * Storage keys
+ */
+export const StorageKeys = STORAGE_KEYS;
+
+// Cleanup expired entries on module load
+if (typeof window !== 'undefined') {
+ cleanupExpiredEntries();
+}
+
diff --git a/frontend/src/utils/sessionStorage.js b/frontend/src/utils/sessionStorage.js
new file mode 100644
index 00000000..00184728
--- /dev/null
+++ b/frontend/src/utils/sessionStorage.js
@@ -0,0 +1,279 @@
+/**
+ * Utility functions for sessionStorage operations with TTL and PII protection
+ * Uses sessionStorage (session-only, cleared on tab close) with TTL expiry
+ */
+
+const STORAGE_KEYS = {
+ CREATE_INVOICE: 'chainvoice_create_invoice',
+ CREATE_INVOICES_BATCH: 'chainvoice_create_invoices_batch',
+ GENERATE_LINK: 'chainvoice_generate_link',
+};
+
+// Default TTL: 24 hours (in milliseconds)
+const DEFAULT_TTL = 24 * 60 * 60 * 1000;
+
+/**
+ * Get storage instance (sessionStorage for session-only storage)
+ */
+const getStorage = () => {
+ if (typeof window === 'undefined') return null;
+ return window.sessionStorage;
+};
+
+/**
+ * Safe sessionStorage getter with TTL expiry check
+ * Automatically deletes expired entries on read
+ * Session-only: cleared when browser tab closes
+ */
+export const getFromStorage = (key, defaultValue = null) => {
+ try {
+ const storage = getStorage();
+ if (!storage) return defaultValue;
+
+ const item = storage.getItem(key);
+ if (!item) return defaultValue;
+
+ // Parse JSON with error handling - remove corrupted entries
+ let parsed;
+ try {
+ parsed = JSON.parse(item);
+ } catch (parseError) {
+ // Corrupted JSON - remove it from storage
+ storage.removeItem(key);
+ console.error(`Corrupted JSON in sessionStorage (${key}), removed:`, parseError);
+ return defaultValue;
+ }
+
+ // Treat undefined as missing data
+ if (parsed === undefined) {
+ storage.removeItem(key);
+ return defaultValue;
+ }
+
+ // Check if entry has TTL and if it's expired
+ if (parsed.expiresAt && parsed.expiresAt < Date.now()) {
+ // Entry expired, delete it
+ storage.removeItem(key);
+ return defaultValue;
+ }
+
+ // Return parsed.data if present, otherwise defaultValue
+ // Treat undefined data as missing to prevent false round-trip of undefined
+ if (parsed.data !== undefined) {
+ return parsed.data;
+ }
+
+ // If parsed.data is undefined, treat as missing
+ return defaultValue;
+ } catch (error) {
+ console.error(`Error reading from sessionStorage (${key}):`, error);
+ return defaultValue;
+ }
+};
+
+/**
+ * Check if an error is a quota exceeded error
+ * Handles various browser implementations and error formats
+ */
+const isQuotaExceededError = (error) => {
+ if (!error) return false;
+
+ // Check error name (standard)
+ if (error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED') {
+ return true;
+ }
+
+ // Check error code (some browsers)
+ if (error.code === 22 || error.code === 1014) {
+ return true;
+ }
+
+ // Check error message (fallback for edge cases)
+ const message = String(error.message || '').toLowerCase();
+ if (message.includes('quota') || message.includes('storage') || message.includes('exceeded')) {
+ return true;
+ }
+
+ return false;
+};
+
+/**
+ * Safe sessionStorage setter with TTL
+ * Wraps data with expiry timestamp
+ * Session-only: cleared when browser tab closes
+ * Includes retry logic for quota exceeded errors
+ */
+export const saveToStorage = (key, value, ttl = DEFAULT_TTL) => {
+ try {
+ const storage = getStorage();
+ if (!storage) return;
+
+ // Validate TTL: ensure it's a positive number
+ const validTtl = typeof ttl === 'number' && ttl > 0 && isFinite(ttl) ? ttl : DEFAULT_TTL;
+
+ const expiresAt = Date.now() + validTtl;
+ const dataToStore = {
+ data: value,
+ expiresAt,
+ createdAt: Date.now(),
+ };
+
+ const serialized = JSON.stringify(dataToStore);
+ storage.setItem(key, serialized);
+ } catch (error) {
+ console.error(`Error saving to sessionStorage (${key}):`, error);
+
+ // Handle quota exceeded error with retry
+ if (isQuotaExceededError(error)) {
+ console.warn('sessionStorage quota exceeded. Clearing old data and retrying...');
+
+ // Clean up: remove current key and expired entries
+ clearStorage(key);
+ cleanupExpiredEntries();
+
+ // Retry the write once after cleanup
+ try {
+ const storage = getStorage();
+ if (!storage) return;
+
+ const validTtl = typeof ttl === 'number' && ttl > 0 && isFinite(ttl) ? ttl : DEFAULT_TTL;
+ const expiresAt = Date.now() + validTtl;
+ const dataToStore = {
+ data: value,
+ expiresAt,
+ createdAt: Date.now(),
+ };
+
+ const serialized = JSON.stringify(dataToStore);
+ storage.setItem(key, serialized);
+ console.info('Successfully saved to sessionStorage after quota cleanup');
+ } catch (retryError) {
+ // If retry also fails, log but don't throw (graceful degradation)
+ console.error(`Failed to save to sessionStorage after cleanup (${key}):`, retryError);
+ }
+ }
+ }
+};
+
+/**
+ * Clear specific key from sessionStorage
+ */
+export const clearStorage = (key) => {
+ try {
+ const storage = getStorage();
+ if (!storage) return;
+ storage.removeItem(key);
+ } catch (error) {
+ console.error(`Error clearing sessionStorage (${key}):`, error);
+ }
+};
+
+/**
+ * Cleanup expired entries from sessionStorage
+ */
+const cleanupExpiredEntries = () => {
+ try {
+ const storage = getStorage();
+ if (!storage) return;
+
+ const keysToRemove = [];
+ for (let i = 0; i < storage.length; i++) {
+ const key = storage.key(i);
+ if (key && key.startsWith('chainvoice_')) {
+ try {
+ const item = storage.getItem(key);
+ if (item) {
+ const parsed = JSON.parse(item);
+ if (parsed.expiresAt && parsed.expiresAt < Date.now()) {
+ keysToRemove.push(key);
+ }
+ }
+ } catch (e) {
+ // Invalid entry, remove it
+ keysToRemove.push(key);
+ }
+ }
+ }
+
+ keysToRemove.forEach((key) => storage.removeItem(key));
+ } catch (error) {
+ console.error('Error cleaning up expired entries:', error);
+ }
+};
+
+// Maximum number of invoice rows to persist (to prevent quota issues)
+const MAX_PERSISTED_INVOICES = 10;
+
+/**
+ * Remove *some* PII (emails, names, countries, etc.) from data object before saving.
+ * Note: `clientAddress` is intentionally retained for form restoration.
+ */
+export const sanitizeDataForStorage = (data) => {
+ if (!data || typeof data !== 'object') return data;
+
+ const sanitized = { ...data };
+
+ // Remove top-level PII fields
+ // Note: clientAddress is intentionally retained for form restoration
+ delete sanitized.userEmail;
+ delete sanitized.clientEmail;
+ delete sanitized.userFname;
+ delete sanitized.userLname;
+ delete sanitized.userCountry;
+ delete sanitized.userCity;
+ delete sanitized.userPostalcode;
+ delete sanitized.clientFname;
+ delete sanitized.clientLname;
+ delete sanitized.clientCountry;
+ delete sanitized.clientCity;
+ delete sanitized.clientPostalcode;
+
+ // Handle nested userInfo object (for batch invoices)
+ if (sanitized.userInfo && typeof sanitized.userInfo === 'object') {
+ sanitized.userInfo = { ...sanitized.userInfo };
+ // Remove all PII from userInfo - only keep non-PII fields if any
+ delete sanitized.userInfo.userEmail;
+ delete sanitized.userInfo.userFname;
+ delete sanitized.userInfo.userLname;
+ delete sanitized.userInfo.userCountry;
+ delete sanitized.userInfo.userCity;
+ delete sanitized.userInfo.userPostalcode;
+ }
+
+ // Handle invoiceRows array (for batch invoices)
+ if (sanitized.invoiceRows && Array.isArray(sanitized.invoiceRows)) {
+ // Limit batch size to prevent quota issues
+ const limitedRows = sanitized.invoiceRows.slice(0, MAX_PERSISTED_INVOICES);
+
+ sanitized.invoiceRows = limitedRows.map((row) => {
+ const sanitizedRow = { ...row };
+ // Remove all client PII - only keep essential non-PII data
+ delete sanitizedRow.clientEmail;
+ delete sanitizedRow.clientFname;
+ delete sanitizedRow.clientLname;
+ delete sanitizedRow.clientCountry;
+ delete sanitizedRow.clientCity;
+ delete sanitizedRow.clientPostalcode;
+ // Keep: clientAddress (needed for functionality), itemData, totalAmountDue
+ return sanitizedRow;
+ });
+ }
+
+ return sanitized;
+};
+
+/**
+ * Get maximum number of invoices that can be persisted
+ */
+export const getMaxPersistedInvoices = () => MAX_PERSISTED_INVOICES;
+
+/**
+ * Storage keys
+ */
+export const StorageKeys = STORAGE_KEYS;
+
+// Cleanup expired entries on module load
+if (typeof window !== 'undefined') {
+ cleanupExpiredEntries();
+}
+