diff --git a/components/ZapsnagButton.tsx b/components/ZapsnagButton.tsx index 9bd2912a8..807314792 100644 --- a/components/ZapsnagButton.tsx +++ b/components/ZapsnagButton.tsx @@ -15,7 +15,7 @@ import { NostrContext, SignerContext, } from "@/components/utility-components/nostr-context-provider"; -import { getLocalStorageData } from "@/utils/nostr/nostr-helper-functions"; +import { getStoredRelays } from "@/utils/nostr/nostr-helper-functions"; import { constructGiftWrappedEvent, constructMessageSeal, @@ -110,6 +110,8 @@ async function resolveSellerZapContext( }; } +import { storage, STORAGE_KEYS } from "@/utils/storage"; + export default function ZapsnagButton({ product }: { product: ProductData }) { const { isOpen, onOpen, onClose } = useDisclosure(); const [loading, setLoading] = useState(false); @@ -131,16 +133,12 @@ export default function ZapsnagButton({ product }: { product: ProductData }) { const { signer, isLoggedIn, pubkey: userPubkey } = useContext(SignerContext); useEffect(() => { - if (typeof window !== "undefined") { - const savedInfo = localStorage.getItem("shopstr_shipping_info"); - if (savedInfo) { - try { - const parsed = JSON.parse(savedInfo); - setShippingInfo((prev) => ({ ...prev, ...parsed })); - } catch (e) { - console.error("Failed to load saved shipping info", e); - } - } + const savedInfo = storage.getJson | null>( + STORAGE_KEYS.SHIPPING_INFO, + null + ); + if (savedInfo) { + setShippingInfo((prev) => ({ ...prev, ...savedInfo })); } }, []); @@ -231,7 +229,7 @@ export default function ZapsnagButton({ product }: { product: ProductData }) { await getSellerZapContext(nostrManager, product.pubkey); originalWebLN = (window as any).webln; - const { nwcString } = getLocalStorageData(); + const nwcString = storage.getItem(STORAGE_KEYS.NWC_STRING); if (nwcString) { const nwcProvider = new NostrWebLNProvider({ nostrWalletConnectUrl: nwcString, @@ -287,7 +285,7 @@ export default function ZapsnagButton({ product }: { product: ProductData }) { setStatus("Paying via Lightning..."); - const { relays: userRelays } = getLocalStorageData(); + const userRelays = getStoredRelays(); const targetRelays = userRelays.length > 0 ? userRelays @@ -304,10 +302,7 @@ export default function ZapsnagButton({ product }: { product: ProductData }) { const response = await ln.zap(zapArgs); if (response.preimage) { - localStorage.setItem( - "shopstr_shipping_info", - JSON.stringify(shippingInfo) - ); + storage.setJson(STORAGE_KEYS.SHIPPING_INFO, shippingInfo); setStatus("Verifying receipt..."); const receiptResult = await validateZapReceipt(nostrManager, { diff --git a/components/__tests__/ZapsnagButton.test.tsx b/components/__tests__/ZapsnagButton.test.tsx index 6a7902fe6..f9bab9f1b 100644 --- a/components/__tests__/ZapsnagButton.test.tsx +++ b/components/__tests__/ZapsnagButton.test.tsx @@ -96,6 +96,7 @@ jest.mock("nostr-tools", () => ({ jest.mock("@/utils/nostr/nostr-helper-functions", () => ({ getLocalStorageData: jest.fn(), + getStoredRelays: jest.fn(() => []), })); jest.mock("@/utils/nostr/gift-wrap", () => ({ diff --git a/components/cart-invoice-card.tsx b/components/cart-invoice-card.tsx index 4bf16749a..1ac3961d1 100644 --- a/components/cart-invoice-card.tsx +++ b/components/cart-invoice-card.tsx @@ -123,6 +123,7 @@ import { sumProductTotalsInSats, } from "@/utils/cart-totals"; import { mapWithConcurrency } from "@/utils/concurrency"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; const CART_SHIPPING_CONVERSION_CONCURRENCY = 6; @@ -267,26 +268,23 @@ export default function CartInvoiceCard({ } }); } - sessionStorage.setItem( - "orderSummary", - JSON.stringify({ - productTitle: pendingOrderRef.current.productTitle, - productImage: products[0]?.images?.[0] || "", - amount: String(totalCost), - subtotal: String(subtotalCost), - currency: pendingOrderRef.current.currency, - paymentMethod: pendingOrderRef.current.paymentMethod, - orderId: pendingOrderRef.current.orderId, - shippingAddress: pendingOrderRef.current.shippingAddress, - sellerPubkey: pendingOrderRef.current.sellerPubkey, - isCart: true, - cartItems, - freeShippingApplied: anyFreeShipping, - originalShippingCost: anyFreeShipping - ? String(originalShipping) - : undefined, - }) - ); + storage.setSessionJson(STORAGE_KEYS.ORDER_SUMMARY, { + productTitle: pendingOrderRef.current.productTitle, + productImage: products[0]?.images?.[0] || "", + amount: String(totalCost), + subtotal: String(subtotalCost), + currency: pendingOrderRef.current.currency, + paymentMethod: pendingOrderRef.current.paymentMethod, + orderId: pendingOrderRef.current.orderId, + shippingAddress: pendingOrderRef.current.shippingAddress, + sellerPubkey: pendingOrderRef.current.sellerPubkey, + isCart: true, + cartItems, + freeShippingApplied: anyFreeShipping, + originalShippingCost: anyFreeShipping + ? String(originalShipping) + : undefined, + }); } catch {} pendingOrderRef.current = null; @@ -1447,7 +1445,7 @@ export default function CartInvoiceCard({ "seller payment hand-off" ); markMintQuoteClaimed(hash); - localStorage.setItem("cart", JSON.stringify([])); + storage.setJson(STORAGE_KEYS.CART, []); setPaymentConfirmed(true); setInvoiceIsPaid(true); setQrCodeUrl(null); @@ -1491,7 +1489,7 @@ export default function CartInvoiceCard({ lastErrorMessage: "Mint reports quote ISSUED before local claim recorded proofs", }); - localStorage.setItem("cart", JSON.stringify([])); + storage.setJson(STORAGE_KEYS.CART, []); setPaymentConfirmed(true); setQrCodeUrl(null); setFailureText( @@ -1514,7 +1512,7 @@ export default function CartInvoiceCard({ status: "failed_terminal", lastErrorMessage: "Quote ISSUED before local claim recorded proofs", }); - localStorage.setItem("cart", JSON.stringify([])); + storage.setJson(STORAGE_KEYS.CART, []); setPaymentConfirmed(true); setQrCodeUrl(null); setFailureText( @@ -2296,18 +2294,15 @@ export default function CartInvoiceCard({ } else { proofArray = [...remainingProofs]; } - localStorage.setItem("tokens", JSON.stringify(proofArray)); - localStorage.setItem( - "history", - JSON.stringify([ - { - type: 5, - amount: serverAmount, - date: Math.floor(Date.now() / 1000), - }, - ...currentHistory, - ]) - ); + storage.setJson(STORAGE_KEYS.TOKENS, proofArray); + storage.setJson(STORAGE_KEYS.HISTORY, [ + { + type: 5, + amount: serverAmount, + date: Math.floor(Date.now() / 1000), + }, + ...currentHistory, + ]); await publishProofEvent( nostr!, signer!, @@ -2317,7 +2312,7 @@ export default function CartInvoiceCard({ serverAmount.toString(), deletedEventIds ); - localStorage.setItem("cart", JSON.stringify([])); + storage.setJson(STORAGE_KEYS.CART, []); setOrderConfirmed(true); setPaymentConfirmed(true); setCashuPaymentSent(true); diff --git a/components/display-products.tsx b/components/display-products.tsx index 22a8131c2..fafc1b06d 100644 --- a/components/display-products.tsx +++ b/components/display-products.tsx @@ -33,6 +33,12 @@ import { getListingReportSignal, summarizeReportEvents, } from "@/utils/nostr/report-moderation"; +import { + createStorageKey, + storage, + STORAGE_KEY_PREFIXES, + STORAGE_KEYS, +} from "@/utils/storage"; import { nip19 } from "nostr-tools"; const isNip19SearchQuery = (search: string) => { @@ -117,9 +123,9 @@ const DisplayProducts = ({ useEffect(() => { if (typeof window !== "undefined") { const storageKey = focusedPubkey - ? `marketplace-page-${focusedPubkey}` - : "marketplace-page-general"; - const savedPage = sessionStorage.getItem(storageKey); + ? createStorageKey(STORAGE_KEY_PREFIXES.MARKETPLACE_PAGE, focusedPubkey) + : STORAGE_KEYS.MARKETPLACE_PAGE_GENERAL; + const savedPage = storage.getSessionItem(storageKey); if (savedPage) { const pageNum = parseInt(savedPage, 10); if (!isNaN(pageNum) && pageNum > 0) { @@ -287,23 +293,28 @@ const DisplayProducts = ({ const prevFiltersRef = `${selectedSearch}-${selectedLocation}-${Array.from( selectedCategories ).join(",")}`; - const currentFiltersRef = sessionStorage.getItem("last-filters-ref"); + const currentFiltersRef = storage.getSessionItem( + STORAGE_KEYS.LAST_FILTERS_REF + ); if (currentFiltersRef && currentFiltersRef !== prevFiltersRef) { // Filters changed, reset to page 1 setCurrentPage(1); if (typeof window !== "undefined") { const storageKey = focusedPubkey - ? `marketplace-page-${focusedPubkey}` - : "marketplace-page-general"; - sessionStorage.setItem(storageKey, "1"); + ? createStorageKey( + STORAGE_KEY_PREFIXES.MARKETPLACE_PAGE, + focusedPubkey + ) + : STORAGE_KEYS.MARKETPLACE_PAGE_GENERAL; + storage.setSessionItem(storageKey, "1"); } } else if (currentPage > newTotalPages) { // Current page exceeds total pages, go to last page setCurrentPage(newTotalPages); } - sessionStorage.setItem("last-filters-ref", prevFiltersRef); + storage.setSessionItem(STORAGE_KEYS.LAST_FILTERS_REF, prevFiltersRef); onFilteredProductsChange?.(filtered); }, [ @@ -423,9 +434,9 @@ const DisplayProducts = ({ // Save to session storage if (typeof window !== "undefined") { const storageKey = focusedPubkey - ? `marketplace-page-${focusedPubkey}` - : "marketplace-page-general"; - sessionStorage.setItem(storageKey, page.toString()); + ? createStorageKey(STORAGE_KEY_PREFIXES.MARKETPLACE_PAGE, focusedPubkey) + : STORAGE_KEYS.MARKETPLACE_PAGE_GENERAL; + storage.setSessionItem(storageKey, page.toString()); } }; diff --git a/components/nav-top.tsx b/components/nav-top.tsx index 3977eb1ba..ae830e1e4 100644 --- a/components/nav-top.tsx +++ b/components/nav-top.tsx @@ -9,7 +9,7 @@ import { useRouter } from "next/router"; import SignInModal from "./sign-in/SignInModal"; import { ProfileWithDropdown } from "./utility-components/profile/profile-dropdown"; import { ShopProfile } from "../utils/types/types"; -import { getLocalStorageJson } from "@/utils/safe-json"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; const TopNav = ({ setFocusedPubkey, @@ -45,10 +45,7 @@ const TopNav = ({ useEffect(() => { const fetchAndUpdateCartQuantity = async () => { - const cartList = getLocalStorageJson("cart", [], { - removeOnError: true, - validate: Array.isArray, - }); + const cartList = storage.getJson(STORAGE_KEYS.CART, []); if (cartList.length > 0) { setCartQuantity(cartList.length); } else { diff --git a/components/product-form.tsx b/components/product-form.tsx index 4e69f3de8..d844668fe 100644 --- a/components/product-form.tsx +++ b/components/product-form.tsx @@ -33,8 +33,8 @@ import { SHIPPING_OPTIONS, } from "@/utils/STATIC-VARIABLES"; import { + getStoredRelays, PostListing, - getLocalStorageData, finalizeAndSendNostrEvent, } from "@/utils/nostr/nostr-helper-functions"; import LocationDropdown from "./utility-components/dropdowns/location-dropdown"; @@ -140,7 +140,7 @@ export default function ProductForm({ useEffect(() => { if (typeof window !== "undefined") { - const { relays } = getLocalStorageData(); + const relays = getStoredRelays(); setPubkey(signerPubKey as string); setRelayHint(relays[0] as string); } diff --git a/components/product-invoice-card.tsx b/components/product-invoice-card.tsx index a2984dfab..1abe12f26 100644 --- a/components/product-invoice-card.tsx +++ b/components/product-invoice-card.tsx @@ -101,6 +101,7 @@ import { SavedAddress, } from "@/utils/types/types"; import { Controller } from "react-hook-form"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; type ListingMintQuoteResponse = { request: string; @@ -324,29 +325,26 @@ export default function ProductInvoiceCard({ useEffect(() => { if (paymentConfirmed && pendingOrderRef.current) { try { - sessionStorage.setItem( - "orderSummary", - JSON.stringify({ - productTitle: pendingOrderRef.current.productTitle, - productImage: productData.images[0] || "", - amount: pendingOrderRef.current.amount, - currency: pendingOrderRef.current.currency, - paymentMethod: pendingOrderRef.current.paymentMethod, - orderId: pendingOrderRef.current.orderId, - shippingCost: productData.shippingCost - ? String(productData.shippingCost) - : undefined, - selectedSize, - selectedVolume, - selectedWeight, - selectedBulkOption: selectedBulkOption - ? String(selectedBulkOption) - : undefined, - shippingAddress: pendingOrderRef.current.shippingAddress, - pickupLocation: selectedPickupLocation || undefined, - sellerPubkey: pendingOrderRef.current.sellerPubkey, - }) - ); + storage.setSessionJson(STORAGE_KEYS.ORDER_SUMMARY, { + productTitle: pendingOrderRef.current.productTitle, + productImage: productData.images[0] || "", + amount: pendingOrderRef.current.amount, + currency: pendingOrderRef.current.currency, + paymentMethod: pendingOrderRef.current.paymentMethod, + orderId: pendingOrderRef.current.orderId, + shippingCost: productData.shippingCost + ? String(productData.shippingCost) + : undefined, + selectedSize, + selectedVolume, + selectedWeight, + selectedBulkOption: selectedBulkOption + ? String(selectedBulkOption) + : undefined, + shippingAddress: pendingOrderRef.current.shippingAddress, + pickupLocation: selectedPickupLocation || undefined, + sellerPubkey: pendingOrderRef.current.sellerPubkey, + }); } catch {} } }, [paymentConfirmed]); @@ -2003,18 +2001,15 @@ export default function ProductInvoiceCard({ } else { proofArray = [...remainingProofs]; } - localStorage.setItem("tokens", JSON.stringify(proofArray)); - localStorage.setItem( - "history", - JSON.stringify([ - { - type: 5, - amount: serverAmount, - date: Math.floor(Date.now() / 1000), - }, - ...currentHistory, - ]) - ); + storage.setJson(STORAGE_KEYS.TOKENS, proofArray); + storage.setJson(STORAGE_KEYS.HISTORY, [ + { + type: 5, + amount: serverAmount, + date: Math.floor(Date.now() / 1000), + }, + ...currentHistory, + ]); await publishProofEvent( nostr!, signer!, diff --git a/components/settings/user-profile-form.tsx b/components/settings/user-profile-form.tsx index a745784ad..5f1763aab 100644 --- a/components/settings/user-profile-form.tsx +++ b/components/settings/user-profile-form.tsx @@ -27,6 +27,7 @@ import { } from "@/utils/nostr/nostr-helper-functions"; import { FileUploaderButton } from "@/components/utility-components/file-uploader"; import ShopstrSpinner from "@/components/utility-components/shopstr-spinner"; +import { storage } from "@/utils/storage"; interface UserProfileFormProps { isOnboarding?: boolean; @@ -77,9 +78,10 @@ const UserProfileForm = ({ isOnboarding }: UserProfileFormProps) => { if (!userPubkey) return; setIsFetchingProfile(true); - const localFallback = parseLocalProfileFallback( - localStorage.getItem(getLocalUserProfileKey(userPubkey)) + const rawLocalFallback = storage.getItem( + getLocalUserProfileKey(userPubkey) ); + const localFallback = parseLocalProfileFallback(rawLocalFallback); const profileMap = profileContext.profileData; const profile = profileMap.has(userPubkey) @@ -100,17 +102,14 @@ const UserProfileForm = ({ isOnboarding }: UserProfileFormProps) => { } try { - localStorage.setItem( - getLocalUserProfileKey(userPubkey), - JSON.stringify({ - content: shouldUseLocalFallback - ? localFallback!.content - : profile.content, - updatedAt: shouldUseLocalFallback - ? localFallback!.updatedAt - : profileCreatedAt, - }) - ); + storage.setJson(getLocalUserProfileKey(userPubkey), { + content: shouldUseLocalFallback + ? localFallback!.content + : profile.content, + updatedAt: shouldUseLocalFallback + ? localFallback!.updatedAt + : profileCreatedAt, + }); } catch (error) { console.error("Failed to persist profile fallback locally:", error); } @@ -161,13 +160,10 @@ const UserProfileForm = ({ isOnboarding }: UserProfileFormProps) => { }; try { - localStorage.setItem( - getLocalUserProfileKey(userPubkey), - JSON.stringify({ - content: updatedData, - updatedAt: Math.floor(Date.now() / 1000), - }) - ); + storage.setJson(getLocalUserProfileKey(userPubkey), { + content: updatedData, + updatedAt: Math.floor(Date.now() / 1000), + }); } catch (error) { console.error("Failed to save local profile fallback:", error); } diff --git a/components/storefront/storefront-layout.tsx b/components/storefront/storefront-layout.tsx index 0d24fb1fd..1922b9b50 100644 --- a/components/storefront/storefront-layout.tsx +++ b/components/storefront/storefront-layout.tsx @@ -44,6 +44,7 @@ import StorefrontWallet from "./storefront-wallet"; import StorefrontMyListings from "./storefront-my-listings"; import StorefrontOrderConfirmation from "./storefront-order-confirmation"; import StorefrontPolicyPage from "./storefront-policy-page"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; import { isExternalStorefrontHref, sanitizeStorefrontNavHref, @@ -162,12 +163,12 @@ export default function StorefrontLayout({ useEffect(() => { if (shopPubkey) { - sessionStorage.setItem("sf_seller_pubkey", shopPubkey); - localStorage.setItem("sf_seller_pubkey", shopPubkey); + storage.setSessionItem(STORAGE_KEYS.SF_SELLER_PUBKEY, shopPubkey); + storage.setItem(STORAGE_KEYS.SF_SELLER_PUBKEY, shopPubkey); } if (shopSlug) { - sessionStorage.setItem("sf_shop_slug", shopSlug); - localStorage.setItem("sf_shop_slug", shopSlug); + storage.setSessionItem(STORAGE_KEYS.SF_SHOP_SLUG, shopSlug); + storage.setItem(STORAGE_KEYS.SF_SHOP_SLUG, shopSlug); } }, [shopPubkey, shopSlug]); diff --git a/components/storefront/storefront-order-confirmation.tsx b/components/storefront/storefront-order-confirmation.tsx index 498e6790a..3ebf91097 100644 --- a/components/storefront/storefront-order-confirmation.tsx +++ b/components/storefront/storefront-order-confirmation.tsx @@ -14,6 +14,7 @@ import parseTags, { import { nip19 } from "nostr-tools"; import ProductCard from "@/components/utility-components/product-card"; import { productSatisfiesPriceFilter } from "@/utils/parsers/product-filter-helpers"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; interface OrderSummaryData { productTitle: string; @@ -72,15 +73,14 @@ export default function StorefrontOrderConfirmation({ useEffect(() => { if (hasConsumedOrderRef.current) return; - const stored = sessionStorage.getItem("orderSummary"); + const stored = storage.getSessionJson( + STORAGE_KEYS.ORDER_SUMMARY, + null + ); if (stored) { hasConsumedOrderRef.current = true; - try { - setOrderData(JSON.parse(stored)); - sessionStorage.removeItem("orderSummary"); - } catch { - router.push(`/shop/${shopSlug}`); - } + setOrderData(stored); + storage.removeSessionItem(STORAGE_KEYS.ORDER_SUMMARY); } else { router.push(`/shop/${shopSlug}`); } diff --git a/components/storefront/storefront-theme-wrapper.tsx b/components/storefront/storefront-theme-wrapper.tsx index 77aa6a87a..036c34207 100644 --- a/components/storefront/storefront-theme-wrapper.tsx +++ b/components/storefront/storefront-theme-wrapper.tsx @@ -18,6 +18,7 @@ import { } from "@/utils/types/types"; import StorefrontFooterComponent from "./storefront-footer"; import { getNavTextColor } from "@/utils/storefront-colors"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; import { getStorefrontCartQuantity } from "@/utils/storefront-cart"; const DEFAULT_COLORS: StorefrontColorScheme = { @@ -80,12 +81,12 @@ export default function StorefrontThemeWrapper({ useEffect(() => { if (sellerPubkey) { - sessionStorage.setItem("sf_seller_pubkey", sellerPubkey); - localStorage.setItem("sf_seller_pubkey", sellerPubkey); + storage.setSessionItem(STORAGE_KEYS.SF_SELLER_PUBKEY, sellerPubkey); + storage.setItem(STORAGE_KEYS.SF_SELLER_PUBKEY, sellerPubkey); } if (storefront?.shopSlug) { - sessionStorage.setItem("sf_shop_slug", storefront.shopSlug); - localStorage.setItem("sf_shop_slug", storefront.shopSlug); + storage.setSessionItem(STORAGE_KEYS.SF_SHOP_SLUG, storefront.shopSlug); + storage.setItem(STORAGE_KEYS.SF_SHOP_SLUG, storefront.shopSlug); } }, [sellerPubkey, storefront?.shopSlug]); diff --git a/components/utility-components/__tests__/checkout-card-p2pk-badge.test.tsx b/components/utility-components/__tests__/checkout-card-p2pk-badge.test.tsx index c7d60ac61..91d6c19aa 100644 --- a/components/utility-components/__tests__/checkout-card-p2pk-badge.test.tsx +++ b/components/utility-components/__tests__/checkout-card-p2pk-badge.test.tsx @@ -155,10 +155,6 @@ jest.mock( }) ); -jest.mock("@/utils/safe-json", () => ({ - getLocalStorageJson: jest.fn().mockReturnValue(null), -})); - jest.mock("@/utils/cart-discounts", () => ({ isCartDiscountsMap: jest.fn().mockReturnValue(false), })); diff --git a/components/utility-components/__tests__/checkout-card.test.tsx b/components/utility-components/__tests__/checkout-card.test.tsx index aea6325df..5f600aac5 100644 --- a/components/utility-components/__tests__/checkout-card.test.tsx +++ b/components/utility-components/__tests__/checkout-card.test.tsx @@ -15,7 +15,6 @@ import { mockFetchJsonOnce, type CheckoutContextOverrides, } from "@/test-utils/checkout-test-helpers"; -import { getLocalStorageJson } from "@/utils/safe-json"; import { isP2pkEscrowFeatureEnabled } from "@/utils/cashu/p2pk-checkout"; import type { ProductData } from "@/utils/parsers/product-parser-functions"; @@ -208,13 +207,8 @@ jest.mock( }) ); -jest.mock("@/utils/safe-json", () => ({ - getLocalStorageJson: jest.fn(), -})); - // ── Typed mock handles ──────────────────────────────────────────────────────── -const mockGetLocalStorageJson = getLocalStorageJson as jest.Mock; const mockIsP2pkEscrowFeatureEnabled = isP2pkEscrowFeatureEnabled as jest.Mock; // ── Fixtures & render helper ───────────────────────────────────────────────── @@ -267,9 +261,6 @@ function latestCallProps(mockFn: jest.Mock) { beforeEach(() => { jest.clearAllMocks(); mockIsP2pkEscrowFeatureEnabled.mockReturnValue(false); - mockGetLocalStorageJson.mockImplementation( - (_key: string, fallback: unknown) => fallback - ); installFetchMock(); localStorage.clear(); jest.spyOn(Storage.prototype, "setItem"); @@ -445,10 +436,6 @@ describe("handleAddToCart", () => { }); it("persists {code: discountCode} into localStorage cartDiscounts keyed by productData.pubkey, guarded by isCartDiscountsMap", async () => { - mockGetLocalStorageJson.mockImplementation( - (key: string, fallback: unknown) => - key === "cartDiscounts" ? {} : fallback - ); const { productData } = renderCheckoutCard({ currency: "SATS", totalCost: 900, @@ -470,11 +457,9 @@ describe("handleAddToCart", () => { }); it("does not corrupt existing cartDiscounts entries for other products when adding this one", async () => { - mockGetLocalStorageJson.mockImplementation( - (key: string, fallback: unknown) => - key === "cartDiscounts" - ? { other_seller_pubkey: { code: "OLD10" } } - : fallback + localStorage.setItem( + "cartDiscounts", + JSON.stringify({ other_seller_pubkey: { code: "OLD10" } }) ); const { productData } = renderCheckoutCard({ currency: "SATS", @@ -570,22 +555,15 @@ describe("Buy Now / Add to Cart gating", () => { }); describe("cart hydration on mount", () => { - it("hydrates cart state from localStorage via getLocalStorageJson", () => { - mockGetLocalStorageJson.mockImplementation( - (key: string, fallback: unknown) => - key === "cart" ? [{ id: "prod1" }] : fallback - ); + it("hydrates cart state from the storage manager", () => { + localStorage.setItem("cart", JSON.stringify([{ id: "prod1" }])); renderCheckoutCard({ id: "prod1" }); expect(screen.getByRole("button", { name: "Add To Cart" })).toBeDisabled(); }); - it("defaults to an empty cart when getLocalStorageJson returns null", () => { - mockGetLocalStorageJson.mockImplementation( - (key: string, fallback: unknown) => (key === "cart" ? null : fallback) - ); - + it("defaults to an empty cart when storage is empty", () => { renderCheckoutCard({ id: "prod1" }); expect(screen.getByRole("button", { name: "Add To Cart" })).toBeEnabled(); diff --git a/components/utility-components/__tests__/file-uploader.test.tsx b/components/utility-components/__tests__/file-uploader.test.tsx index 5a04b9573..8de31466d 100644 --- a/components/utility-components/__tests__/file-uploader.test.tsx +++ b/components/utility-components/__tests__/file-uploader.test.tsx @@ -41,6 +41,7 @@ jest.mock( jest.mock("@/utils/nostr/nostr-helper-functions", () => ({ blossomUploadImages: jest.fn(), + getStoredBlossomServers: jest.fn(() => []), getLocalStorageData: jest.fn(() => ({})), })); diff --git a/components/utility-components/__tests__/nostr-context-provider-storage.test.tsx b/components/utility-components/__tests__/nostr-context-provider-storage.test.tsx new file mode 100644 index 000000000..c8645d2e9 --- /dev/null +++ b/components/utility-components/__tests__/nostr-context-provider-storage.test.tsx @@ -0,0 +1,30 @@ +import { render, waitFor } from "@testing-library/react"; +import { SignerContextProvider } from "../nostr-context-provider"; + +jest.mock("../request-passphrase-modal", () => () => null); +jest.mock("../auth-challenge-modal", () => () => null); +jest.mock("../migration-prompt-modal", () => () => null); + +describe("SignerContextProvider storage loading", () => { + beforeEach(() => { + localStorage.clear(); + jest.restoreAllMocks(); + }); + + it("does not report a storage parse warning when no signer is stored", async () => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + + render( + +
signed out
+
+ ); + + await waitFor(() => { + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining('Storage parse error for key "signer"'), + expect.anything() + ); + }); + }); +}); diff --git a/components/utility-components/__tests__/shopstr-slider.test.tsx b/components/utility-components/__tests__/shopstr-slider.test.tsx index 472c9ccfd..2397ee0c6 100644 --- a/components/utility-components/__tests__/shopstr-slider.test.tsx +++ b/components/utility-components/__tests__/shopstr-slider.test.tsx @@ -5,17 +5,12 @@ import { FollowsContext, type FollowsContextInterface, } from "@/utils/context/context"; -import { getLocalStorageData } from "@/utils/nostr/nostr-helper-functions"; const mockUseTheme = { theme: "light" }; jest.mock("next-themes", () => ({ useTheme: () => mockUseTheme, })); -jest.mock("@/utils/nostr/nostr-helper-functions", () => ({ - getLocalStorageData: jest.fn(() => ({ wot: 5 })), -})); - jest.mock("@/utils/STATIC-VARIABLES", () => ({ SHOPSTRBUTTONCLASSNAMES: "mock-button-class", })); @@ -40,8 +35,14 @@ jest.mock("@heroui/react", () => ({ })); const mockLocalStorageSetItem = jest.fn(); +const mockLocalStorageGetItem = jest.fn(() => "5"); Object.defineProperty(window, "localStorage", { - value: { setItem: mockLocalStorageSetItem }, + value: { + getItem: mockLocalStorageGetItem, + setItem: mockLocalStorageSetItem, + removeItem: jest.fn(), + clear: jest.fn(), + }, writable: true, }); @@ -70,7 +71,7 @@ describe("ShopstrSlider", () => { it("initializes with a value from localStorage and does not show the refresh button", () => { renderWithContext(defaultFollowsContext); - expect(getLocalStorageData).toHaveBeenCalled(); + expect(mockLocalStorageGetItem).toHaveBeenCalledWith("wot"); expect(screen.getByTestId("slider")).toBeInTheDocument(); expect( screen.queryByRole("button", { name: "Refresh to Apply" }) diff --git a/components/utility-components/checkout-card.tsx b/components/utility-components/checkout-card.tsx index ed7ed29fe..d35063e9b 100644 --- a/components/utility-components/checkout-card.tsx +++ b/components/utility-components/checkout-card.tsx @@ -44,8 +44,8 @@ import WeightSelector from "./weight-selector"; import BulkSelector from "./bulk-selector"; import ZapsnagButton from "@/components/ZapsnagButton"; import { RawEventModal, EventIdModal } from "./modals/event-modals"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; import useReportEventFlow from "./use-report-event-flow"; -import { getLocalStorageJson } from "@/utils/safe-json"; import { CartDiscountsMap, isCartDiscountsMap } from "@/utils/cart-discounts"; const SUMMARY_CHARACTER_LIMIT = 100; @@ -233,14 +233,9 @@ export default function CheckoutCard({ const containerRef = useRef(null); useEffect(() => { - if (typeof window !== "undefined") { - const cartList = getLocalStorageJson("cart", [], { - removeOnError: true, - validate: Array.isArray, - }); - if (cartList && cartList.length > 0) { - setCart(cartList); - } + const cartList = storage.getJson(STORAGE_KEYS.CART, []); + if (cartList && cartList.length > 0) { + setCart(cartList); } }, []); @@ -390,7 +385,7 @@ export default function CheckoutCard({ updatedCart = [...cart, productToAdd]; setCart(updatedCart); - localStorage.setItem("cart", JSON.stringify(updatedCart)); + storage.setJson(STORAGE_KEYS.CART, updatedCart); const sellerShop = shopMapContext.shopData.get(productData.pubkey); if ( @@ -403,19 +398,18 @@ export default function CheckoutCard({ // Store discount code if applied if (appliedDiscount > 0 && discountCode) { - const discounts = getLocalStorageJson( - "cartDiscounts", + const discounts = storage.getJson( + STORAGE_KEYS.CART_DISCOUNTS, {}, { removeOnError: true, - removeOnValidationError: true, validate: isCartDiscountsMap, } ); discounts[productData.pubkey] = { code: discountCode, }; - localStorage.setItem("cartDiscounts", JSON.stringify(discounts)); + storage.setJson(STORAGE_KEYS.CART_DISCOUNTS, discounts); } } else { onOpen(); diff --git a/components/utility-components/claim-button.tsx b/components/utility-components/claim-button.tsx index 2c6ea27d0..5701f89f7 100644 --- a/components/utility-components/claim-button.tsx +++ b/components/utility-components/claim-button.tsx @@ -77,6 +77,12 @@ import { parseDisputeEvent, publishDisputeEvent, } from "@/utils/nostr/dispute-records"; +import { + createStorageKey, + storage, + STORAGE_KEY_PREFIXES, + STORAGE_KEYS, +} from "@/utils/storage"; export default function ClaimButton({ token, @@ -129,7 +135,7 @@ export default function ClaimButton({ ); const paymentRequestSentAtKey = (id: string) => - `shopstr.escrow.paymentRequestSentAt.${id}`; + createStorageKey(STORAGE_KEY_PREFIXES.PAYMENT_REQUEST_SENT_AT, id); // Restores "awaiting buyer confirmation" state across reloads. The DM // timestamp isn't a reliable client-independent clock, so this trusts the @@ -137,7 +143,7 @@ export default function ClaimButton({ // gates a client-side UI affordance (the escalate button), not payout. useEffect(() => { if (!orderId) return; - const stored = localStorage.getItem(paymentRequestSentAtKey(orderId)); + const stored = storage.getItem(paymentRequestSentAtKey(orderId)); if (stored) { setRequestSentAt(Number(stored)); setIsAwaitingBuyerConfirm(true); @@ -520,13 +526,10 @@ export default function ClaimButton({ "in", tokenAmount.toString() ); - localStorage.setItem( - "tokens", - JSON.stringify([...tokens, ...freshProofs]) - ); + storage.setJson(STORAGE_KEYS.TOKENS, [...tokens, ...freshProofs]); if (!mints.includes(tokenMint)) { const updatedMints = [...mints, tokenMint]; - localStorage.setItem("mints", JSON.stringify(updatedMints)); + storage.setJson(STORAGE_KEYS.MINTS, updatedMints); if (cashuPrivkey) { await publishWalletEvent( nostr!, @@ -544,17 +547,14 @@ export default function ClaimButton({ setIsReceived(true); } setIsRedeeming(false); - localStorage.setItem( - "history", - JSON.stringify([ - { - type: 1, - amount: tokenAmount, - date: Math.floor(Date.now() / 1000), - }, - ...history, - ]) - ); + storage.setJson(STORAGE_KEYS.HISTORY, [ + { + type: 1, + amount: tokenAmount, + date: Math.floor(Date.now() / 1000), + }, + ...history, + ]); return; } @@ -585,10 +585,10 @@ export default function ClaimButton({ tokenAmount.toString() ); const tokenArray = [...tokens, ...uniqueProofs]; - localStorage.setItem("tokens", JSON.stringify(tokenArray)); + storage.setJson(STORAGE_KEYS.TOKENS, tokenArray); if (!mints.includes(tokenMint)) { const updatedMints = [...mints, tokenMint]; - localStorage.setItem("mints", JSON.stringify(updatedMints)); + storage.setJson(STORAGE_KEYS.MINTS, updatedMints); if (cashuPrivkey) { await publishWalletEvent( nostr!, @@ -604,17 +604,14 @@ export default function ClaimButton({ setIsReceived(true); } setIsRedeeming(false); - localStorage.setItem( - "history", - JSON.stringify([ - { - type: 1, - amount: tokenAmount, - date: Math.floor(Date.now() / 1000), - }, - ...history, - ]) - ); + storage.setJson(STORAGE_KEYS.HISTORY, [ + { + type: 1, + amount: tokenAmount, + date: Math.floor(Date.now() / 1000), + }, + ...history, + ]); } else { setIsSpent(true); setIsRedeeming(false); @@ -788,7 +785,7 @@ export default function ClaimButton({ }; await sendEscrowDm(buyerPubkey, payload); const sentAt = Date.now(); - localStorage.setItem(paymentRequestSentAtKey(orderId), String(sentAt)); + storage.setItem(paymentRequestSentAtKey(orderId), String(sentAt)); setRequestSentAt(sentAt); setIsAwaitingBuyerConfirm(true); } catch (error) { @@ -866,7 +863,7 @@ export default function ClaimButton({ }); if (result.success) { setIsReceived(true); - localStorage.removeItem(paymentRequestSentAtKey(orderId)); + storage.removeItem(paymentRequestSentAtKey(orderId)); setIsAwaitingBuyerConfirm(false); } else { setEscrowActionError(result.error ?? "Redemption failed."); @@ -1019,7 +1016,7 @@ export default function ClaimButton({ ); } - localStorage.removeItem(paymentRequestSentAtKey(orderId)); + storage.removeItem(paymentRequestSentAtKey(orderId)); } catch (error) { setDisputeStatus("none"); setIsDisputeInProgress(false); diff --git a/components/utility-components/file-uploader.tsx b/components/utility-components/file-uploader.tsx index 86dc06c51..0983cdc12 100644 --- a/components/utility-components/file-uploader.tsx +++ b/components/utility-components/file-uploader.tsx @@ -2,7 +2,7 @@ import { useContext, useRef, useState } from "react"; import { Button, Input, Progress } from "@heroui/react"; import { blossomUploadImages, - getLocalStorageData, + getStoredBlossomServers, } from "@/utils/nostr/nostr-helper-functions"; import { SignerContext } from "@/components/utility-components/nostr-context-provider"; import { AnimatePresence, motion } from "framer-motion"; @@ -73,7 +73,7 @@ export const FileUploaderButton = ({ const hiddenFileInput = useRef(null); const dropZoneRef = useRef(null); const { signer, isLoggedIn } = useContext(SignerContext); - const { blossomServers } = getLocalStorageData() || {}; + const blossomServers = getStoredBlossomServers(); const getPreviewUrl = (file: File): string => URL.createObjectURL(file); diff --git a/components/utility-components/mint-recovery-boot.tsx b/components/utility-components/mint-recovery-boot.tsx index 5c70573e7..12e609b2b 100644 --- a/components/utility-components/mint-recovery-boot.tsx +++ b/components/utility-components/mint-recovery-boot.tsx @@ -9,14 +9,12 @@ import { recoverPendingMintQuotes, getPendingMintQuotes, } from "@/utils/cashu/pending-mint-operations"; -import { - getLocalStorageData, - publishProofEvent, -} from "@/utils/nostr/nostr-helper-functions"; +import { publishProofEvent } from "@/utils/nostr/nostr-helper-functions"; import { NostrContext, SignerContext, } from "@/components/utility-components/nostr-context-provider"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; /** * Mounted once near the root of the app. On first signer/nostr availability, @@ -59,20 +57,18 @@ export function MintRecoveryBoot(): null { }, onProofsClaimed: async (quote: PendingMintQuote, proofs: Proof[]) => { if (cancelled) return; - const { tokens, history } = getLocalStorageData(); + const tokens = storage.getJson(STORAGE_KEYS.TOKENS, []); + const history = storage.getJson(STORAGE_KEYS.HISTORY, []); const proofArray = [...tokens, ...proofs]; - window.localStorage.setItem("tokens", JSON.stringify(proofArray)); - window.localStorage.setItem( - "history", - JSON.stringify([ - { - type: 3, - amount: quote.amount, - date: Math.floor(Date.now() / 1000), - }, - ...history, - ]) - ); + storage.setJson(STORAGE_KEYS.TOKENS, proofArray); + storage.setJson(STORAGE_KEYS.HISTORY, [ + { + type: 3, + amount: quote.amount, + date: Math.floor(Date.now() / 1000), + }, + ...history, + ]); await publishProofEvent( nostr, signer, diff --git a/components/utility-components/nostr-context-provider.tsx b/components/utility-components/nostr-context-provider.tsx index f826c2f14..dbb0556dc 100644 --- a/components/utility-components/nostr-context-provider.tsx +++ b/components/utility-components/nostr-context-provider.tsx @@ -12,7 +12,12 @@ import { NostrSigner, } from "@/utils/nostr/signers/nostr-signer"; import { NostrManager } from "@/utils/nostr/nostr-manager"; -import { getLocalStorageData } from "@/utils/nostr/nostr-helper-functions"; +import { + getStoredReadRelays, + getStoredRelays, + getStoredWriteRelays, + isStoredSignerDataOrUndefined, +} from "@/utils/nostr/nostr-helper-functions"; import PassphraseChallengeModal from "@/components/utility-components/request-passphrase-modal"; import AuthUrlChallengeModal from "@/components/utility-components/auth-challenge-modal"; import { NostrNIP07Signer } from "@/utils/nostr/signers/nostr-nip07-signer"; @@ -20,6 +25,7 @@ import { NostrNIP46Signer } from "@/utils/nostr/signers/nostr-nip46-signer"; import { NostrNSecSigner } from "@/utils/nostr/signers/nostr-nsec-signer"; import { needsMigration } from "@/utils/nostr/encryption-migration"; import MigrationPromptModal from "./migration-prompt-modal"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; interface SignerContextInterface { signer?: NostrSigner; @@ -125,23 +131,32 @@ export function SignerContextProvider({ children }: { children: ReactNode }) { const loadSigner = useCallback((retryCount = 0) => { let existingSigner; - const { signer, signInMethod } = getLocalStorageData(); + const signer = storage.getJson( + STORAGE_KEYS.SIGNER, + undefined, + { validate: isStoredSignerDataOrUndefined } + ); + const signInMethod = storage.getItem(STORAGE_KEYS.SIGN_IN_METHOD); if (signer) { existingSigner = signer; } else if (signInMethod) { switch (signInMethod) { case "bunker": { - let bunker = - "bunker://" + - getLocalStorageData().bunkerRemotePubkey + - "?secret=" + - getLocalStorageData().bunkerSecret; - const bunkerRelays = getLocalStorageData().bunkerRelays; - for (const relay of bunkerRelays!) { + const remotePubkey = storage.getItem( + STORAGE_KEYS.BUNKER_REMOTE_PUBKEY + ); + const secret = storage.getItem(STORAGE_KEYS.BUNKER_SECRET); + const bunkerRelays = storage.getJson( + STORAGE_KEYS.BUNKER_RELAYS, + [] + ); + + let bunker = "bunker://" + remotePubkey + "?secret=" + secret; + for (const relay of bunkerRelays) { bunker += "&relay=" + relay; } - const appPrivKey = getLocalStorageData().clientPrivkey; + const appPrivKey = storage.getItem(STORAGE_KEYS.CLIENT_PRIVKEY); existingSigner = { type: "nip46", bunker, @@ -156,7 +171,9 @@ export function SignerContextProvider({ children }: { children: ReactNode }) { break; } case "nsec": { - const encryptedPrivateKey = getLocalStorageData().encryptedPrivateKey; + const encryptedPrivateKey = storage.getItem( + STORAGE_KEYS.ENCRYPTED_PRIVATE_KEY + ); existingSigner = { type: "nsec", encryptedPrivKey: encryptedPrivateKey!, @@ -206,12 +223,12 @@ export function SignerContextProvider({ children }: { children: ReactNode }) { setSigner(signerObject); loadKeys(signerObject); - const isAlreadyLoaded = localStorage.getItem("signer"); + const isAlreadyLoaded = storage.getItem(STORAGE_KEYS.SIGNER); if ( !isAlreadyLoaded || JSON.stringify(existingSigner) !== isAlreadyLoaded ) { - localStorage.setItem("signer", JSON.stringify(existingSigner)); + storage.setJson(STORAGE_KEYS.SIGNER, existingSigner); const shouldReloadSigner = false; window.dispatchEvent( @@ -322,7 +339,9 @@ export function NostrContextProvider({ children }: { children: ReactNode }) { const [nostr] = useState(new NostrManager()); const reload = useCallback(() => { - const { readRelays, writeRelays, relays } = getLocalStorageData(); + const readRelays = getStoredReadRelays(); + const writeRelays = getStoredWriteRelays(); + const relays = getStoredRelays(); nostr.addRelays([...writeRelays, ...relays, ...readRelays]); }, [nostr]); diff --git a/components/utility-components/product-card.tsx b/components/utility-components/product-card.tsx index da5c2eaac..3d9717922 100644 --- a/components/utility-components/product-card.tsx +++ b/components/utility-components/product-card.tsx @@ -14,7 +14,7 @@ import { } from "@heroicons/react/24/outline"; import { RawEventModal, EventIdModal } from "./modals/event-modals"; import { nip19 } from "nostr-tools"; -import { getLocalStorageData } from "@/utils/nostr/nostr-helper-functions"; +import { getStoredRelays } from "@/utils/nostr/nostr-helper-functions"; import { locationAvatar } from "./dropdowns/location-dropdown"; import ImageCarousel from "./image-carousel"; import CompactPriceDisplay from "./display-monetary-info"; @@ -212,7 +212,7 @@ export default function ProductCard({ e.preventDefault(); e.stopPropagation(); try { - const { relays } = getLocalStorageData(); + const relays = getStoredRelays(); const targetRelays = relays.length > 0 ? relays.slice(0, 3) diff --git a/components/utility-components/shopstr-slider.tsx b/components/utility-components/shopstr-slider.tsx index e9610a2b6..618a63c0a 100644 --- a/components/utility-components/shopstr-slider.tsx +++ b/components/utility-components/shopstr-slider.tsx @@ -3,8 +3,8 @@ import { Button } from "@heroui/react"; import { Slider } from "@heroui/react"; import { useTheme } from "next-themes"; import { FollowsContext } from "../../utils/context/context"; -import { getLocalStorageData } from "@/utils/nostr/nostr-helper-functions"; import { SHOPSTRBUTTONCLASSNAMES } from "@/utils/STATIC-VARIABLES"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; const ShopstrSlider = () => { const { theme } = useTheme(); @@ -15,12 +15,12 @@ const ShopstrSlider = () => { const [wotIsChanged, setWotIsChanged] = useState(false); useEffect(() => { - const savedWot = getLocalStorageData().wot; + const savedWot = storage.getJson(STORAGE_KEYS.WOT, 3); setWot(savedWot); }, []); useEffect(() => { - localStorage.setItem("wot", String(wot)); + storage.setJson(STORAGE_KEYS.WOT, wot); }, [wot]); const refreshPage = () => { diff --git a/components/wallet/__tests__/mint-button.test.tsx b/components/wallet/__tests__/mint-button.test.tsx index e8892f6de..95d9b8405 100644 --- a/components/wallet/__tests__/mint-button.test.tsx +++ b/components/wallet/__tests__/mint-button.test.tsx @@ -33,9 +33,11 @@ const mockMintProofs = jest.fn(); jest.mock("@/utils/nostr/nostr-helper-functions", () => ({ getLocalStorageData: jest.fn(), + getStoredMints: jest.fn(), publishProofEvent: jest.fn(), })); const mockGetLocalStorageData = NostrHelper.getLocalStorageData as jest.Mock; +const mockGetStoredMints = NostrHelper.getStoredMints as jest.Mock; const mockPublishProofEvent = NostrHelper.publishProofEvent as jest.Mock; jest.mock("qrcode", () => ({ @@ -111,6 +113,7 @@ describe("MintButton Component", () => { beforeEach(() => { jest.useFakeTimers(); mockGetLocalStorageData.mockReturnValue(mockLocalStorage); + mockGetStoredMints.mockReturnValue(mockLocalStorage.mints); mockToDataURL.mockResolvedValue("data:image/png;base64,mock-qr-code"); mockPublishProofEvent.mockResolvedValue(undefined); Object.defineProperty(navigator, "clipboard", { diff --git a/components/wallet/__tests__/pay-button.test.tsx b/components/wallet/__tests__/pay-button.test.tsx index 1e0f5ed4a..2c08f389f 100644 --- a/components/wallet/__tests__/pay-button.test.tsx +++ b/components/wallet/__tests__/pay-button.test.tsx @@ -13,6 +13,7 @@ import { NostrContext, } from "@/components/utility-components/nostr-context-provider"; import { + getStoredMints, getLocalStorageData, publishProofEvent, } from "@/utils/nostr/nostr-helper-functions"; @@ -23,6 +24,7 @@ jest.mock("next-themes", () => ({ })); jest.mock("@/utils/nostr/nostr-helper-functions", () => ({ + getStoredMints: jest.fn(), getLocalStorageData: jest.fn(), publishProofEvent: jest.fn(), })); @@ -127,6 +129,9 @@ describe("PayButton Component", () => { tokens: JSON.parse(localStorageMock.getItem("tokens") || "[]"), history: JSON.parse(localStorageMock.getItem("history") || "[]"), })); + (getStoredMints as jest.Mock).mockReturnValue([ + "https://legend.lnbits.com/cashu/api/v1/4gr9XkQ8ez543F4L6f5UqA", + ]); }); test("renders the pay button initially", () => { diff --git a/components/wallet/__tests__/receive-button.test.tsx b/components/wallet/__tests__/receive-button.test.tsx index 31bad4856..ccfabc8a7 100644 --- a/components/wallet/__tests__/receive-button.test.tsx +++ b/components/wallet/__tests__/receive-button.test.tsx @@ -20,7 +20,6 @@ import { Wallet as CashuWallet, } from "@cashu/cashu-ts"; import { - getLocalStorageData, publishProofEvent, publishWalletEvent, } from "@/utils/nostr/nostr-helper-functions"; @@ -33,7 +32,7 @@ import { jest.setTimeout(15000); jest.mock("@/utils/nostr/nostr-helper-functions", () => ({ - getLocalStorageData: jest.fn(), + ...jest.requireActual("@/utils/nostr/nostr-helper-functions"), publishProofEvent: jest.fn(), publishWalletEvent: jest.fn(), })); @@ -142,7 +141,7 @@ jest.mock("@heroicons/react/24/outline", () => ({ InformationCircleIcon: () =>
, })); -const mockGetLocalStorageData = getLocalStorageData as jest.Mock; +// No more mocks needed for getLocalStorageData as we use StorageManager now const mockGetDecodedToken = getDecodedToken as jest.Mock; const mockGetTokenMetadata = getTokenMetadata as jest.Mock; const mockPublishProofEvent = publishProofEvent as jest.Mock; @@ -202,12 +201,8 @@ const VALID_TOKEN = describe("ReceiveButton", () => { beforeEach(() => { jest.clearAllMocks(); - Storage.prototype.setItem = jest.fn(); - mockGetLocalStorageData.mockReturnValue({ - mints: [], - tokens: [], - history: [], - }); + window.localStorage.clear(); + jest.spyOn(Storage.prototype, "setItem"); mockPublishProofEvent.mockResolvedValue(undefined); mockPublishWalletEvent.mockResolvedValue(undefined); mockParseP2PKProofSet.mockReturnValue({ p2pk: null }); @@ -475,11 +470,9 @@ describe("ReceiveButton", () => { secret: "secret", C: "C1", }; - mockGetLocalStorageData.mockReturnValue({ - mints: [], - tokens: [mockProof], - history: [], - }); + window.localStorage.setItem("tokens", JSON.stringify([mockProof])); + window.localStorage.setItem("mints", JSON.stringify([])); + window.localStorage.setItem("history", JSON.stringify([])); mockGetDecodedToken.mockReturnValue({ mint: "https://testmint.com", proofs: [mockProof], diff --git a/components/wallet/__tests__/send-button.test.tsx b/components/wallet/__tests__/send-button.test.tsx index 21a554807..5850c00a7 100644 --- a/components/wallet/__tests__/send-button.test.tsx +++ b/components/wallet/__tests__/send-button.test.tsx @@ -10,6 +10,7 @@ import { import { CashuWalletContext } from "@/utils/context/context"; import { Wallet as CashuWallet, getEncodedToken } from "@cashu/cashu-ts"; import { + getStoredMints, getLocalStorageData, publishProofEvent, } from "@/utils/nostr/nostr-helper-functions"; @@ -50,6 +51,7 @@ jest.mock("@heroicons/react/24/outline", () => ({ })); const mockGetLocalStorageData = getLocalStorageData as jest.Mock; +const mockGetStoredMints = getStoredMints as jest.Mock; const mockPublishProofEvent = publishProofEvent as jest.Mock; const mockGetEncodedToken = getEncodedToken as jest.Mock; const MockCashuWallet = CashuWallet as jest.Mock; @@ -118,12 +120,24 @@ describe("SendButton", () => { })); setItemSpy = jest.spyOn(Storage.prototype, "setItem"); + localStorage.setItem( + "mints", + JSON.stringify(["https://legend.lnbits.com/cashu/api/v1/4_sadf7asdf78"]) + ); + localStorage.setItem( + "tokens", + JSON.stringify([{ id: "keyset_id_1", amount: 1000, C: "C1" }]) + ); + mockPublishProofEvent.mockResolvedValue(undefined); mockGetLocalStorageData.mockReturnValue({ mints: ["https://legend.lnbits.com/cashu/api/v1/4_sadf7asdf78"], tokens: [{ id: "keyset_id_1", amount: 1000, C: "C1" }], history: [], }); + mockGetStoredMints.mockReturnValue([ + "https://legend.lnbits.com/cashu/api/v1/4_sadf7asdf78", + ]); Object.defineProperty(navigator, "clipboard", { value: { writeText: jest.fn().mockResolvedValue(undefined) }, writable: true, @@ -277,6 +291,14 @@ describe("SendButton", () => { }); test("handles tokens with different keyset IDs", async () => { + localStorage.setItem( + "tokens", + JSON.stringify([ + { id: "keyset_id_1", amount: 500, C: "C1" }, + { id: "keyset_id_2", amount: 300, C: "C2" }, + { id: "keyset_id_3", amount: 200, C: "C3" }, + ]) + ); mockGetLocalStorageData.mockReturnValue({ mints: ["https://legend.lnbits.com/cashu/api/v1/4_sadf7asdf78"], tokens: [ diff --git a/components/wallet/__tests__/transactions.test.tsx b/components/wallet/__tests__/transactions.test.tsx index 8a8b9d129..654fc632e 100644 --- a/components/wallet/__tests__/transactions.test.tsx +++ b/components/wallet/__tests__/transactions.test.tsx @@ -1,13 +1,8 @@ import { render, screen, act, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; import Transactions from "../transactions"; -import { getLocalStorageData } from "@/utils/nostr/nostr-helper-functions"; import { Transaction } from "@/utils/types/types"; -jest.mock("@/utils/nostr/nostr-helper-functions", () => ({ - getLocalStorageData: jest.fn(() => ({ history: [] })), -})); - jest.mock("@heroicons/react/24/outline", () => ({ ArrowDownTrayIcon: () =>
, ArrowUpTrayIcon: () =>
, @@ -16,11 +11,12 @@ jest.mock("@heroicons/react/24/outline", () => ({ ShoppingBagIcon: () =>
, })); -const mockedGetLocalStorageData = getLocalStorageData as jest.Mock; +// No more mocks needed for getLocalStorageData as we use StorageManager now describe("Transactions", () => { beforeEach(() => { jest.useFakeTimers(); + window.localStorage.clear(); }); afterEach(() => { @@ -41,7 +37,7 @@ describe("Transactions", () => { { type: 1, amount: 1000, date: 1721915400 }, // Deposit { type: 2, amount: 500, date: 1721915500 }, // Withdraw ]; - mockedGetLocalStorageData.mockReturnValue({ history: mockHistory }); + window.localStorage.setItem("history", JSON.stringify(mockHistory)); render(); @@ -59,7 +55,7 @@ describe("Transactions", () => { { type: 4, amount: 100, date: 1721915400 }, // Lightning { type: 5, amount: 100, date: 1721915400 }, // Purchase ]; - mockedGetLocalStorageData.mockReturnValue({ history: mockHistory }); + window.localStorage.setItem("history", JSON.stringify(mockHistory)); render(); @@ -74,7 +70,7 @@ describe("Transactions", () => { const initialHistory: Transaction[] = [ { type: 1, amount: 100, date: 1721915400 }, ]; - mockedGetLocalStorageData.mockReturnValue({ history: initialHistory }); + window.localStorage.setItem("history", JSON.stringify(initialHistory)); render(); expect(screen.getByText("100 sats")).toBeInTheDocument(); @@ -84,7 +80,7 @@ describe("Transactions", () => { ...initialHistory, { type: 2, amount: 200, date: 1721915500 }, ]; - mockedGetLocalStorageData.mockReturnValue({ history: updatedHistory }); + window.localStorage.setItem("history", JSON.stringify(updatedHistory)); act(() => { jest.advanceTimersByTime(2100); @@ -93,7 +89,6 @@ describe("Transactions", () => { await waitFor(() => { expect(screen.getByText("200 sats")).toBeInTheDocument(); }); - expect(mockedGetLocalStorageData).toHaveBeenCalledTimes(2); }); it("should clean up the interval on component unmount", () => { diff --git a/components/wallet/mint-button.tsx b/components/wallet/mint-button.tsx index 683c5f8fd..eb3f4e578 100644 --- a/components/wallet/mint-button.tsx +++ b/components/wallet/mint-button.tsx @@ -49,6 +49,7 @@ import { recordPendingMintQuote, updatePendingMintQuote, } from "@/utils/cashu/pending-mint-operations"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; const MintButton = () => { const [showMintModal, setShowMintModal] = useState(false); @@ -219,18 +220,15 @@ const MintButton = () => { ...(currentTokens as Proof[]), ...proofs, ]); - localStorage.setItem("tokens", JSON.stringify(proofArray)); - localStorage.setItem( - "history", - JSON.stringify([ - { - type: 3, - amount: invoiceAmount, - date: Math.floor(Date.now() / 1000), - }, - ...currentHistory, - ]) - ); + storage.setJson(STORAGE_KEYS.TOKENS, proofArray); + storage.setJson(STORAGE_KEYS.HISTORY, [ + { + type: 3, + amount: invoiceAmount, + date: Math.floor(Date.now() / 1000), + }, + ...currentHistory, + ]); await publishProofEvent( nostr!, signer!, diff --git a/components/wallet/pay-button.tsx b/components/wallet/pay-button.tsx index c16c2046d..0c72a0f3d 100644 --- a/components/wallet/pay-button.tsx +++ b/components/wallet/pay-button.tsx @@ -38,6 +38,7 @@ import { SignerContext, } from "@/components/utility-components/nostr-context-provider"; import { NostrNIP46Signer } from "@/utils/nostr/signers/nostr-nip46-signer"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; const PayButton = () => { const [showPayModal, setShowPayModal] = useState(false); @@ -170,7 +171,7 @@ const PayButton = () => { ) || !send.some((s) => s.secret === p.secret) ) as Proof[]; const quarantineProofArray = [...remainingProofsAfterMelt, ...keep]; - localStorage.setItem("tokens", JSON.stringify(quarantineProofArray)); + storage.setJson(STORAGE_KEYS.TOKENS, quarantineProofArray); throw new Error(meltOutcome.errorMessage ?? "Melt outcome ambiguous"); } const changeProofs = [...keep, ...meltOutcome.changeProofs]; @@ -188,20 +189,17 @@ const PayButton = () => { } else { proofArray = [...remainingProofs]; } - localStorage.setItem("tokens", JSON.stringify(proofArray)); + storage.setJson(STORAGE_KEYS.TOKENS, proofArray); const filteredTokenAmount = sumProofAmounts(filteredProofs); const transactionAmount = filteredTokenAmount - changeAmount; - localStorage.setItem( - "history", - JSON.stringify([ - { - type: 4, - amount: transactionAmount, - date: Math.floor(Date.now() / 1000), - }, - ...history, - ]) - ); + storage.setJson(STORAGE_KEYS.HISTORY, [ + { + type: 4, + amount: transactionAmount, + date: Math.floor(Date.now() / 1000), + }, + ...history, + ]); await publishProofEvent( nostr!, signer!, diff --git a/components/wallet/receive-button.tsx b/components/wallet/receive-button.tsx index 5715f34f0..13a500688 100644 --- a/components/wallet/receive-button.tsx +++ b/components/wallet/receive-button.tsx @@ -38,6 +38,7 @@ import { checkMintP2pkSupport, parseP2PKProofSet, } from "@/utils/cashu/p2pk-checkout"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; const ReceiveButton = () => { const [showReceiveModal, setShowReceiveModal] = useState(false); @@ -85,10 +86,10 @@ const ReceiveButton = () => { } const tokenArray = [...tokens, ...uniqueProofs]; - localStorage.setItem("tokens", JSON.stringify(tokenArray)); + storage.setJson(STORAGE_KEYS.TOKENS, tokenArray); if (!mints.includes(tokenMint)) { const updatedMints = [...mints, tokenMint]; - localStorage.setItem("mints", JSON.stringify(updatedMints)); + storage.setJson(STORAGE_KEYS.MINTS, updatedMints); if (cashuPrivkey) { await publishWalletEvent( nostr!, @@ -100,17 +101,14 @@ const ReceiveButton = () => { } setIsClaimed(true); handleToggleReceiveModal(); - localStorage.setItem( - "history", - JSON.stringify([ - { - type: 1, - amount: transactionAmount, - date: Math.floor(Date.now() / 1000), - }, - ...history, - ]) - ); + storage.setJson(STORAGE_KEYS.HISTORY, [ + { + type: 1, + amount: transactionAmount, + date: Math.floor(Date.now() / 1000), + }, + ...history, + ]); await publishProofEvent( nostr!, signer!, diff --git a/components/wallet/send-button.tsx b/components/wallet/send-button.tsx index 3a80fa29c..3a1ff5157 100644 --- a/components/wallet/send-button.tsx +++ b/components/wallet/send-button.tsx @@ -23,9 +23,10 @@ import { } from "@heroui/react"; import { SHOPSTRBUTTONCLASSNAMES } from "@/utils/STATIC-VARIABLES"; import { - getLocalStorageData, + getStoredMints, publishProofEvent, } from "@/utils/nostr/nostr-helper-functions"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; import { Mint as CashuMint, Wallet as CashuWallet, @@ -53,7 +54,9 @@ const SendButton = () => { const { signer } = useContext(SignerContext); const { nostr } = useContext(NostrContext); - const { mints, tokens, history } = getLocalStorageData(); + const mints = getStoredMints(); + const tokens = storage.getJson(STORAGE_KEYS.TOKENS, []); + const history = storage.getJson(STORAGE_KEYS.HISTORY, []); const { handleSubmit: handleSendSubmit, @@ -147,14 +150,11 @@ const SendButton = () => { } else { proofArray = [...remainingProofs]; } - localStorage.setItem("tokens", JSON.stringify(proofArray)); - localStorage.setItem( - "history", - JSON.stringify([ - { type: 2, amount: numSats, date: Math.floor(Date.now() / 1000) }, - ...history, - ]) - ); + storage.setJson(STORAGE_KEYS.TOKENS, proofArray); + storage.setJson(STORAGE_KEYS.HISTORY, [ + { type: 2, amount: numSats, date: Math.floor(Date.now() / 1000) }, + ...history, + ]); await publishProofEvent( nostr!, signer!, diff --git a/components/wallet/transactions.tsx b/components/wallet/transactions.tsx index 766cd70a6..db3ad5cd2 100644 --- a/components/wallet/transactions.tsx +++ b/components/wallet/transactions.tsx @@ -6,7 +6,7 @@ import { BoltIcon, ShoppingBagIcon, } from "@heroicons/react/24/outline"; -import { getLocalStorageData } from "@/utils/nostr/nostr-helper-functions"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; import { Transaction } from "@/utils/types/types"; // add found proofs as nutsack deposit with different icon @@ -17,10 +17,8 @@ const Transactions = () => { useEffect(() => { // Function to fetch and update transactions const fetchAndUpdateTransactions = () => { - const localData = getLocalStorageData(); - if (localData && localData.history) { - setHistory(localData.history); - } + const history = storage.getJson(STORAGE_KEYS.HISTORY, []); + setHistory(history); }; // Initial fetch fetchAndUpdateTransactions(); diff --git a/pages/_app.tsx b/pages/_app.tsx index 3153383a2..d39e0a4d2 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -79,6 +79,7 @@ import { applyOptimisticFollow, applyOptimisticUnfollow, } from "@/utils/nostr/follow-state"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; const mergeReportEvents = ( existingReports: NostrEvent[], @@ -801,7 +802,7 @@ function Shopstr({ props }: { props: AppProps }) { if (allRelays.length === 0) { allRelays = getDefaultRelays(); - localStorage.setItem("relays", JSON.stringify(allRelays)); + storage.setJson(STORAGE_KEYS.RELAYS, allRelays); } // Fire them first and in parellel since independent of each other and other depend on it @@ -826,14 +827,11 @@ function Shopstr({ props }: { props: AppProps }) { if (!isCurrentRun()) return; if (relayResult && relayResult.relayList.length !== 0) { - localStorage.setItem("relays", JSON.stringify(relayResult.relayList)); - localStorage.setItem( - "readRelays", - JSON.stringify(relayResult.readRelayList) - ); - localStorage.setItem( - "writeRelays", - JSON.stringify(relayResult.writeRelayList) + storage.setJson(STORAGE_KEYS.RELAYS, relayResult.relayList); + storage.setJson(STORAGE_KEYS.READ_RELAYS, relayResult.readRelayList); + storage.setJson( + STORAGE_KEYS.WRITE_RELAYS, + relayResult.writeRelayList ); allRelays = [...relayResult.relayList, ...relayResult.readRelayList]; } @@ -1038,9 +1036,9 @@ function Shopstr({ props }: { props: AppProps }) { if (!isCurrentRun()) return; if (blossomResult?.blossomServers?.length) { - localStorage.setItem( - "blossomServers", - JSON.stringify(blossomResult.blossomServers) + storage.setJson( + STORAGE_KEYS.BLOSSOM_SERVERS, + blossomResult.blossomServers ); } @@ -1051,11 +1049,8 @@ function Shopstr({ props }: { props: AppProps }) { ...walletResult.cashuProofs, ]); - localStorage.setItem( - "mints", - JSON.stringify(walletResult.cashuMints) - ); - localStorage.setItem("tokens", JSON.stringify(mergedProofs)); + storage.setJson(STORAGE_KEYS.MINTS, walletResult.cashuMints); + storage.setJson(STORAGE_KEYS.TOKENS, mergedProofs); } await runTask("retrying relay publishes", async () => { diff --git a/pages/cart/index.tsx b/pages/cart/index.tsx index 4a8303dbb..4ea3d5ab9 100644 --- a/pages/cart/index.tsx +++ b/pages/cart/index.tsx @@ -28,7 +28,7 @@ import { ShopMapContext, ProfileMapContext } from "@/utils/context/context"; import { nip19 } from "nostr-tools"; import StorefrontThemeWrapper from "@/components/storefront/storefront-theme-wrapper"; import ProtectedRoute from "@/components/utility-components/protected-route"; -import { getLocalStorageJson } from "@/utils/safe-json"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; import { CartDiscountsMap, isCartDiscountsMap } from "@/utils/cart-discounts"; import { isSellerP2pkEscrowActive } from "@/utils/cashu/p2pk-checkout"; import { mapWithConcurrency } from "@/utils/concurrency"; @@ -212,31 +212,25 @@ export default function Component() { useEffect(() => { const stored = - sessionStorage.getItem("sf_seller_pubkey") || - localStorage.getItem("sf_seller_pubkey"); + storage.getSessionItem(STORAGE_KEYS.SF_SELLER_PUBKEY) || + storage.getItem(STORAGE_KEYS.SF_SELLER_PUBKEY); if (stored) setSfSellerPubkey(stored); const storedSlug = - sessionStorage.getItem("sf_shop_slug") || - localStorage.getItem("sf_shop_slug"); + storage.getSessionItem(STORAGE_KEYS.SF_SHOP_SLUG) || + storage.getItem(STORAGE_KEYS.SF_SHOP_SLUG); if (storedSlug) setSfShopSlug(storedSlug); }, []); useEffect(() => { let isCancelled = false; - const loadCart = async () => { - if (typeof window === "undefined") { - return; - } + if (typeof window === "undefined") return; const sfPk = - sessionStorage.getItem("sf_seller_pubkey") || - localStorage.getItem("sf_seller_pubkey") || + storage.getSessionItem(STORAGE_KEYS.SF_SELLER_PUBKEY) || + storage.getItem(STORAGE_KEYS.SF_SELLER_PUBKEY) || ""; - const fullCart = getLocalStorageJson("cart", [], { - removeOnError: true, - validate: Array.isArray, - }); + const fullCart = storage.getJson(STORAGE_KEYS.CART, []); let cartList = fullCart; if (sfPk) { @@ -259,19 +253,16 @@ export default function Component() { } } - if (cartList.length === 0) { - return; - } + if (cartList.length === 0) return; - const discounts = getLocalStorageJson( - "cartDiscounts", - {}, - { - removeOnError: true, - removeOnValidationError: true, - validate: isCartDiscountsMap, - } + const discounts = storage.getJson( + STORAGE_KEYS.CART_DISCOUNTS, + {} ); + if (!isCartDiscountsMap(discounts)) { + storage.removeItem(STORAGE_KEYS.CART_DISCOUNTS); + return; + } if (Object.keys(discounts).length === 0) { return; @@ -346,12 +337,9 @@ export default function Component() { setIsValidatingDiscounts(false); if (Object.keys(refreshedDiscounts).length > 0) { - localStorage.setItem( - "cartDiscounts", - JSON.stringify(refreshedDiscounts) - ); + storage.setJson(STORAGE_KEYS.CART_DISCOUNTS, refreshedDiscounts); } else { - localStorage.removeItem("cartDiscounts"); + storage.removeItem(STORAGE_KEYS.CART_DISCOUNTS); } }; @@ -449,16 +437,13 @@ export default function Component() { }; const handleRemoveFromCart = (productId: string) => { - const cartContent = getLocalStorageJson("cart", [], { - removeOnError: true, - validate: Array.isArray, - }); + const cartContent = storage.getJson(STORAGE_KEYS.CART, []); if (cartContent.length > 0) { const updatedCart = cartContent.filter( (obj: ProductData) => obj.id !== productId ); setProducts(updatedCart); - localStorage.setItem("cart", JSON.stringify(updatedCart)); + storage.setJson(STORAGE_KEYS.CART, updatedCart); } }; @@ -496,20 +481,15 @@ export default function Component() { }); setDiscountErrors({ ...discountErrors, [pubkey]: "" }); - // Save to localStorage - const discounts = getLocalStorageJson( - "cartDiscounts", - {}, - { - removeOnError: true, - removeOnValidationError: true, - validate: isCartDiscountsMap, - } + // Save to storage + const discounts = storage.getJson( + STORAGE_KEYS.CART_DISCOUNTS, + {} ); discounts[pubkey] = { code: code, }; - localStorage.setItem("cartDiscounts", JSON.stringify(discounts)); + storage.setJson(STORAGE_KEYS.CART_DISCOUNTS, discounts); } else { setDiscountErrors({ ...discountErrors, @@ -532,19 +512,14 @@ export default function Component() { setAppliedDiscounts({ ...appliedDiscounts, [pubkey]: 0 }); setDiscountErrors({ ...discountErrors, [pubkey]: "" }); - // Remove from localStorage - const discounts = getLocalStorageJson( - "cartDiscounts", - {}, - { - removeOnError: true, - removeOnValidationError: true, - validate: isCartDiscountsMap, - } + // Remove from storage + const discounts = storage.getJson( + STORAGE_KEYS.CART_DISCOUNTS, + {} ); if (Object.keys(discounts).length > 0) { delete discounts[pubkey]; - localStorage.setItem("cartDiscounts", JSON.stringify(discounts)); + storage.setJson(STORAGE_KEYS.CART_DISCOUNTS, discounts); } }; diff --git a/pages/listing/[[...productId]].tsx b/pages/listing/[[...productId]].tsx index 0bd289781..3a3c4cef2 100644 --- a/pages/listing/[[...productId]].tsx +++ b/pages/listing/[[...productId]].tsx @@ -44,6 +44,7 @@ import SignInModal from "@/components/sign-in/SignInModal"; import useReportEventFlow from "@/components/utility-components/use-report-event-flow"; import ShopstrSpinner from "@/components/utility-components/shopstr-spinner"; import { useFollowToggle } from "@/components/hooks/use-follow-toggle"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; type ListingPageProps = { ogMeta: OgMetaProps; @@ -258,12 +259,10 @@ const Listing = ({ initialProductEvent }: ListingPageProps) => { } = useFollowToggle(sellerPubkey, { onRequireSignIn: onOpen }); useEffect(() => { - if (typeof window !== "undefined") { - const pk = - sessionStorage.getItem("sf_seller_pubkey") || - localStorage.getItem("sf_seller_pubkey"); - if (pk) setSfSellerPubkey(pk); - } + const pk = + storage.getSessionItem(STORAGE_KEYS.SF_SELLER_PUBKEY) || + storage.getItem(STORAGE_KEYS.SF_SELLER_PUBKEY); + if (pk) setSfSellerPubkey(pk); }, []); useEffect(() => { @@ -324,10 +323,10 @@ const Listing = ({ initialProductEvent }: ListingPageProps) => { if (matchingEvent) { if (sfSellerPubkey && matchingEvent.pubkey !== sfSellerPubkey) { setSfSellerPubkey(""); - sessionStorage.removeItem("sf_seller_pubkey"); - sessionStorage.removeItem("sf_shop_slug"); - localStorage.removeItem("sf_seller_pubkey"); - localStorage.removeItem("sf_shop_slug"); + storage.removeSessionItem(STORAGE_KEYS.SF_SELLER_PUBKEY); + storage.removeSessionItem(STORAGE_KEYS.SF_SHOP_SLUG); + storage.removeItem(STORAGE_KEYS.SF_SELLER_PUBKEY); + storage.removeItem(STORAGE_KEYS.SF_SHOP_SLUG); } const resolvedListing = resolveListingStateFromEvent(matchingEvent); if (resolvedListing) { diff --git a/pages/marketplace/[[...npub]].tsx b/pages/marketplace/[[...npub]].tsx index 6d539c535..af248aed5 100644 --- a/pages/marketplace/[[...npub]].tsx +++ b/pages/marketplace/[[...npub]].tsx @@ -10,6 +10,7 @@ import { fetchProfilePubkeyByNameSlug, } from "@/utils/db/db-service"; import { NostrEvent } from "@/utils/types/types"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; type MarketplacePageProps = { ogMeta: OgMetaProps; @@ -94,12 +95,10 @@ export default function SellerView({ setSelectedSection, }: MarketplacePageProps) { useEffect(() => { - if (typeof window !== "undefined") { - sessionStorage.removeItem("sf_seller_pubkey"); - sessionStorage.removeItem("sf_shop_slug"); - localStorage.removeItem("sf_seller_pubkey"); - localStorage.removeItem("sf_shop_slug"); - } + storage.removeItem(STORAGE_KEYS.SF_SELLER_PUBKEY); + storage.removeItem(STORAGE_KEYS.SF_SHOP_SLUG); + storage.removeSessionItem(STORAGE_KEYS.SF_SELLER_PUBKEY); + storage.removeSessionItem(STORAGE_KEYS.SF_SHOP_SLUG); }, []); return ( diff --git a/pages/onboarding/wallet.tsx b/pages/onboarding/wallet.tsx index b62d1d254..bd1cca8e4 100644 --- a/pages/onboarding/wallet.tsx +++ b/pages/onboarding/wallet.tsx @@ -9,6 +9,7 @@ import { import { SHOPSTRBUTTONCLASSNAMES } from "@/utils/STATIC-VARIABLES"; import { saveNWCString } from "@/utils/nostr/nostr-helper-functions"; import { NostrWebLNProvider } from "@getalby/sdk"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; const OnboardingWallet = () => { const router = useRouter(); @@ -42,7 +43,7 @@ const OnboardingWallet = () => { const info = await nwc.getInfo(); saveNWCString(nwcString); - localStorage.setItem("nwcInfo", JSON.stringify(info)); + storage.setJson(STORAGE_KEYS.NWC_INFO, info); handleNext(); } catch (e: any) { diff --git a/pages/order-summary/index.tsx b/pages/order-summary/index.tsx index 410a8c058..81c0ccc23 100644 --- a/pages/order-summary/index.tsx +++ b/pages/order-summary/index.tsx @@ -16,6 +16,7 @@ import parseTags, { import ProductCard from "@/components/utility-components/product-card"; import { SHOPSTRBUTTONCLASSNAMES } from "@/utils/STATIC-VARIABLES"; import ProtectedRoute from "@/components/utility-components/protected-route"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; interface OrderSummaryData { productTitle: string; @@ -62,12 +63,12 @@ export default function OrderSummary() { useEffect(() => { if (typeof window !== "undefined") { const pk = - sessionStorage.getItem("sf_seller_pubkey") || - localStorage.getItem("sf_seller_pubkey"); + storage.getSessionItem(STORAGE_KEYS.SF_SELLER_PUBKEY) || + storage.getItem(STORAGE_KEYS.SF_SELLER_PUBKEY); if (pk) setSfSellerPubkey(pk); const slug = - sessionStorage.getItem("sf_shop_slug") || - localStorage.getItem("sf_shop_slug"); + storage.getSessionItem(STORAGE_KEYS.SF_SHOP_SLUG) || + storage.getItem(STORAGE_KEYS.SF_SHOP_SLUG); if (slug) setSfShopSlug(slug); } }, []); @@ -80,13 +81,15 @@ export default function OrderSummary() { const hasConsumedOrderRef = useRef(false); useEffect(() => { if (hasConsumedOrderRef.current) return; - const stored = sessionStorage.getItem("orderSummary"); - if (stored) { + const data = storage.getSessionJson( + STORAGE_KEYS.ORDER_SUMMARY, + null + ); + if (data) { hasConsumedOrderRef.current = true; try { - const data = JSON.parse(stored); setOrderData(data); - sessionStorage.removeItem("orderSummary"); + storage.removeSessionItem(STORAGE_KEYS.ORDER_SUMMARY); if (data.sellerPubkey && !sfSellerPubkey) { setSfSellerPubkey(data.sellerPubkey); } diff --git a/pages/settings/nostr-wallet-connect.tsx b/pages/settings/nostr-wallet-connect.tsx index 3d270395d..90c6bc049 100644 --- a/pages/settings/nostr-wallet-connect.tsx +++ b/pages/settings/nostr-wallet-connect.tsx @@ -8,10 +8,8 @@ import { Spinner, } from "@heroui/react"; import { SettingsBreadCrumbs } from "@/components/settings/settings-bread-crumbs"; -import { - getLocalStorageData, - saveNWCString, -} from "@/utils/nostr/nostr-helper-functions"; +import { saveNWCString } from "@/utils/nostr/nostr-helper-functions"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; import { SHOPSTRBUTTONCLASSNAMES } from "@/utils/STATIC-VARIABLES"; import { CheckCircleIcon, @@ -33,8 +31,9 @@ const NWCSettingsPage = () => { // Load existing connection and info on mount useEffect(() => { const loadInfo = () => { - const { nwcString: savedString, nwcInfo: savedInfo } = - getLocalStorageData(); + const savedString = storage.getItem(STORAGE_KEYS.NWC_STRING); + const savedInfo = storage.getItem(STORAGE_KEYS.NWC_INFO); + if (savedString) { setNwcString(savedString); } @@ -42,7 +41,7 @@ const NWCSettingsPage = () => { try { const info = JSON.parse(savedInfo); setWalletInfo(info); - if (info.methods.includes("get_balance") && savedString) { + if (info.methods?.includes("get_balance") && savedString) { fetchBalance(savedString); } } catch (e) { @@ -108,7 +107,7 @@ const NWCSettingsPage = () => { // Save successful connection saveNWCString(nwcString); - localStorage.setItem("nwcInfo", JSON.stringify(info)); + storage.setJson(STORAGE_KEYS.NWC_INFO, info); setWalletInfo(info); setIsSaved(true); @@ -125,7 +124,7 @@ const NWCSettingsPage = () => { }` ); saveNWCString(""); - localStorage.removeItem("nwcInfo"); + storage.removeItem(STORAGE_KEYS.NWC_INFO); } finally { setIsLoading(false); if (nwc) { @@ -140,7 +139,7 @@ const NWCSettingsPage = () => { setBalance(null); setError(null); saveNWCString(""); - localStorage.removeItem("nwcInfo"); + storage.removeItem(STORAGE_KEYS.NWC_INFO); }; return ( diff --git a/pages/settings/preferences.tsx b/pages/settings/preferences.tsx index b1630b414..2042a0069 100644 --- a/pages/settings/preferences.tsx +++ b/pages/settings/preferences.tsx @@ -22,12 +22,17 @@ import { SHOPSTRBUTTONCLASSNAMES } from "@/utils/STATIC-VARIABLES"; import { createBlossomServerEvent, createNostrRelayEvent, - getLocalStorageData, + getStoredBlossomServers, + getStoredMints, + getStoredReadRelays, + getStoredRelays, + getStoredWriteRelays, publishWalletEvent, saveAddress, deleteAddress, getSavedAddresses, } from "@/utils/nostr/nostr-helper-functions"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; import { useTheme } from "next-themes"; import { SettingsBreadCrumbs } from "@/components/settings/settings-bread-crumbs"; import ShopstrSlider from "../../components/utility-components/shopstr-slider"; @@ -76,11 +81,11 @@ const PreferencesPage = () => { useEffect(() => { if (typeof window !== "undefined") { - setMints(getLocalStorageData().mints); - setRelays(getLocalStorageData().relays); - setReadRelays(getLocalStorageData().readRelays); - setWriteRelays(getLocalStorageData().writeRelays); - setBlossomServers(getLocalStorageData().blossomServers); + setMints(getStoredMints()); + setRelays(getStoredRelays()); + setReadRelays(getStoredReadRelays()); + setWriteRelays(getStoredWriteRelays()); + setBlossomServers(getStoredBlossomServers()); loadSavedAddresses(); } setIsLoaded(true); @@ -125,14 +130,14 @@ const PreferencesPage = () => { }, []); useEffect(() => { - if (mints.length != 0) { - localStorage.setItem("mints", JSON.stringify(mints)); + if (mints.length !== 0) { + storage.setJson(STORAGE_KEYS.MINTS, mints); } }, [mints]); useEffect(() => { - if (blossomServers.length != 0) { - localStorage.setItem("blossomServers", JSON.stringify(blossomServers)); + if (blossomServers.length !== 0) { + storage.setJson(STORAGE_KEYS.BLOSSOM_SERVERS, blossomServers); } }, [blossomServers]); @@ -211,14 +216,14 @@ const PreferencesPage = () => { }; useEffect(() => { - if (relays.length != 0) { - localStorage.setItem("relays", JSON.stringify(relays)); + if (relays.length !== 0) { + storage.setJson(STORAGE_KEYS.RELAYS, relays); } - if (readRelays.length != 0) { - localStorage.setItem("readRelays", JSON.stringify(readRelays)); + if (readRelays.length !== 0) { + storage.setJson(STORAGE_KEYS.READ_RELAYS, readRelays); } - if (writeRelays.length != 0) { - localStorage.setItem("writeRelays", JSON.stringify(writeRelays)); + if (writeRelays.length !== 0) { + storage.setJson(STORAGE_KEYS.WRITE_RELAYS, writeRelays); } }, [relays, readRelays, writeRelays]); @@ -1103,10 +1108,12 @@ const PreferencesPage = () => { label="Select your prefered theme:" orientation={"horizontal"} defaultValue={ - (localStorage.getItem("theme") as string) || theme || "system" + (storage.getItem(STORAGE_KEYS.THEME) as string) || + theme || + "system" } onChange={(e) => { - localStorage.setItem("theme", e.target.value); + storage.setItem(STORAGE_KEYS.THEME, e.target.value); setTheme(e.target.value); }} > diff --git a/pages/settings/user-profile.tsx b/pages/settings/user-profile.tsx index b512d889c..af21fceb2 100644 --- a/pages/settings/user-profile.tsx +++ b/pages/settings/user-profile.tsx @@ -29,11 +29,11 @@ import { NostrNSecSigner } from "@/utils/nostr/signers/nostr-nsec-signer"; import { createNostrProfileEvent, getLocalUserProfileKey, - parseLocalProfileFallback, isProfileContentPopulated, } from "@/utils/nostr/nostr-helper-functions"; import { FileUploaderButton } from "@/components/utility-components/file-uploader"; import ShopstrSpinner from "@/components/utility-components/shopstr-spinner"; +import { storage } from "@/utils/storage"; import ProtectedRoute from "@/components/utility-components/protected-route"; import { normalizeCashuPubkey, @@ -132,8 +132,9 @@ const UserProfilePage = () => { useEffect(() => { if (!userPubkey) return; - const localFallback = parseLocalProfileFallback( - localStorage.getItem(getLocalUserProfileKey(userPubkey)) + const localFallback = storage.getJson( + getLocalUserProfileKey(userPubkey), + null ); const profileMap = profileContext.profileData; @@ -155,17 +156,14 @@ const UserProfilePage = () => { } try { - localStorage.setItem( - getLocalUserProfileKey(userPubkey), - JSON.stringify({ - content: shouldUseLocalFallback - ? localFallback!.content - : profile.content, - updatedAt: shouldUseLocalFallback - ? localFallback!.updatedAt - : profileCreatedAt, - }) - ); + storage.setJson(getLocalUserProfileKey(userPubkey), { + content: shouldUseLocalFallback + ? localFallback!.content + : profile.content, + updatedAt: shouldUseLocalFallback + ? localFallback!.updatedAt + : profileCreatedAt, + }); } catch (error) { console.error("Failed to persist profile fallback locally:", error); } @@ -278,13 +276,10 @@ const UserProfilePage = () => { } try { - localStorage.setItem( - getLocalUserProfileKey(userPubkey), - JSON.stringify({ - content: updatedData, - updatedAt: Math.floor(Date.now() / 1000), - }) - ); + storage.setJson(getLocalUserProfileKey(userPubkey), { + content: updatedData, + updatedAt: Math.floor(Date.now() / 1000), + }); } catch (error) { console.error("Failed to save local profile fallback:", error); } diff --git a/utils/__tests__/safe-json.test.ts b/utils/__tests__/safe-json.test.ts index 2dee80f42..ca58e183b 100644 --- a/utils/__tests__/safe-json.test.ts +++ b/utils/__tests__/safe-json.test.ts @@ -1,4 +1,4 @@ -import { getLocalStorageJson, parseJsonWithFallback } from "../safe-json"; +import { parseJsonWithFallback } from "../safe-json"; describe("safe-json helpers", () => { beforeEach(() => { @@ -31,83 +31,4 @@ describe("safe-json helpers", () => { expect(parsed).toEqual([]); }); }); - - describe("getLocalStorageJson", () => { - it("returns fallback for missing keys", () => { - const parsed = getLocalStorageJson("missing-key", [] as string[]); - - expect(parsed).toEqual([]); - }); - - it("removes malformed key when removeOnError is enabled", () => { - localStorage.setItem("cart", "{bad-json"); - const removeItemSpy = jest.spyOn(Storage.prototype, "removeItem"); - - const parsed = getLocalStorageJson("cart", [] as unknown[], { - removeOnError: true, - }); - - expect(parsed).toEqual([]); - expect(removeItemSpy).toHaveBeenCalledWith("cart"); - }); - - it("does not remove key on validation mismatch by default", () => { - localStorage.setItem("relays", "[1,2,3]"); - const removeItemSpy = jest.spyOn(Storage.prototype, "removeItem"); - - const parsed = getLocalStorageJson("relays", [], { - removeOnError: true, - validate: (value): value is string[] => - Array.isArray(value) && - value.every((item) => typeof item === "string"), - }); - - expect(parsed).toEqual([]); - expect(removeItemSpy).not.toHaveBeenCalled(); - expect(localStorage.getItem("relays")).toBe("[1,2,3]"); - }); - - it("removes invalid key when removeOnValidationError is enabled", () => { - localStorage.setItem("relays", "[1,2,3]"); - const removeItemSpy = jest.spyOn(Storage.prototype, "removeItem"); - - const parsed = getLocalStorageJson("relays", [], { - removeOnValidationError: true, - validate: (value): value is string[] => - Array.isArray(value) && - value.every((item) => typeof item === "string"), - }); - - expect(parsed).toEqual([]); - expect(removeItemSpy).toHaveBeenCalledWith("relays"); - }); - - it("emits diagnostic context for parse and validation errors", () => { - const onError = jest.fn(); - - localStorage.setItem("cart", "{bad-json"); - getLocalStorageJson("cart", [] as string[], { - removeOnError: true, - onError, - }); - - localStorage.setItem("relays", "[1,2,3]"); - getLocalStorageJson("relays", [], { - validate: (value): value is string[] => - Array.isArray(value) && - value.every((item) => typeof item === "string"), - onError, - }); - - expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ reason: "parse_error", key: "cart" }) - ); - expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ - reason: "validation_mismatch", - key: "relays", - }) - ); - }); - }); }); diff --git a/utils/__tests__/storage-types.test.ts b/utils/__tests__/storage-types.test.ts new file mode 100644 index 000000000..812e51ef9 --- /dev/null +++ b/utils/__tests__/storage-types.test.ts @@ -0,0 +1,56 @@ +import { + createStorageKey, + storage, + STORAGE_KEY_PREFIXES, + STORAGE_KEYS, +} from "../storage"; + +describe("StorageManager key types", () => { + beforeEach(() => { + localStorage.clear(); + jest.restoreAllMocks(); + }); + + it("accepts catalogued keys and rejects unregistered literals", () => { + expect(storage.getItem(STORAGE_KEYS.CART)).toBeNull(); + + // @ts-expect-error Unregistered static keys must not compile. + storage.getItem("cart-typo"); + + const profileKey = createStorageKey( + STORAGE_KEY_PREFIXES.USER_PROFILE, + "pubkey" + ); + expect(storage.getItem(profileKey)).toBeNull(); + }); + + it("removes malformed JSON when requested", () => { + localStorage.setItem(STORAGE_KEYS.CART, "{bad-json"); + + expect( + storage.getJson(STORAGE_KEYS.CART, [], { removeOnError: true }) + ).toEqual([]); + expect(localStorage.getItem(STORAGE_KEYS.CART)).toBeNull(); + }); + + it("validates parsed JSON and reports the storage key", () => { + const onError = jest.fn(); + localStorage.setItem(STORAGE_KEYS.RELAYS, "[1,2,3]"); + + const relays = storage.getJson(STORAGE_KEYS.RELAYS, [], { + onError, + removeOnValidationError: true, + validate: (value): value is string[] => + Array.isArray(value) && value.every((item) => typeof item === "string"), + }); + + expect(relays).toEqual([]); + expect(localStorage.getItem(STORAGE_KEYS.RELAYS)).toBeNull(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + reason: "validation_mismatch", + key: STORAGE_KEYS.RELAYS, + }) + ); + }); +}); diff --git a/utils/cashu/__tests__/dispute-redemption.test.ts b/utils/cashu/__tests__/dispute-redemption.test.ts index ab995c2a3..fe0f26b72 100644 --- a/utils/cashu/__tests__/dispute-redemption.test.ts +++ b/utils/cashu/__tests__/dispute-redemption.test.ts @@ -33,6 +33,7 @@ jest.mock("@cashu/cashu-ts", () => ({ })); import { publishProofEvent } from "@/utils/nostr/nostr-helper-functions"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; import { createPartialRedemption, combineAndRedeem, @@ -78,6 +79,7 @@ describe("combineAndRedeem", () => { }); it("combines two signature sets into each proof's witness and calls wallet.receive with no privkey option", async () => { + const setJson = jest.spyOn(storage, "setJson"); const proofs = [mkProof("secret-a"), mkProof("secret-b")]; const freshProofs = [mkProof("fresh-a"), mkProof("fresh-b")]; mockReceive.mockResolvedValue(freshProofs); @@ -122,6 +124,14 @@ describe("combineAndRedeem", () => { expect(JSON.parse(localStorage.getItem("mints")!)).toEqual([ "https://mint.example", ]); + expect(setJson).toHaveBeenCalledWith(STORAGE_KEYS.TOKENS, freshProofs); + expect(setJson).toHaveBeenCalledWith(STORAGE_KEYS.MINTS, [ + "https://mint.example", + ]); + expect(setJson).toHaveBeenCalledWith( + STORAGE_KEYS.HISTORY, + expect.any(Array) + ); expect(publishProofEvent).toHaveBeenCalledWith( nostr, signer, diff --git a/utils/cashu/__tests__/p2pk-escrow-records.test.ts b/utils/cashu/__tests__/p2pk-escrow-records.test.ts index f660f9b2a..58099adfc 100644 --- a/utils/cashu/__tests__/p2pk-escrow-records.test.ts +++ b/utils/cashu/__tests__/p2pk-escrow-records.test.ts @@ -21,6 +21,7 @@ jest.mock("@/utils/nostr/nip98-auth", () => ({ import { finalizeAndSendNostrEvent } from "@/utils/nostr/nostr-helper-functions"; import { createNip98AuthorizationHeader } from "@/utils/nostr/nip98-auth"; +import { storage } from "@/utils/storage"; // Phase 1 fixture: no arbiterPubkey/disputeStatus, matching records // persisted before Phase 2 shipped. @@ -94,8 +95,12 @@ describe("p2pk-escrow-records", () => { }); it("restoreEscrowRecordLocally writes the record to localStorage, defaulting disputeStatus to none", () => { + const setJson = jest.spyOn(storage, "setJson"); restoreEscrowRecordLocally(record); expect(getLocalBuyerP2pkEscrowRecords()).toEqual([normalizedRecord]); + expect(setJson).toHaveBeenCalledWith("shopstr.p2pkEscrowRecords", [ + normalizedRecord, + ]); }); it("restoreEscrowRecordLocally deduplicates by orderId — calling twice does not create a duplicate", () => { diff --git a/utils/cashu/__tests__/wallet-recovery.test.ts b/utils/cashu/__tests__/wallet-recovery.test.ts index 1032dfd4e..4b767152f 100644 --- a/utils/cashu/__tests__/wallet-recovery.test.ts +++ b/utils/cashu/__tests__/wallet-recovery.test.ts @@ -6,12 +6,10 @@ import { } from "../wallet-recovery"; jest.mock("@/utils/nostr/nostr-helper-functions", () => ({ - getLocalStorageData: jest.fn(() => ({ tokens: [], history: [] })), publishProofEvent: jest.fn(), })); const helpers = jest.requireMock("@/utils/nostr/nostr-helper-functions") as { - getLocalStorageData: jest.Mock; publishProofEvent: jest.Mock; }; @@ -26,9 +24,7 @@ const mkProof = (secret: string, amount = 10): Proof => describe("recoverProofsToBuyerWallet", () => { beforeEach(() => { window.localStorage.clear(); - helpers.getLocalStorageData.mockReset(); helpers.publishProofEvent.mockReset(); - helpers.getLocalStorageData.mockReturnValue({ tokens: [], history: [] }); helpers.publishProofEvent.mockResolvedValue(undefined); }); @@ -51,10 +47,14 @@ describe("recoverProofsToBuyerWallet", () => { }); it("preserves existing wallet contents", async () => { - helpers.getLocalStorageData.mockReturnValue({ - tokens: [mkProof("existing", 1)], - history: [{ type: 3, amount: 1, date: 1 }], - }); + window.localStorage.setItem( + "tokens", + JSON.stringify([mkProof("existing", 1)]) + ); + window.localStorage.setItem( + "history", + JSON.stringify([{ type: 3, amount: 1, date: 1 }]) + ); await recoverProofsToBuyerWallet( {} as never, {} as never, diff --git a/utils/cashu/dispute-redemption.ts b/utils/cashu/dispute-redemption.ts index 05d0ca25c..4520d3d04 100644 --- a/utils/cashu/dispute-redemption.ts +++ b/utils/cashu/dispute-redemption.ts @@ -14,6 +14,7 @@ import { buildSignedHttpRequestProofTemplate, SIGNED_EVENT_HEADER, } from "@/utils/nostr/request-auth"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; export type EscrowPaymentRequestPayload = { type: "escrow-payment-request"; @@ -142,24 +143,18 @@ export async function combineAndRedeem(params: { const uniqueProofs = freshProofs.filter( (proof: Proof) => !tokens.some((t: Proof) => t.C === proof.C) ); - localStorage.setItem( - "tokens", - JSON.stringify([...tokens, ...uniqueProofs]) - ); + storage.setJson(STORAGE_KEYS.TOKENS, [...tokens, ...uniqueProofs]); if (!mints.includes(tokenMint)) { - localStorage.setItem("mints", JSON.stringify([...mints, tokenMint])); + storage.setJson(STORAGE_KEYS.MINTS, [...mints, tokenMint]); } - localStorage.setItem( - "history", - JSON.stringify([ - { - type: 1, - amount: tokenAmount, - date: Math.floor(Date.now() / 1000), - }, - ...history, - ]) - ); + storage.setJson(STORAGE_KEYS.HISTORY, [ + { + type: 1, + amount: tokenAmount, + date: Math.floor(Date.now() / 1000), + }, + ...history, + ]); await publishProofEvent( nostr, diff --git a/utils/cashu/p2pk-escrow-records.ts b/utils/cashu/p2pk-escrow-records.ts index d2f7298a4..f7eea69de 100644 --- a/utils/cashu/p2pk-escrow-records.ts +++ b/utils/cashu/p2pk-escrow-records.ts @@ -3,9 +3,11 @@ import { NostrManager } from "@/utils/nostr/nostr-manager"; import type { EventTemplate } from "nostr-tools"; import { finalizeAndSendNostrEvent } from "@/utils/nostr/nostr-helper-functions"; import { createNip98AuthorizationHeader } from "@/utils/nostr/nip98-auth"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; +import type { StorageKey } from "@/utils/storage"; -const LOCAL_STORAGE_KEY = "shopstr.p2pkEscrowRecords"; -const ENCRYPTED_STORAGE_KEY = "shopstr.p2pkEscrowRecords.encrypted"; +const LOCAL_STORAGE_KEY = STORAGE_KEYS.P2PK_ESCROW_RECORDS; +const ENCRYPTED_STORAGE_KEY = STORAGE_KEYS.P2PK_ESCROW_RECORDS_ENCRYPTED; export const BUYER_P2PK_ESCROW_EVENT_KIND = 30406; const BUYER_P2PK_ESCROW_D_PREFIX = "shopstr:p2pk-escrow"; @@ -205,22 +207,16 @@ function isEncryptedBuyerP2pkEscrowRecord( ); } -function readJsonArray(storageKey: string): T[] { - if (typeof window === "undefined") return []; - - try { - const raw = window.localStorage.getItem(storageKey); - if (!raw) return []; - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } +function readJsonArray(storageKey: StorageKey): T[] { + return storage.getJson(storageKey, [], { + removeOnError: true, + removeOnValidationError: true, + validate: (value): value is T[] => Array.isArray(value), + }); } -function writeJsonArray(storageKey: string, records: T[]): void { - if (typeof window === "undefined") return; - window.localStorage.setItem(storageKey, JSON.stringify(records)); +function writeJsonArray(storageKey: StorageKey, records: T[]): void { + storage.setJson(storageKey, records); } export function getLocalBuyerP2pkEscrowRecords(): BuyerP2pkEscrowRecord[] { @@ -274,8 +270,8 @@ function removeLocalBuyerP2pkEscrowRecord(orderId: string): void { if (records.length > 0) { writeJsonArray(LOCAL_STORAGE_KEY, records); - } else if (typeof window !== "undefined") { - window.localStorage.removeItem(LOCAL_STORAGE_KEY); + } else { + storage.removeItem(LOCAL_STORAGE_KEY); } } diff --git a/utils/cashu/pending-mint-operations.ts b/utils/cashu/pending-mint-operations.ts index b51f06561..9932938c6 100644 --- a/utils/cashu/pending-mint-operations.ts +++ b/utils/cashu/pending-mint-operations.ts @@ -1,7 +1,6 @@ import { Wallet as CashuWallet, Proof } from "@cashu/cashu-ts"; import { withMintRetry } from "./mint-retry-service"; - -const STORAGE_KEY = "shopstr.pendingMintQuotes"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; export type PendingMintQuoteStatus = "awaiting_payment" | "paid_unclaimed" | "claimed" | "failed_terminal"; @@ -25,20 +24,14 @@ export interface PendingMintQuote { export const PAID_UNCLAIMED_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; function readAll(): PendingMintQuote[] { - if (typeof window === "undefined") return []; - try { - const raw = window.localStorage.getItem(STORAGE_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } + return storage.getJson( + STORAGE_KEYS.PENDING_MINT_QUOTES, + [] + ); } function writeAll(quotes: PendingMintQuote[]): void { - if (typeof window === "undefined") return; - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(quotes)); + storage.setJson(STORAGE_KEYS.PENDING_MINT_QUOTES, quotes); } export interface RecordPendingMintQuoteInput { diff --git a/utils/cashu/wallet-recovery.ts b/utils/cashu/wallet-recovery.ts index 78ed7bd68..ce03106e0 100644 --- a/utils/cashu/wallet-recovery.ts +++ b/utils/cashu/wallet-recovery.ts @@ -1,8 +1,6 @@ import { Proof } from "@cashu/cashu-ts"; -import { - getLocalStorageData, - publishProofEvent, -} from "@/utils/nostr/nostr-helper-functions"; +import { publishProofEvent } from "@/utils/nostr/nostr-helper-functions"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; type Nostr = Parameters[0]; type Signer = Parameters[1]; @@ -16,7 +14,6 @@ type Signer = Parameters[1]; * Idempotency: callers must only invoke this once per failed claim. The * pending-mint-store should be transitioned to `claimed` immediately after * a successful call so boot recovery does not re-attempt the (already issued) - * mint quote. */ export async function recoverProofsToBuyerWallet( nostr: Nostr, @@ -28,20 +25,19 @@ export async function recoverProofsToBuyerWallet( if (typeof window === "undefined") return; if (!proofs || proofs.length === 0) return; - const { tokens, history } = getLocalStorageData(); + const tokens = storage.getJson(STORAGE_KEYS.TOKENS, []); + const history = storage.getJson(STORAGE_KEYS.HISTORY, []); + const proofArray = [...tokens, ...proofs]; - window.localStorage.setItem("tokens", JSON.stringify(proofArray)); - window.localStorage.setItem( - "history", - JSON.stringify([ - { - type: 3, - amount, - date: Math.floor(Date.now() / 1000), - }, - ...history, - ]) - ); + storage.setJson(STORAGE_KEYS.TOKENS, proofArray); + storage.setJson(STORAGE_KEYS.HISTORY, [ + { + type: 3, + amount, + date: Math.floor(Date.now() / 1000), + }, + ...history, + ]); // Best-effort wallet event publish; localStorage is the source of truth and // sendGiftWrappedMessageEvent / publishProofEvent already cache to DB first diff --git a/utils/nostr/__tests__/local-storage-data.test.ts b/utils/nostr/__tests__/local-storage-data.test.ts index e62a6d1ec..0bf7d379c 100644 --- a/utils/nostr/__tests__/local-storage-data.test.ts +++ b/utils/nostr/__tests__/local-storage-data.test.ts @@ -1,73 +1,42 @@ -import { - getDefaultBlossomServer, - getDefaultMint, - getDefaultRelays, - getLocalStorageData, -} from "../nostr-helper-functions"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; -describe("getLocalStorageData", () => { +describe("StorageManager defaults (replaces getLocalStorageData)", () => { beforeEach(() => { localStorage.clear(); jest.restoreAllMocks(); }); it("returns safe defaults for missing keys", () => { - const data = getLocalStorageData(); - - expect(data.relays).toEqual(getDefaultRelays()); - expect(data.mints).toEqual([getDefaultMint()]); - expect(data.blossomServers).toEqual([getDefaultBlossomServer()]); - expect(data.tokens).toEqual([]); - expect(data.history).toEqual([]); + expect(storage.getJson(STORAGE_KEYS.RELAYS, [])).toEqual([]); + expect(storage.getJson(STORAGE_KEYS.MINTS, [])).toEqual([]); + expect(storage.getJson(STORAGE_KEYS.TOKENS, [])).toEqual([]); + expect(storage.getJson(STORAGE_KEYS.HISTORY, [])).toEqual([]); }); it("recovers from malformed JSON in critical keys", () => { localStorage.setItem("relays", "{bad"); - localStorage.setItem("readRelays", "{bad"); - localStorage.setItem("writeRelays", "{bad"); localStorage.setItem("mints", "{bad"); - localStorage.setItem("blossomServers", "{bad"); localStorage.setItem("tokens", "{bad"); localStorage.setItem("history", "{bad"); - localStorage.setItem("bunkerRelays", "{bad"); localStorage.setItem("signer", "{bad"); - expect(() => getLocalStorageData()).not.toThrow(); - - const data = getLocalStorageData(); - expect(data.relays).toEqual(getDefaultRelays()); - expect(data.readRelays).toEqual([]); - expect(data.writeRelays).toEqual([]); - expect(data.mints).toEqual([getDefaultMint()]); - expect(data.blossomServers).toEqual([getDefaultBlossomServer()]); - expect(data.tokens).toEqual([]); - expect(data.history).toEqual([]); - expect(data.bunkerRelays).toEqual([]); + expect(() => storage.getJson(STORAGE_KEYS.RELAYS, [])).not.toThrow(); + expect(storage.getJson(STORAGE_KEYS.RELAYS, [])).toEqual([]); + expect(storage.getJson(STORAGE_KEYS.MINTS, [])).toEqual([]); + expect(storage.getJson(STORAGE_KEYS.TOKENS, [])).toEqual([]); + expect(storage.getJson(STORAGE_KEYS.HISTORY, [])).toEqual([]); }); - it("falls back to signInMethod signer when stored signer shape is invalid", () => { - localStorage.setItem("signInMethod", "extension"); - localStorage.setItem("signer", JSON.stringify({ type: "nip46" })); - - const data = getLocalStorageData(); - - expect(data.signer).toEqual({ type: "nip07" }); + it("reads and writes data correctly", () => { + const relays = ["wss://relay.damus.io"]; + storage.setJson(STORAGE_KEYS.RELAYS, relays); + expect(storage.getJson(STORAGE_KEYS.RELAYS, [])).toEqual(relays); }); - it("keeps valid stored signer shape", () => { - localStorage.setItem( - "signer", - JSON.stringify({ - type: "nsec", - encryptedPrivKey: "ncryptsec1mock", - }) + it("returns string items correctly", () => { + storage.setItem(STORAGE_KEYS.NWC_STRING, "nostr+walletconnect://test"); + expect(storage.getItem(STORAGE_KEYS.NWC_STRING)).toBe( + "nostr+walletconnect://test" ); - - const data = getLocalStorageData(); - - expect(data.signer).toEqual({ - type: "nsec", - encryptedPrivKey: "ncryptsec1mock", - }); }); }); diff --git a/utils/nostr/encryption-migration.ts b/utils/nostr/encryption-migration.ts index 8f926c448..c24c5fe29 100644 --- a/utils/nostr/encryption-migration.ts +++ b/utils/nostr/encryption-migration.ts @@ -7,23 +7,20 @@ import { NostrNSecSigner } from "./signers/nostr-nsec-signer"; let migrationAttempted = false; function findEncryptedKey() { - const storedData = getLocalStorageData(); + const { encryptedPrivateKey, signer } = getLocalStorageData(); - if (storedData.encryptedPrivateKey) { + if (encryptedPrivateKey) { return { - key: storedData.encryptedPrivateKey, + key: encryptedPrivateKey, inSigner: false, }; } - if ( - storedData.signer?.type === "nsec" && - storedData.signer.encryptedPrivKey - ) { + if (signer?.type === "nsec" && signer.encryptedPrivKey) { return { - key: storedData.signer.encryptedPrivKey, + key: signer.encryptedPrivKey, inSigner: true, - signer: storedData.signer, + signer: signer, }; } @@ -31,7 +28,7 @@ function findEncryptedKey() { } export function needsMigration(): boolean { - if (getLocalStorageData().migrationComplete === true) { + if (getLocalStorageData().migrationComplete) { return false; } const { key } = findEncryptedKey(); diff --git a/utils/nostr/nostr-helper-functions.ts b/utils/nostr/nostr-helper-functions.ts index aa58d103b..8bd522a92 100644 --- a/utils/nostr/nostr-helper-functions.ts +++ b/utils/nostr/nostr-helper-functions.ts @@ -20,7 +20,13 @@ import { buildSignedHttpRequestProofTemplate, } from "@/utils/nostr/request-auth"; import { newPromiseWithTimeout } from "@/utils/timeout"; -import { getLocalStorageJson } from "@/utils/safe-json"; +import { + createStorageKey, + storage, + STORAGE_KEY_PREFIXES, + STORAGE_KEYS, +} from "@/utils/storage"; +import type { StorageKey } from "@/utils/storage"; import { buildWalletConfigV1 } from "@/utils/cashu/wallet-config"; import { isHexPubkey } from "@/utils/nostr/pubkey"; import { pickPreferredReplaceableEvent } from "@/utils/nostr/replaceable-events"; @@ -812,31 +818,6 @@ export async function blossomUploadImages( /***** HELPER FUNCTIONS *****/ -// function to validate public and private keys -const LOCALSTORAGECONSTANTS = { - signInMethod: "signInMethod", - userNPub: "userNPub", - userPubkey: "userPubkey", - encryptedPrivateKey: "encryptedPrivateKey", - relays: "relays", - readRelays: "readRelays", - writeRelays: "writeRelays", - mints: "mints", - blossomServers: "blossomServers", - tokens: "tokens", - history: "history", - wot: "wot", - clientPubkey: "clientPubkey", - clientPrivkey: "clientPrivkey", - bunkerRemotePubkey: "bunkerRemotePubkey", - bunkerRelays: "bunkerRelays", - bunkerSecret: "bunkerSecret", - signer: "signer", - nwcString: "nwcString", - nwcInfo: "nwcInfo", - savedAddresses: "savedAddresses", -}; - export const setLocalStorageDataOnSignIn = ({ encryptedPrivateKey, relays, @@ -869,65 +850,55 @@ export const setLocalStorageDataOnSignIn = ({ migrationComplete?: boolean; }) => { if (encryptedPrivateKey) { - localStorage.setItem( - LOCALSTORAGECONSTANTS.encryptedPrivateKey, - encryptedPrivateKey - ); + storage.setItem(STORAGE_KEYS.ENCRYPTED_PRIVATE_KEY, encryptedPrivateKey); } - localStorage.setItem( - LOCALSTORAGECONSTANTS.relays, - JSON.stringify(relays && relays.length != 0 ? relays : getDefaultRelays()) + storage.setJson( + STORAGE_KEYS.RELAYS, + relays && relays.length !== 0 ? relays : getDefaultRelays() ); - localStorage.setItem( - LOCALSTORAGECONSTANTS.readRelays, - JSON.stringify(readRelays && readRelays.length != 0 ? readRelays : []) + storage.setJson( + STORAGE_KEYS.READ_RELAYS, + readRelays && readRelays.length !== 0 ? readRelays : [] ); - localStorage.setItem( - LOCALSTORAGECONSTANTS.writeRelays, - JSON.stringify(writeRelays && writeRelays.length != 0 ? writeRelays : []) + storage.setJson( + STORAGE_KEYS.WRITE_RELAYS, + writeRelays && writeRelays.length !== 0 ? writeRelays : [] ); - localStorage.setItem( - LOCALSTORAGECONSTANTS.mints, - JSON.stringify(mints ? mints : [getDefaultMint()]) - ); + storage.setJson(STORAGE_KEYS.MINTS, mints ? mints : [getDefaultMint()]); - localStorage.setItem( - LOCALSTORAGECONSTANTS.blossomServers, - JSON.stringify( - blossomServers ? blossomServers : [getDefaultBlossomServer()] - ) + storage.setJson( + STORAGE_KEYS.BLOSSOM_SERVERS, + blossomServers ? blossomServers : [getDefaultBlossomServer()] ); - localStorage.setItem(LOCALSTORAGECONSTANTS.wot, String(wot ? wot : 3)); + storage.setItem(STORAGE_KEYS.WOT, String(wot ? wot : 3)); if (clientPubkey && clientPrivkey && bunkerRemotePubkey && bunkerRelays) { - localStorage.setItem(LOCALSTORAGECONSTANTS.clientPubkey, clientPubkey); - localStorage.setItem(LOCALSTORAGECONSTANTS.clientPrivkey, clientPrivkey); - localStorage.setItem( - LOCALSTORAGECONSTANTS.bunkerRemotePubkey, - bunkerRemotePubkey - ); - localStorage.setItem( - LOCALSTORAGECONSTANTS.bunkerRelays, - JSON.stringify( - bunkerRelays && bunkerRelays.length != 0 ? bunkerRelays : [] - ) + storage.setItem(STORAGE_KEYS.CLIENT_PUBKEY, clientPubkey); + storage.setItem(STORAGE_KEYS.CLIENT_PRIVKEY, clientPrivkey); + storage.setItem(STORAGE_KEYS.BUNKER_REMOTE_PUBKEY, bunkerRemotePubkey); + storage.setJson( + STORAGE_KEYS.BUNKER_RELAYS, + bunkerRelays && bunkerRelays.length !== 0 ? bunkerRelays : [] ); if (bunkerSecret) { - localStorage.setItem(LOCALSTORAGECONSTANTS.bunkerSecret, bunkerSecret); + storage.setItem(STORAGE_KEYS.BUNKER_SECRET, bunkerSecret); } } if (signer) { - localStorage.setItem(LOCALSTORAGECONSTANTS.signer, JSON.stringify(signer)); + storage.setJson(STORAGE_KEYS.SIGNER, signer); } if (migrationComplete) { - localStorage.setItem("migrationComplete", migrationComplete.toString()); + storage.setItem( + STORAGE_KEYS.MIGRATION_COMPLETE, + migrationComplete.toString() + ); } window.dispatchEvent(new Event("storage")); @@ -961,7 +932,7 @@ export interface LocalStorageInterface { savedAddresses: SavedAddress[]; } -function isStoredSignerData( +export function isStoredSignerData( value: unknown ): value is NonNullable { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -998,250 +969,222 @@ function isStoredSignerData( return false; } -export const getLocalStorageData = (): LocalStorageInterface => { - const isStringArray = (value: unknown): value is string[] => - Array.isArray(value) && value.every((entry) => typeof entry === "string"); - const isArray = (value: unknown): value is unknown[] => Array.isArray(value); - - let signInMethod; - let encryptedPrivateKey; - let relays; - let readRelays; - let writeRelays; - let mints; - let blossomServers; - let tokens; - let history; - let wot; - let clientPrivkey; - let bunkerRemotePubkey; - let bunkerRelays; - let bunkerSecret; - let signer: LocalStorageInterface["signer"] | undefined; - let migrationComplete; - let nwcString; - let nwcInfo; - let savedAddresses: SavedAddress[] = []; - - if (typeof window !== "undefined") { - encryptedPrivateKey = localStorage.getItem( - LOCALSTORAGECONSTANTS.encryptedPrivateKey - ); +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((entry) => typeof entry === "string"); - signInMethod = localStorage.getItem(LOCALSTORAGECONSTANTS.signInMethod); +const isUnknownArray = (value: unknown): value is unknown[] => + Array.isArray(value); - if (signInMethod) { - // remove old data - localStorage.removeItem("npub"); - localStorage.removeItem("signIn"); - localStorage.removeItem("chats"); - localStorage.removeItem("cashuWalletRelays"); - } +const isSavedAddressArray = (value: unknown): value is SavedAddress[] => + Array.isArray(value); - relays = getLocalStorageJson(LOCALSTORAGECONSTANTS.relays, [], { +export const isStoredSignerDataOrUndefined = ( + value: unknown +): value is LocalStorageInterface["signer"] | undefined => + value === undefined || isStoredSignerData(value); + +function getStoredStringArray( + key: StorageKey, + fallback: string[] = [], + persistFallback = false +): string[] { + const values = storage + .getJson(key, [], { removeOnError: true, + removeOnValidationError: true, validate: isStringArray, - }); + }) + .filter(Boolean); - const defaultRelays = getDefaultRelays(); + if (values.length > 0) { + return values; + } - if (relays && relays.length === 0) { - relays = defaultRelays; - localStorage.setItem("relays", JSON.stringify(relays)); - } else { - try { - if (relays) { - relays = relays.filter((r) => r); - } - } catch { - relays = defaultRelays; - localStorage.setItem("relays", JSON.stringify(relays)); - } - } + if (persistFallback && fallback.length > 0) { + storage.setJson(key, fallback); + } - readRelays = getLocalStorageJson( - LOCALSTORAGECONSTANTS.readRelays, - [], - { - removeOnError: true, - validate: isStringArray, - } - ).filter((r) => r); + return fallback; +} - writeRelays = getLocalStorageJson( - LOCALSTORAGECONSTANTS.writeRelays, - [], - { - removeOnError: true, - validate: isStringArray, - } - ).filter((r) => r); +export function getStoredRelays(): string[] { + return getStoredStringArray(STORAGE_KEYS.RELAYS, getDefaultRelays()); +} - mints = getLocalStorageJson(LOCALSTORAGECONSTANTS.mints, [], { - removeOnError: true, - validate: isStringArray, - }); +export function getStoredReadRelays(): string[] { + return getStoredStringArray(STORAGE_KEYS.READ_RELAYS); +} - if (mints.length === 0) { - mints = [getDefaultMint()]; - localStorage.setItem(LOCALSTORAGECONSTANTS.mints, JSON.stringify(mints)); - } +export function getStoredWriteRelays(): string[] { + return getStoredStringArray(STORAGE_KEYS.WRITE_RELAYS); +} - blossomServers = getLocalStorageJson( - LOCALSTORAGECONSTANTS.blossomServers, - [], - { - removeOnError: true, - validate: isStringArray, - } - ); +export function getStoredMints(): string[] { + return getStoredStringArray(STORAGE_KEYS.MINTS, [getDefaultMint()]); +} - if (blossomServers.length === 0) { - blossomServers = [getDefaultBlossomServer()]; - localStorage.setItem( - LOCALSTORAGECONSTANTS.blossomServers, - JSON.stringify(blossomServers) - ); - } +export function getStoredBlossomServers(): string[] { + return getStoredStringArray(STORAGE_KEYS.BLOSSOM_SERVERS, [ + getDefaultBlossomServer(), + ]); +} - tokens = getLocalStorageJson(LOCALSTORAGECONSTANTS.tokens, [], { - removeOnError: true, - validate: isArray, - }); - if ( - tokens.length === 0 && - !localStorage.getItem(LOCALSTORAGECONSTANTS.tokens) - ) { - localStorage.setItem(LOCALSTORAGECONSTANTS.tokens, JSON.stringify([])); - } +export const getLocalStorageData = (): LocalStorageInterface => { + const signInMethod = storage.getItem(STORAGE_KEYS.SIGN_IN_METHOD) || ""; + const encryptedPrivateKey = + storage.getItem(STORAGE_KEYS.ENCRYPTED_PRIVATE_KEY) || undefined; + + if (signInMethod) { + storage.removeItem(STORAGE_KEYS.LEGACY_NPUB); + storage.removeItem(STORAGE_KEYS.LEGACY_SIGN_IN); + storage.removeItem(STORAGE_KEYS.LEGACY_CHATS); + storage.removeItem(STORAGE_KEYS.LEGACY_CASHU_WALLET_RELAYS); + } - history = getLocalStorageJson( - LOCALSTORAGECONSTANTS.history, - [], - { - removeOnError: true, - validate: isArray, - } - ); - if ( - history.length === 0 && - !localStorage.getItem(LOCALSTORAGECONSTANTS.history) - ) { - localStorage.setItem(LOCALSTORAGECONSTANTS.history, JSON.stringify([])); - } + const relays = getStoredStringArray( + STORAGE_KEYS.RELAYS, + getDefaultRelays(), + true + ); + const readRelays = getStoredReadRelays(); + const writeRelays = getStoredWriteRelays(); + const mints = getStoredStringArray( + STORAGE_KEYS.MINTS, + [getDefaultMint()], + true + ); + const blossomServers = getStoredStringArray( + STORAGE_KEYS.BLOSSOM_SERVERS, + [getDefaultBlossomServer()], + true + ); - wot = localStorage.getItem(LOCALSTORAGECONSTANTS.wot) - ? Number(localStorage.getItem(LOCALSTORAGECONSTANTS.wot)) - : 3; + const tokens = storage.getJson(STORAGE_KEYS.TOKENS, [], { + removeOnError: true, + removeOnValidationError: true, + validate: isUnknownArray, + }); + if (!storage.getItem(STORAGE_KEYS.TOKENS)) { + storage.setJson(STORAGE_KEYS.TOKENS, []); + } - clientPrivkey = localStorage.getItem(LOCALSTORAGECONSTANTS.clientPrivkey) - ? localStorage.getItem(LOCALSTORAGECONSTANTS.clientPrivkey) - : undefined; - bunkerRemotePubkey = localStorage.getItem( - LOCALSTORAGECONSTANTS.bunkerRemotePubkey - ) - ? localStorage.getItem(LOCALSTORAGECONSTANTS.bunkerRemotePubkey) - : undefined; - bunkerRelays = getLocalStorageJson( - LOCALSTORAGECONSTANTS.bunkerRelays, - [], - { - removeOnError: true, - validate: isStringArray, - } - ).filter((r) => r); - bunkerSecret = localStorage.getItem(LOCALSTORAGECONSTANTS.bunkerSecret) - ? localStorage.getItem(LOCALSTORAGECONSTANTS.bunkerSecret) - : undefined; - - signer = getLocalStorageJson( - LOCALSTORAGECONSTANTS.signer, - undefined, - { - removeOnError: true, - validate: isStoredSignerData, - } - ); - if (!signer) { - switch (signInMethod) { - case "extension": - signer = { - type: "nip07", - }; - break; - case "bunker": - let bunker = - "bunker://" + bunkerRemotePubkey + "?secret=" + bunkerSecret; + const history = storage.getJson(STORAGE_KEYS.HISTORY, [], { + removeOnError: true, + removeOnValidationError: true, + validate: isUnknownArray, + }); + if (!storage.getItem(STORAGE_KEYS.HISTORY)) { + storage.setJson(STORAGE_KEYS.HISTORY, []); + } + + const parsedWot = Number(storage.getItem(STORAGE_KEYS.WOT) || 3); + const wot = Number.isFinite(parsedWot) ? parsedWot : 3; + const clientPrivkey = + storage.getItem(STORAGE_KEYS.CLIENT_PRIVKEY) || undefined; + const bunkerRemotePubkey = + storage.getItem(STORAGE_KEYS.BUNKER_REMOTE_PUBKEY) || undefined; + const bunkerRelays = getStoredStringArray(STORAGE_KEYS.BUNKER_RELAYS); + const bunkerSecret = storage.getItem(STORAGE_KEYS.BUNKER_SECRET) || undefined; + + let signer = storage.getJson( + STORAGE_KEYS.SIGNER, + undefined, + { + removeOnError: true, + removeOnValidationError: true, + validate: isStoredSignerDataOrUndefined, + } + ); + + if (!signer) { + switch (signInMethod) { + case "extension": + signer = { type: "nip07" }; + break; + case "bunker": + if (bunkerRemotePubkey && bunkerSecret) { + let bunker = `bunker://${bunkerRemotePubkey}?secret=${bunkerSecret}`; for (const relay of bunkerRelays) { - bunker += "&relay=" + relay; + bunker += `&relay=${relay}`; } signer = { type: "nip46", - bunker: bunker, - appPrivKey: - typeof clientPrivkey === "string" ? clientPrivkey : undefined, + bunker, + appPrivKey: clientPrivkey, }; - break; - case "nsec": - if (typeof encryptedPrivateKey === "string") { - signer = { - type: "nsec", - encryptedPrivKey: encryptedPrivateKey, - }; - } - break; - } + } + break; + case "nsec": + if (encryptedPrivateKey) { + signer = { + type: "nsec", + encryptedPrivKey: encryptedPrivateKey, + }; + } + break; } + } - nwcString = localStorage.getItem(LOCALSTORAGECONSTANTS.nwcString) - ? localStorage.getItem(LOCALSTORAGECONSTANTS.nwcString) - : null; - - nwcInfo = localStorage.getItem(LOCALSTORAGECONSTANTS.nwcInfo) - ? localStorage.getItem(LOCALSTORAGECONSTANTS.nwcInfo) - : null; - migrationComplete = localStorage.getItem("migrationComplete") === "true"; - savedAddresses = getLocalStorageJson( - LOCALSTORAGECONSTANTS.savedAddresses, + return { + signInMethod, + encryptedPrivateKey, + relays, + readRelays, + writeRelays, + mints, + blossomServers, + tokens, + history, + wot, + clientPrivkey, + bunkerRemotePubkey, + bunkerRelays, + bunkerSecret, + signer, + nwcString: storage.getItem(STORAGE_KEYS.NWC_STRING), + nwcInfo: storage.getItem(STORAGE_KEYS.NWC_INFO), + migrationComplete: + storage.getItem(STORAGE_KEYS.MIGRATION_COMPLETE) === "true", + savedAddresses: storage.getJson( + STORAGE_KEYS.SAVED_ADDRESSES, [], { removeOnError: true, - validate: (value): value is SavedAddress[] => Array.isArray(value), + removeOnValidationError: true, + validate: isSavedAddressArray, } - ); - } - return { - signInMethod: signInMethod as string, - encryptedPrivateKey: encryptedPrivateKey as string, - relays: relays || [], - readRelays: readRelays || [], - writeRelays: writeRelays || [], - mints: mints || [], - blossomServers: blossomServers || [], - tokens: tokens || [], - history: history || [], - wot: wot || 3, - clientPrivkey: clientPrivkey?.toString(), - bunkerRemotePubkey: bunkerRemotePubkey?.toString(), - bunkerRelays: bunkerRelays || [], - bunkerSecret: bunkerSecret?.toString(), - signer, - nwcString: nwcString as string | null, - nwcInfo: nwcInfo as string | null, - migrationComplete: migrationComplete || false, - savedAddresses, + ), }; }; export const LogOut = () => { - // remove old data - localStorage.removeItem("npub"); - localStorage.removeItem("signIn"); - localStorage.removeItem("chats"); - for (const key in LOCALSTORAGECONSTANTS) { - localStorage.removeItem(key); - } - + storage.removeItem(STORAGE_KEYS.LEGACY_NPUB); + storage.removeItem(STORAGE_KEYS.LEGACY_SIGN_IN); + storage.removeItem(STORAGE_KEYS.LEGACY_CHATS); + storage.removeItem(STORAGE_KEYS.LEGACY_CASHU_WALLET_RELAYS); + storage.clearKeys([ + STORAGE_KEYS.SIGNER, + STORAGE_KEYS.SIGN_IN_METHOD, + STORAGE_KEYS.ENCRYPTED_PRIVATE_KEY, + STORAGE_KEYS.USER_NPUB, + STORAGE_KEYS.USER_PUBKEY, + STORAGE_KEYS.CLIENT_PUBKEY, + STORAGE_KEYS.CLIENT_PRIVKEY, + STORAGE_KEYS.RELAYS, + STORAGE_KEYS.READ_RELAYS, + STORAGE_KEYS.WRITE_RELAYS, + STORAGE_KEYS.BLOSSOM_SERVERS, + STORAGE_KEYS.MINTS, + STORAGE_KEYS.TOKENS, + STORAGE_KEYS.HISTORY, + STORAGE_KEYS.WOT, + STORAGE_KEYS.NWC_STRING, + STORAGE_KEYS.NWC_INFO, + STORAGE_KEYS.BUNKER_REMOTE_PUBKEY, + STORAGE_KEYS.BUNKER_RELAYS, + STORAGE_KEYS.BUNKER_SECRET, + STORAGE_KEYS.SAVED_ADDRESSES, + ]); window.dispatchEvent(new Event("storage")); }; @@ -1292,16 +1235,16 @@ export async function verifyNip05Identifier( export const saveNWCString = (nwcString: string) => { if (nwcString) { - localStorage.setItem(LOCALSTORAGECONSTANTS.nwcString, nwcString); + storage.setItem(STORAGE_KEYS.NWC_STRING, nwcString); } else { - localStorage.removeItem(LOCALSTORAGECONSTANTS.nwcString); - localStorage.removeItem(LOCALSTORAGECONSTANTS.nwcInfo); + storage.removeItem(STORAGE_KEYS.NWC_STRING); + storage.removeItem(STORAGE_KEYS.NWC_INFO); } window.dispatchEvent(new Event("storage")); }; export const getLocalUserProfileKey = (pubkey: string) => - `shopstr:user-profile:${pubkey}`; + createStorageKey(STORAGE_KEY_PREFIXES.USER_PROFILE, pubkey); export interface LocalProfileFallback { content: Record; diff --git a/utils/nostr/saved-address-helpers.ts b/utils/nostr/saved-address-helpers.ts index 20d2624f6..213857b35 100644 --- a/utils/nostr/saved-address-helpers.ts +++ b/utils/nostr/saved-address-helpers.ts @@ -1,8 +1,6 @@ -import { getLocalStorageJson } from "@/utils/safe-json"; +import { storage, STORAGE_KEYS } from "@/utils/storage"; import { SavedAddress } from "@/utils/types/types"; -const SAVED_ADDRESSES_KEY = "savedAddresses"; - const ensureSingleDefault = ( addresses: SavedAddress[], preferredId?: string @@ -22,15 +20,16 @@ const ensureSingleDefault = ( }; export const getSavedAddresses = (): SavedAddress[] => { - if (typeof window === "undefined") return []; - return getLocalStorageJson(SAVED_ADDRESSES_KEY, [], { + return storage.getJson(STORAGE_KEYS.SAVED_ADDRESSES, [], { removeOnError: true, + removeOnValidationError: true, validate: (value): value is SavedAddress[] => Array.isArray(value), }); }; const persist = (addresses: SavedAddress[]): void => { - localStorage.setItem(SAVED_ADDRESSES_KEY, JSON.stringify(addresses)); + storage.setJson(STORAGE_KEYS.SAVED_ADDRESSES, addresses); + if (typeof window === "undefined") return; window.dispatchEvent(new Event("storage")); }; diff --git a/utils/safe-json.ts b/utils/safe-json.ts index ba5e99239..5ac31b12d 100644 --- a/utils/safe-json.ts +++ b/utils/safe-json.ts @@ -12,7 +12,7 @@ interface JsonErrorContext { error?: unknown; } -interface StorageParseOptions { +export interface StorageParseOptions { removeOnError?: boolean; removeOnValidationError?: boolean; onError?: (context: JsonErrorContext) => void; @@ -46,43 +46,3 @@ export function parseJsonWithFallback( return fallback; } } - -export function getLocalStorageJson( - key: string, - fallback: T, - options?: StorageParseOptions -): T { - const reportError = (context: JsonErrorContext) => { - options?.onError?.({ ...context, key }); - }; - - if (options?.validate && !options.validate(fallback)) { - reportError({ reason: "fallback_validation_mismatch" }); - } - - if (typeof window === "undefined") { - reportError({ reason: "ssr" }); - return fallback; - } - - const raw = localStorage.getItem(key); - if (raw === null) return fallback; - - try { - const parsed: unknown = JSON.parse(raw); - if (options?.validate && !options.validate(parsed)) { - if (options.removeOnValidationError) { - localStorage.removeItem(key); - } - reportError({ reason: "validation_mismatch" }); - return fallback; - } - return parsed as T; - } catch (error) { - if (options?.removeOnError) { - localStorage.removeItem(key); - } - reportError({ reason: "parse_error", error }); - return fallback; - } -} diff --git a/utils/storage.ts b/utils/storage.ts new file mode 100644 index 000000000..61924a579 --- /dev/null +++ b/utils/storage.ts @@ -0,0 +1,244 @@ +import { parseJsonWithFallback } from "./safe-json"; +import type { StorageParseOptions } from "./safe-json"; + +/** + * Storage Schema definition for consistent key management and type safety. + * This acts as a single source of truth for all localStorage keys used in Shopstr. + */ +export const STORAGE_KEYS = { + // Auth & Signer keys inherited from existing constants + SIGNER: "signer", + SIGN_IN_METHOD: "signInMethod", + ENCRYPTED_PRIVATE_KEY: "encryptedPrivateKey", + CLIENT_PUBKEY: "clientPubkey", + CLIENT_PRIVKEY: "clientPrivkey", + + // Nostr & Relays + RELAYS: "relays", + READ_RELAYS: "readRelays", + WRITE_RELAYS: "writeRelays", + BLOSSOM_SERVERS: "blossomServers", + + // Wallet & Cashu + MINTS: "mints", + TOKENS: "tokens", + HISTORY: "history", + WOT: "wot", + PENDING_MINT_QUOTES: "shopstr.pendingMintQuotes", + P2PK_ESCROW_RECORDS: "shopstr.p2pkEscrowRecords", + P2PK_ESCROW_RECORDS_ENCRYPTED: "shopstr.p2pkEscrowRecords.encrypted", + + // NWC + NWC_STRING: "nwcString", + NWC_INFO: "nwcInfo", + + // Bunker & Remote Signer + BUNKER_REMOTE_PUBKEY: "bunkerRemotePubkey", + BUNKER_RELAYS: "bunkerRelays", + BUNKER_SECRET: "bunkerSecret", + + // Storefront & Cart (These were mostly unmanaged string literals) + CART: "cart", + CART_DISCOUNTS: "cartDiscounts", + SAVED_ADDRESSES: "savedAddresses", + SF_SELLER_PUBKEY: "sf_seller_pubkey", + SF_SHOP_SLUG: "sf_shop_slug", + SHIPPING_INFO: "shopstr_shipping_info", + ORDER_SUMMARY: "orderSummary", + + // System + MIGRATION_COMPLETE: "migrationComplete", + THEME: "theme", + USER_NPUB: "userNPub", + USER_PUBKEY: "userPubkey", + LEGACY_NPUB: "npub", + LEGACY_SIGN_IN: "signIn", + LEGACY_CHATS: "chats", + LEGACY_CASHU_WALLET_RELAYS: "cashuWalletRelays", + LAST_FILTERS_REF: "last-filters-ref", + MARKETPLACE_PAGE_GENERAL: "marketplace-page-general", +} as const; + +export const STORAGE_KEY_PREFIXES = { + USER_PROFILE: "shopstr:user-profile:", + PAYMENT_REQUEST_SENT_AT: "shopstr.escrow.paymentRequestSentAt.", + MARKETPLACE_PAGE: "marketplace-page-", +} as const; + +export type StorageKey = (typeof STORAGE_KEYS)[keyof typeof STORAGE_KEYS]; +export type StorageKeyPrefix = + (typeof STORAGE_KEY_PREFIXES)[keyof typeof STORAGE_KEY_PREFIXES]; +declare const dynamicStorageKeyBrand: unique symbol; +export type DynamicStorageKey = string & { + readonly [dynamicStorageKeyBrand]: true; +}; +type StorageIdentifier = StorageKey | DynamicStorageKey; + +export function createStorageKey( + prefix: StorageKeyPrefix, + identifier: string +): DynamicStorageKey { + return `${prefix}${identifier}` as DynamicStorageKey; +} + +class StorageManager { + /** + * Safe check for browser environment to prevent Next.js SSR hydration crashes + */ + private get isBrowser(): boolean { + return typeof window !== "undefined"; + } + + /** + * Get a string item from localStorage + */ + getItem(key: StorageIdentifier): string | null { + if (!this.isBrowser) return null; + return localStorage.getItem(key); + } + + /** + * Set a string item in localStorage + */ + setItem(key: StorageIdentifier, value: string): void { + if (!this.isBrowser) return; + localStorage.setItem(key, value); + } + + /** + * Remove an item from localStorage + */ + removeItem(key: StorageIdentifier): void { + if (!this.isBrowser) return; + localStorage.removeItem(key); + } + + /** + * Get and parse JSON data with a fallback and type-safety + */ + getJson( + key: StorageIdentifier, + fallback: T, + options?: StorageParseOptions + ): T { + if (!this.isBrowser) return fallback; + const raw = localStorage.getItem(key); + return parseJsonWithFallback(raw, fallback, { + ...options, + onError: (context) => { + const contextWithKey = { ...context, key }; + options?.onError?.(contextWithKey); + + if (context.reason === "parse_error" && options?.removeOnError) { + localStorage.removeItem(key); + } + + if ( + context.reason === "validation_mismatch" && + options?.removeOnValidationError + ) { + localStorage.removeItem(key); + } + + console.warn(`Storage parse error for key "${key}":`, context); + }, + }); + } + + /** + * Stringify and set JSON data in localStorage safely + */ + setJson(key: StorageIdentifier, value: T): void { + if (!this.isBrowser) return; + try { + const serialized = JSON.stringify(value); + localStorage.setItem(key, serialized); + } catch (err) { + console.error(`Failed to serialize data for storage key "${key}":`, err); + } + } + + /** + * Clear multiple specific keys. Useful for logout routines. + */ + clearKeys(keys: StorageIdentifier[]): void { + if (!this.isBrowser) return; + keys.forEach((key) => localStorage.removeItem(key)); + } + + /** + * Clear ALL Shopstr-related storage. + */ + clearAll(): void { + if (!this.isBrowser) return; + localStorage.clear(); + sessionStorage.clear(); + } + + // Session Storage Helpers (for non-persistent session data) + + setSessionItem(key: StorageIdentifier, value: string): void { + if (!this.isBrowser) return; + sessionStorage.setItem(key, value); + } + + getSessionItem(key: StorageIdentifier): string | null { + if (!this.isBrowser) return null; + return sessionStorage.getItem(key); + } + + /** + * Get and parse JSON data from sessionStorage + */ + getSessionJson( + key: StorageIdentifier, + fallback: T, + options?: StorageParseOptions + ): T { + if (!this.isBrowser) return fallback; + const raw = sessionStorage.getItem(key); + return parseJsonWithFallback(raw, fallback, { + ...options, + onError: (context) => { + const contextWithKey = { ...context, key }; + options?.onError?.(contextWithKey); + + if (context.reason === "parse_error" && options?.removeOnError) { + sessionStorage.removeItem(key); + } + + if ( + context.reason === "validation_mismatch" && + options?.removeOnValidationError + ) { + sessionStorage.removeItem(key); + } + + console.warn(`SessionStorage parse error for key "${key}":`, context); + }, + }); + } + + /** + * Stringify and set JSON data in sessionStorage + */ + setSessionJson(key: StorageIdentifier, value: T): void { + if (!this.isBrowser) return; + try { + const serialized = JSON.stringify(value); + sessionStorage.setItem(key, serialized); + } catch (err) { + console.error(`Failed to serialize session data for key "${key}":`, err); + } + } + + /** + * Remove an item from sessionStorage + */ + removeSessionItem(key: StorageIdentifier): void { + if (!this.isBrowser) return; + sessionStorage.removeItem(key); + } +} + +export const storage = new StorageManager(); diff --git a/utils/storefront-cart.ts b/utils/storefront-cart.ts index a89eb5f07..faf72c0d1 100644 --- a/utils/storefront-cart.ts +++ b/utils/storefront-cart.ts @@ -1,14 +1,18 @@ -import { getLocalStorageJson } from "./safe-json"; +import { storage, STORAGE_KEYS } from "./storage"; type StorefrontCartItem = { pubkey?: string; }; export const getStorefrontCartQuantity = (sellerPubkey = "") => { - const cartItems = getLocalStorageJson("cart", [], { - removeOnError: true, - validate: Array.isArray, - }); + const cartItems = storage.getJson( + STORAGE_KEYS.CART, + [], + { + removeOnError: true, + validate: Array.isArray, + } + ); if (!sellerPubkey) { return cartItems.length;