Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions components/nav-top.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +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";

const TopNav = ({
setFocusedPubkey,
Expand Down Expand Up @@ -44,11 +45,14 @@ const TopNav = ({

useEffect(() => {
const fetchAndUpdateCartQuantity = async () => {
const cartList = localStorage.getItem("cart")
? JSON.parse(localStorage.getItem("cart") as string)
: [];
if (cartList) {
const cartList = getLocalStorageJson<unknown[]>("cart", [], {
removeOnError: true,
validate: Array.isArray,
});
if (cartList.length > 0) {
setCartQuantity(cartList.length);
} else {
setCartQuantity(0);
}
};

Expand Down
38 changes: 33 additions & 5 deletions components/utility-components/checkout-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,28 @@ import WeightSelector from "./weight-selector";
import BulkSelector from "./bulk-selector";
import ZapsnagButton from "@/components/ZapsnagButton";
import { RawEventModal, EventIdModal } from "./modals/event-modals";
import { getLocalStorageJson } from "@/utils/safe-json";

const SUMMARY_CHARACTER_LIMIT = 100;
type CartDiscountsMap = Record<string, { code: string; percentage: number }>;

const isCartDiscountsMap = (value: unknown): value is CartDiscountsMap => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}

return Object.values(value).every((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}

const candidate = entry as { code?: unknown; percentage?: unknown };
return (
typeof candidate.code === "string" &&
typeof candidate.percentage === "number"
);
});
};

export default function CheckoutCard({
productData,
Expand Down Expand Up @@ -177,9 +197,10 @@ export default function CheckoutCard({

useEffect(() => {
if (typeof window !== "undefined") {
const cartList = localStorage.getItem("cart")
? JSON.parse(localStorage.getItem("cart") as string)
: [];
const cartList = getLocalStorageJson<ProductData[]>("cart", [], {
removeOnError: true,
validate: Array.isArray,
});
if (cartList && cartList.length > 0) {
setCart(cartList);
}
Expand Down Expand Up @@ -345,8 +366,15 @@ export default function CheckoutCard({

// Store discount code if applied
if (appliedDiscount > 0 && discountCode) {
const storedDiscounts = localStorage.getItem("cartDiscounts");
const discounts = storedDiscounts ? JSON.parse(storedDiscounts) : {};
const discounts = getLocalStorageJson<CartDiscountsMap>(
"cartDiscounts",
{},
{
removeOnError: true,
removeOnValidationError: true,
validate: isCartDiscountsMap,
}
);
discounts[productData.pubkey] = {
code: discountCode,
percentage: appliedDiscount,
Expand Down
2 changes: 1 addition & 1 deletion components/wallet/transactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { Transaction } from "@/utils/types/types";
// add found proofs as nutsack deposit with different icon

const Transactions = () => {
const [history, setHistory] = useState([]);
const [history, setHistory] = useState<Transaction[]>([]);

useEffect(() => {
// Function to fetch and update transactions
Expand Down
103 changes: 62 additions & 41 deletions pages/cart/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import currencySelection from "../../public/currencySelection.json";
import { ShopMapContext, ProfileMapContext } from "@/utils/context/context";
import { nip19 } from "nostr-tools";
import StorefrontThemeWrapper from "@/components/storefront/storefront-theme-wrapper";
import { getLocalStorageJson } from "@/utils/safe-json";

interface QuantitySelectorProps {
value: number;
Expand All @@ -40,6 +41,26 @@ interface QuantitySelectorProps {
max: number;
}

type CartDiscountsMap = Record<string, { code: string; percentage: number }>;

const isCartDiscountsMap = (value: unknown): value is CartDiscountsMap => {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}

return Object.values(value).every((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}

const candidate = entry as { code?: unknown; percentage?: unknown };
return (
typeof candidate.code === "string" &&
typeof candidate.percentage === "number"
);
});
};

function QuantitySelector({
value,
onDecrease,
Expand Down Expand Up @@ -196,13 +217,10 @@ export default function Component() {
sessionStorage.getItem("sf_seller_pubkey") ||
localStorage.getItem("sf_seller_pubkey") ||
"";
let fullCart: ProductData[] = [];
try {
const raw = localStorage.getItem("cart");
if (raw) fullCart = JSON.parse(raw);
} catch {
localStorage.removeItem("cart");
}
const fullCart = getLocalStorageJson<ProductData[]>("cart", [], {
removeOnError: true,
validate: Array.isArray,
});

let cartList = fullCart;
if (sfPk) {
Expand All @@ -224,21 +242,28 @@ export default function Component() {
}

// Load saved discount codes
const storedDiscounts = localStorage.getItem("cartDiscounts");
if (storedDiscounts) {
let discounts;
try {
discounts = JSON.parse(storedDiscounts);
} catch {
localStorage.removeItem("cartDiscounts");
return;
const discounts = getLocalStorageJson<CartDiscountsMap>(
"cartDiscounts",
{},
{
removeOnError: true,
removeOnValidationError: true,
validate: isCartDiscountsMap,
}
);
if (Object.keys(discounts).length > 0) {
const codes: { [pubkey: string]: string } = {};
const applied: { [pubkey: string]: number } = {};

Object.entries(discounts).forEach(([pubkey, data]: [string, any]) => {
codes[pubkey] = data.code;
applied[pubkey] = data.percentage;
Object.entries(discounts).forEach(([pubkey, data]) => {
if (!data || typeof data !== "object") return;
const code = (data as { code?: unknown }).code;
const percentage = (data as { percentage?: unknown }).percentage;
if (typeof code !== "string" || typeof percentage !== "number") {
return;
}
codes[pubkey] = code;
applied[pubkey] = percentage;
});

setDiscountCodes(codes);
Expand Down Expand Up @@ -336,13 +361,10 @@ export default function Component() {
};

const handleRemoveFromCart = (productId: string) => {
let cartContent: ProductData[] = [];
try {
const raw = localStorage.getItem("cart");
if (raw) cartContent = JSON.parse(raw);
} catch {
localStorage.removeItem("cart");
}
const cartContent = getLocalStorageJson<ProductData[]>("cart", [], {
removeOnError: true,
validate: Array.isArray,
});
if (cartContent.length > 0) {
const updatedCart = cartContent.filter(
(obj: ProductData) => obj.id !== productId
Expand Down Expand Up @@ -387,13 +409,15 @@ export default function Component() {
setDiscountErrors({ ...discountErrors, [pubkey]: "" });

// Save to localStorage
const storedDiscounts = localStorage.getItem("cartDiscounts");
let discounts: { [pubkey: string]: { code: string; percentage: number } } = {};
try {
if (storedDiscounts) discounts = JSON.parse(storedDiscounts);
} catch {
localStorage.removeItem("cartDiscounts");
}
const discounts = getLocalStorageJson<CartDiscountsMap>(
"cartDiscounts",
{},
{
removeOnError: true,
removeOnValidationError: true,
validate: isCartDiscountsMap,
}
);
discounts[pubkey] = {
code: code,
percentage: result.discount_percentage,
Expand Down Expand Up @@ -422,15 +446,12 @@ export default function Component() {
setDiscountErrors({ ...discountErrors, [pubkey]: "" });

// Remove from localStorage
const storedDiscounts = localStorage.getItem("cartDiscounts");
if (storedDiscounts) {
let discounts;
try {
discounts = JSON.parse(storedDiscounts);
} catch {
localStorage.removeItem("cartDiscounts");
return;
}
const discounts = getLocalStorageJson<CartDiscountsMap>("cartDiscounts", {}, {
removeOnError: true,
removeOnValidationError: true,
validate: isCartDiscountsMap,
});
if (Object.keys(discounts).length > 0) {
delete discounts[pubkey];
localStorage.setItem("cartDiscounts", JSON.stringify(discounts));
}
Expand Down
111 changes: 111 additions & 0 deletions utils/__tests__/safe-json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { getLocalStorageJson, parseJsonWithFallback } from "../safe-json";

describe("safe-json helpers", () => {
beforeEach(() => {
localStorage.clear();
jest.restoreAllMocks();
});

describe("parseJsonWithFallback", () => {
it("returns parsed value for valid JSON", () => {
const parsed = parseJsonWithFallback<{ ok: boolean }>(
'{"ok":true}',
{ ok: false }
);

expect(parsed).toEqual({ ok: true });
});

it("returns fallback for malformed JSON", () => {
const parsed = parseJsonWithFallback<string[]>("{bad", []);

expect(parsed).toEqual([]);
});

it("returns fallback when validator fails", () => {
const parsed = parseJsonWithFallback<string[]>(
"[1,2,3]",
[],
{
validate: (value): value is string[] =>
Array.isArray(value) && value.every((item) => typeof item === "string"),
}
);

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<string[]>("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<string[]>("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<string[]>("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" })
);
});
});
});
Loading