Harden onion boot flow and deepen site surfaces
Add persistent onion key backup and restore, improve startup resilience, and flesh out the major site verticals with richer navigation, search coverage, and operator documentation. Made-with: Cursor
This commit is contained in:
118
contexts/AccountContext.tsx
Normal file
118
contexts/AccountContext.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
clearSession,
|
||||
getPublicProfile,
|
||||
readSession,
|
||||
registerAccount,
|
||||
setSession,
|
||||
updateDisplayName,
|
||||
verifyCredentials,
|
||||
type PublicCredentials,
|
||||
} from "@/lib/cyberluxAccount";
|
||||
|
||||
export type AccountContextValue = {
|
||||
user: PublicCredentials | null;
|
||||
hydrated: boolean;
|
||||
signIn: (username: string, password: string) => Promise<{ ok: true } | { ok: false; error: string }>;
|
||||
signUp: (
|
||||
username: string,
|
||||
password: string,
|
||||
displayName?: string,
|
||||
) => Promise<{ ok: true } | { ok: false; error: string }>;
|
||||
signOut: () => void;
|
||||
refreshProfile: () => void;
|
||||
saveDisplayName: (displayName: string) => { ok: true } | { ok: false; error: string };
|
||||
};
|
||||
|
||||
const AccountContext = createContext<AccountContextValue | undefined>(undefined);
|
||||
|
||||
export function useAccount(): AccountContextValue {
|
||||
const ctx = useContext(AccountContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAccount must be used within AccountProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function AccountProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<PublicCredentials | null>(null);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
const refreshProfile = useCallback(() => {
|
||||
const s = readSession();
|
||||
if (!s) {
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
const p = getPublicProfile(s.username);
|
||||
setUser(p);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshProfile();
|
||||
setHydrated(true);
|
||||
}, [refreshProfile]);
|
||||
|
||||
const signIn = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
const res = await verifyCredentials(username, password);
|
||||
if (!res.ok) return res;
|
||||
setSession(res.profile.username);
|
||||
setUser(res.profile);
|
||||
return { ok: true as const };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const signUp = useCallback(async (username: string, password: string, displayName?: string) => {
|
||||
const reg = await registerAccount(username, password, displayName);
|
||||
if (!reg.ok) return reg;
|
||||
const res = await verifyCredentials(username, password);
|
||||
if (!res.ok) return { ok: false, error: "Account created but sign-in failed — try again." };
|
||||
setSession(res.profile.username);
|
||||
setUser(res.profile);
|
||||
return { ok: true as const };
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
clearSession();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const saveDisplayName = useCallback(
|
||||
(displayName: string): { ok: true } | { ok: false; error: string } => {
|
||||
if (!user) return { ok: false, error: "Not signed in." };
|
||||
const dn = displayName.trim();
|
||||
if (dn.length < 2) return { ok: false, error: "Display name needs at least 2 characters." };
|
||||
if (!updateDisplayName(user.username, dn)) return { ok: false, error: "Could not update profile." };
|
||||
setUser({ ...user, displayName: dn });
|
||||
return { ok: true };
|
||||
},
|
||||
[user],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
hydrated,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
refreshProfile,
|
||||
saveDisplayName,
|
||||
}),
|
||||
[user, hydrated, signIn, signUp, signOut, refreshProfile, saveDisplayName],
|
||||
);
|
||||
|
||||
return <AccountContext.Provider value={value}>{children}</AccountContext.Provider>;
|
||||
}
|
||||
149
contexts/CartContext.tsx
Normal file
149
contexts/CartContext.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { ShopCurrency, ShopProduct } from "@/lib/shopCatalog";
|
||||
|
||||
const CART_KEY = "cyberlux-cart-v1";
|
||||
|
||||
export type CartLine = {
|
||||
productId: string;
|
||||
qty: number;
|
||||
name: string;
|
||||
price: number;
|
||||
currency: ShopCurrency;
|
||||
};
|
||||
|
||||
type CartContextValue = {
|
||||
lines: CartLine[];
|
||||
itemCount: number;
|
||||
hydrated: boolean;
|
||||
addToCart: (product: ShopProduct, qty?: number) => void;
|
||||
removeLine: (productId: string) => void;
|
||||
setLineQty: (productId: string, qty: number) => void;
|
||||
clearCart: () => void;
|
||||
};
|
||||
|
||||
const CartContext = createContext<CartContextValue | undefined>(undefined);
|
||||
|
||||
function loadRaw(): CartLine[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(CART_KEY);
|
||||
if (!raw) return [];
|
||||
const v = JSON.parse(raw) as CartLine[];
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter(
|
||||
(row) =>
|
||||
row &&
|
||||
typeof row.productId === "string" &&
|
||||
typeof row.qty === "number" &&
|
||||
row.qty > 0 &&
|
||||
typeof row.name === "string" &&
|
||||
typeof row.price === "number" &&
|
||||
row.currency &&
|
||||
["BTC", "ETH", "USD", "XMR"].includes(row.currency),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persist(lines: CartLine[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(CART_KEY, JSON.stringify(lines));
|
||||
}
|
||||
|
||||
export function CartProvider({ children }: { children: ReactNode }) {
|
||||
const [lines, setLines] = useState<CartLine[]>([]);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLines(loadRaw());
|
||||
setHydrated(true);
|
||||
}, []);
|
||||
|
||||
const flush = useCallback((next: CartLine[]) => {
|
||||
persist(next);
|
||||
setLines(next);
|
||||
}, []);
|
||||
|
||||
const addToCart = useCallback((product: ShopProduct, qty = 1) => {
|
||||
const q = Math.max(1, Math.min(99, qty));
|
||||
const cur = loadRaw();
|
||||
const i = cur.findIndex((l) => l.productId === product.id);
|
||||
let next: CartLine[];
|
||||
if (i >= 0) {
|
||||
next = [...cur];
|
||||
next[i] = { ...next[i]!, qty: Math.min(99, next[i]!.qty + q) };
|
||||
} else {
|
||||
next = [
|
||||
...cur,
|
||||
{
|
||||
productId: product.id,
|
||||
qty: q,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
},
|
||||
];
|
||||
}
|
||||
persist(next);
|
||||
setLines(next);
|
||||
}, []);
|
||||
|
||||
const removeLine = useCallback((productId: string) => {
|
||||
const next = loadRaw().filter((l) => l.productId !== productId);
|
||||
persist(next);
|
||||
setLines(next);
|
||||
}, []);
|
||||
|
||||
const setLineQty = useCallback((productId: string, qty: number) => {
|
||||
const q = Math.floor(qty);
|
||||
if (q < 1) {
|
||||
const next = loadRaw().filter((l) => l.productId !== productId);
|
||||
persist(next);
|
||||
setLines(next);
|
||||
return;
|
||||
}
|
||||
const capped = Math.min(99, q);
|
||||
const next = loadRaw().map((l) => (l.productId === productId ? { ...l, qty: capped } : l));
|
||||
persist(next);
|
||||
setLines(next);
|
||||
}, []);
|
||||
|
||||
const clearCart = useCallback(() => {
|
||||
persist([]);
|
||||
setLines([]);
|
||||
}, []);
|
||||
|
||||
const itemCount = useMemo(() => lines.reduce((s, l) => s + l.qty, 0), [lines]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
lines,
|
||||
itemCount,
|
||||
hydrated,
|
||||
addToCart,
|
||||
removeLine,
|
||||
setLineQty,
|
||||
clearCart,
|
||||
}),
|
||||
[lines, itemCount, hydrated, addToCart, removeLine, setLineQty, clearCart],
|
||||
);
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCart(): CartContextValue {
|
||||
const ctx = useContext(CartContext);
|
||||
if (!ctx) throw new Error("useCart must be used within CartProvider");
|
||||
return ctx;
|
||||
}
|
||||
196
contexts/WalletContext.tsx
Normal file
196
contexts/WalletContext.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState, ReactNode } from "react";
|
||||
import type { VaultReceipt, VaultState } from "@/lib/metaGame";
|
||||
import { addKey, addReceipt, defaultVaultState, loadVaultState, saveVaultState, setFlag } from "@/lib/metaGame";
|
||||
import {
|
||||
loadClaimedTxids,
|
||||
loadUsdStoreCredit,
|
||||
saveClaimedTxids,
|
||||
saveUsdStoreCredit,
|
||||
} from "@/lib/storeCreditStorage";
|
||||
|
||||
interface WalletContextType {
|
||||
isConnected: boolean;
|
||||
address: string | null;
|
||||
chainId: number | null;
|
||||
connect: () => Promise<void>;
|
||||
disconnect: () => void;
|
||||
balance: string;
|
||||
luxCredits: number;
|
||||
usdStoreCredit: number;
|
||||
spendLuxCredits: (amount: number) => boolean;
|
||||
earnLuxCredits: (amount: number) => void;
|
||||
spendUsdStoreCredit: (amount: number) => boolean;
|
||||
verifyBtcDeposit: (txid: string) => Promise<{ ok: boolean; error?: string; creditedUsd?: number }>;
|
||||
vault: VaultState;
|
||||
collectKey: (key: string) => boolean;
|
||||
addVaultReceipt: (receipt: VaultReceipt) => void;
|
||||
setVaultFlag: (flag: string, value: boolean) => void;
|
||||
}
|
||||
|
||||
const WalletContext = createContext<WalletContextType | undefined>(undefined);
|
||||
|
||||
export const useWallet = () => {
|
||||
const context = useContext(WalletContext);
|
||||
if (!context) {
|
||||
throw new Error("useWallet must be used within WalletProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface WalletProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const WalletProvider = ({ children }: WalletProviderProps) => {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [address, setAddress] = useState<string | null>(null);
|
||||
const [chainId, setChainId] = useState<number | null>(null);
|
||||
const [balance, setBalance] = useState("0.0");
|
||||
const [luxCredits, setLuxCredits] = useState(0);
|
||||
const [usdStoreCredit, setUsdStoreCredit] = useState(0);
|
||||
const [usdHydrated, setUsdHydrated] = useState(false);
|
||||
const [vault, setVault] = useState<VaultState>(() => defaultVaultState());
|
||||
|
||||
const mockAddress = "0x71C7...e4a2";
|
||||
const mockChainId = 1;
|
||||
const mockBalance = "2.45";
|
||||
const startingLuxCredits = 5000;
|
||||
|
||||
const connect = async () => {
|
||||
// Simulate wallet connection delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
setIsConnected(true);
|
||||
setAddress(mockAddress);
|
||||
setChainId(mockChainId);
|
||||
setBalance(mockBalance);
|
||||
setLuxCredits(startingLuxCredits);
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
setIsConnected(false);
|
||||
setAddress(null);
|
||||
setChainId(null);
|
||||
setBalance("0.0");
|
||||
setLuxCredits(0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setVault(loadVaultState());
|
||||
setUsdStoreCredit(loadUsdStoreCredit());
|
||||
setUsdHydrated(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
saveVaultState(vault);
|
||||
}, [vault]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!usdHydrated) return;
|
||||
saveUsdStoreCredit(usdStoreCredit);
|
||||
}, [usdStoreCredit, usdHydrated]);
|
||||
|
||||
const spendLuxCredits = (amount: number) => {
|
||||
const safeAmount = Number.isFinite(amount) ? Math.max(0, Math.floor(amount)) : 0;
|
||||
if (safeAmount <= 0) return true;
|
||||
if (luxCredits < safeAmount) return false;
|
||||
setLuxCredits((c) => c - safeAmount);
|
||||
return true;
|
||||
};
|
||||
|
||||
const earnLuxCredits = (amount: number) => {
|
||||
const safeAmount = Number.isFinite(amount) ? Math.max(0, Math.floor(amount)) : 0;
|
||||
if (safeAmount <= 0) return;
|
||||
setLuxCredits((c) => c + safeAmount);
|
||||
};
|
||||
|
||||
const spendUsdStoreCredit = (amount: number) => {
|
||||
const safe = Number.isFinite(amount) ? Math.max(0, Math.round(amount * 100) / 100) : 0;
|
||||
if (safe <= 0) return true;
|
||||
if (usdStoreCredit + 1e-9 < safe) return false;
|
||||
setUsdStoreCredit((u) => Math.round((u - safe) * 100) / 100);
|
||||
return true;
|
||||
};
|
||||
|
||||
const verifyBtcDeposit = async (txid: string) => {
|
||||
const normalized = String(txid || "").trim();
|
||||
if (!/^[a-fA-F0-9]{64}$/.test(normalized)) {
|
||||
return { ok: false, error: "Enter a valid 64-character transaction id" };
|
||||
}
|
||||
const claimed = loadClaimedTxids();
|
||||
if (claimed.includes(normalized)) {
|
||||
return { ok: false, error: "This transaction was already used to add credit" };
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/btc/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ txid: normalized }),
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
creditedUsd?: number;
|
||||
};
|
||||
if (!data.ok || typeof data.creditedUsd !== "number") {
|
||||
return { ok: false, error: data.error || "Verification failed" };
|
||||
}
|
||||
claimed.push(normalized);
|
||||
saveClaimedTxids(claimed);
|
||||
const add = Math.round(data.creditedUsd * 100) / 100;
|
||||
setUsdStoreCredit((u) => Math.round((u + add) * 100) / 100);
|
||||
return { ok: true, creditedUsd: add };
|
||||
} catch {
|
||||
return { ok: false, error: "Network error while verifying" };
|
||||
}
|
||||
};
|
||||
|
||||
const collectKey = (key: string) => {
|
||||
const trimmed = String(key || "").trim();
|
||||
if (!trimmed) return false;
|
||||
if (vault.keys.includes(trimmed)) return false;
|
||||
setVault((v) => addKey(v, trimmed));
|
||||
return true;
|
||||
};
|
||||
|
||||
const addVaultReceipt = (receipt: VaultReceipt) => {
|
||||
setVault((v) => addReceipt(v, receipt));
|
||||
};
|
||||
|
||||
const setVaultFlag = (flag: string, value: boolean) => {
|
||||
const f = String(flag || "").trim();
|
||||
if (!f) return;
|
||||
setVault((v) => setFlag(v, f, Boolean(value)));
|
||||
};
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
isConnected,
|
||||
address,
|
||||
chainId,
|
||||
connect,
|
||||
disconnect,
|
||||
balance,
|
||||
luxCredits,
|
||||
usdStoreCredit,
|
||||
spendLuxCredits,
|
||||
earnLuxCredits,
|
||||
spendUsdStoreCredit,
|
||||
verifyBtcDeposit,
|
||||
vault,
|
||||
collectKey,
|
||||
addVaultReceipt,
|
||||
setVaultFlag,
|
||||
}),
|
||||
[isConnected, address, chainId, balance, luxCredits, usdStoreCredit, vault],
|
||||
);
|
||||
|
||||
return (
|
||||
<WalletContext.Provider
|
||||
value={value}
|
||||
>
|
||||
{children}
|
||||
</WalletContext.Provider>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user