diff --git a/.gitignore b/.gitignore index 3e6a18b..580de8c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ !.github/workflows/build.yml app/app web/web +/venv \ No newline at end of file diff --git a/app/README.md b/app/README.md index 9dc80c7..bfd09e7 100644 --- a/app/README.md +++ b/app/README.md @@ -1 +1,6 @@ urnetwork-webmanager + +## Development + +Create an `.env` file and add `VITE_API_BASE=/api` +Run `npm run dev` to start the development server. diff --git a/app/src/components/AccountSettingsSection.tsx b/app/src/components/AccountSettingsSection.tsx index 5a159bb..ff8db50 100644 --- a/app/src/components/AccountSettingsSection.tsx +++ b/app/src/components/AccountSettingsSection.tsx @@ -1,10 +1,12 @@ -import React, { useState, useEffect } from 'react'; -import { Settings, Key, Copy, Clock, Users, AlertCircle, CheckCircle, Shield, Lock, CreditCard, ExternalLink } from 'lucide-react'; +import React, { useState, useEffect, useCallback } from 'react'; +import { Settings, Key, Copy, Clock, Users, AlertCircle, CheckCircle, Shield, Lock, CreditCard, ExternalLink, TicketSlash, TicketCheck } from 'lucide-react'; import { useAuth } from '../hooks/useAuth'; -import { createAuthCode, fetchNetworkUser } from '../services/api'; +import { createAuthCode, fetchNetworkTransferBalanceCodes, fetchNetworkUser } from '../services/api'; import type { CreateAuthCodeResponse } from '../services/api'; import toast from 'react-hot-toast'; import PasswordResetModal from './PasswordResetModal'; +import { RedeemedTransferBalanceCode } from '../services/types'; +import RedeemTransferBalanceCodeModal from './RedeemTransferBalanceCodeModal'; const AccountSettingsSection: React.FC = () => { const { token } = useAuth(); @@ -14,9 +16,32 @@ const AccountSettingsSection: React.FC = () => { const [authCodeResponse, setAuthCodeResponse] = useState(null); const [copiedToClipboard, setCopiedToClipboard] = useState(false); const [isPasswordResetModalOpen, setIsPasswordResetModalOpen] = useState(false); + const [transferBalanceCodes, setTransferBalanceCodes] = useState([]); + const [isAddTransferBalanceCodeModalOpen, setIsAddTransferBalanceCodeModalOpen] = useState(false); + const [isLoadingTransferBalanceCodes, setIsLoadingTransferBalanceCodes] = useState(true); const [userEmail, setUserEmail] = useState(''); const [isLoadingUserEmail, setIsLoadingUserEmail] = useState(true); + const loadTransferBalanceCodes = useCallback(async () => { + if (!token) { + setIsLoadingTransferBalanceCodes(false); + return; + } + + setIsLoadingTransferBalanceCodes(true); + + try { + const response = await fetchNetworkTransferBalanceCodes(token); + if (response.balance_codes) { + setTransferBalanceCodes(response.balance_codes); + } + } catch (error) { + console.error('Failed to fetch network transfer balance codes:', error); + } finally { + setIsLoadingTransferBalanceCodes(false); + } + }, [token]); + useEffect(() => { const loadUserEmail = async () => { if (!token) { @@ -37,6 +62,7 @@ const AccountSettingsSection: React.FC = () => { }; loadUserEmail(); + loadTransferBalanceCodes(); }, [token]); const handleGenerateAuthCode = async () => { @@ -101,6 +127,16 @@ const AccountSettingsSection: React.FC = () => { } }; + const maskSecret = (secret: string) => { + if (!secret || secret.length <= 6) return secret; + return `${secret.slice(0, 3)}...${secret.slice(-3)}`; + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleString(); + }; + return (
@@ -328,6 +364,62 @@ const AccountSettingsSection: React.FC = () => {
+ {/* Account transfer balance codes */} +
+
+
+ +
+

Account Transfer Balance Codes

+

Redeem a transfer balance code to add data to your account.

+
+
+
+ +
+ + + + + + + + + + {transferBalanceCodes.map((code) => ( + + + + + ))} + +
SecretRedeemed
{maskSecret(code.secret)}{formatDate(code.redeem_time)}
+ + {transferBalanceCodes.length === 0 && !isLoadingTransferBalanceCodes && ( +
+
+ +
+

No Transfer Balance Codes Redeemed

+

No transfer balances codes found for your network.

+
+ )} + + +
+
+ {/* Password Reset */}
@@ -408,6 +500,15 @@ const AccountSettingsSection: React.FC = () => { onClose={() => setIsPasswordResetModalOpen(false)} userEmail={userEmail} /> + + setIsAddTransferBalanceCodeModalOpen(false)} + onSuccess={() => { + // reload balance codes + loadTransferBalanceCodes(); + }} + />
); }; diff --git a/app/src/components/RedeemTransferBalanceCodeModal.tsx b/app/src/components/RedeemTransferBalanceCodeModal.tsx new file mode 100644 index 0000000..1f0621c --- /dev/null +++ b/app/src/components/RedeemTransferBalanceCodeModal.tsx @@ -0,0 +1,231 @@ +import React, { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { X, CheckCircle, TicketCheck } from "lucide-react"; +import { redeemTransferBalanceCode } from "../services/api"; +import toast from "react-hot-toast"; +import { useAuth } from "../hooks/useAuth"; + +interface PasswordResetModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +type ModalState = "initial" | "loading" | "success"; + +const RedeemTransferBalanceCodeModal: React.FC = ({ + isOpen, + onClose, + onSuccess, +}) => { + const { token } = useAuth(); + const modalRef = useRef(null); + const [state, setState] = useState("initial"); +// const [balanceCode, setBalanceCode] = useState(""); + const balanceCodeInputRef = useRef(null); + const [balanceCodeFocused, setBalanceCodeFocused] = useState(false); + const [isBalanceCodeValid, setIsBalanceCodeValid] = useState(false); + + + useEffect(() => { + if (isOpen) { + setState("initial"); + } + }, [isOpen]); + + useEffect(() => { + if (state === "success") { + const timer = setTimeout(() => { + onClose(); + }, 3000); + return () => clearTimeout(timer); + } + }, [state, onClose]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && isOpen && state !== "loading") { + onClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose, state]); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + modalRef.current && + !modalRef.current.contains(e.target as Node) && + isOpen && + state !== "loading" + ) { + onClose(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen, onClose, state]); + + const handleRedeemTransferBalance = async () => { + + if (!token) { + toast.error("You must be logged in to redeem a balance code."); + return + } + + const balanceCode = balanceCodeInputRef.current?.value?.toString().trim(); + + if (!balanceCode || balanceCode.length != 26) { + toast.error("Please enter a valid balance code."); + return; + } + + setState("loading"); + + try { + const response = await redeemTransferBalanceCode(balanceCode, token); + + if (response.error) { + toast.error(response.error.message); + setState("initial"); + } else { + setState("success"); + toast.success("Transfer balance redeemed!"); + onSuccess(); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to redeem balance code" + ); + setState("initial"); + } + }; + + if (!isOpen) return null; + + return createPortal( +
+
+
+
+
+ +
+

Redeem Transfer Balance Code

+
+ {state !== "loading" && ( + + )} +
+ +
+ {state === "success" ? ( +
+
+ +
+

+ Transfer Balance Code Redeemed Successfully +

+
+ ) : ( + <> +
+ + setBalanceCodeFocused(true)} + onBlur={() => setBalanceCodeFocused(false)} + onChange={(e) => + setIsBalanceCodeValid( + e.target.value.trim().length == 26, + ) + } + className={`w-full px-4 py-3 bg-gray-700 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-300 text-white placeholder-gray-400 ${ + balanceCodeFocused + ? "shadow-lg shadow-blue-500/20" + : "" + } ${ + isBalanceCodeValid + ? "border-green-500" + : "border-gray-600" + }`} + placeholder="Enter your transfer balance code" + disabled={state === "loading"} + required + /> + +

+ Redeeming transfer balance will add data credit to your network. +

+
+ +
+ + +
+ + )} +
+
+
, + document.body + ); +}; + +export default RedeemTransferBalanceCodeModal; diff --git a/app/src/services/api.ts b/app/src/services/api.ts index bac4caf..1b729a2 100644 --- a/app/src/services/api.ts +++ b/app/src/services/api.ts @@ -32,6 +32,8 @@ import type { AccountPoint, AccountPointsResponse, NetworkReliabilityResponse, + RedeemedTransferBalanceCodesResponse, + RedeemTransferBalanceCodeResponse, } from "./types"; const API_BASE_URL = import.meta.env.VITE_API_BASE ?? "https://api.bringyour.com"; @@ -879,6 +881,99 @@ export const fetchNetworkReliability = async ( } }; +/** + * Get network reliability statistics + * @param token - JWT authentication token + * @returns NetworkReliabilityResponse with reliability window data or error + */ +export const fetchNetworkTransferBalanceCodes = async ( + token: string +): Promise => { + try { + const response = await fetch(`${API_BASE_URL}/account/balance-codes`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + Accept: "*/*", + }, + }); + + if (!response.ok) { + return { + balance_codes: [], + error: { + message: `HTTP error! status: ${response.status}`, + }, + }; + } + + return await safeJsonParse(response); + } catch (error) { + console.error("Fetch network transfer balance codes error:", error); + return { + balance_codes: [], + error: { + message: + error instanceof Error + ? error.message + : "Failed to fetch network transfer balance codes", + }, + }; + } +}; + +/** + * Redeem transfer balance code + * @param balanceCode - The transfer balance code to redeem + * @returns PasswordResetResponse with error if failed + */ +export const redeemTransferBalanceCode = async ( + balanceCode: string, + token: string +): Promise => { + try { + const response = await fetch(`${API_BASE_URL}/subscription/redeem-balance-code`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + Accept: "*/*", + }, + body: JSON.stringify({ + secret: balanceCode, + }), + }); + + if (!response.ok) { + console.error( + "Redeem transfer balance code request failed:", + response.status, + response.statusText + ); + const errorData = await response.text(); + console.error("Error response:", errorData); + + return { + error: { + message: `HTTP error! status: ${response.status}`, + }, + }; + } + + return await safeJsonParse(response); + } catch (error) { + console.error("Redeem transfer balance code request error:", error); + return { + error: { + message: + error instanceof Error + ? error.message + : "Failed to redeem transfer balance code", + }, + }; + } +}; + // Export types for convenience export type { AuthResponse, diff --git a/app/src/services/types.ts b/app/src/services/types.ts index 255f8d0..f543cc5 100644 --- a/app/src/services/types.ts +++ b/app/src/services/types.ts @@ -537,3 +537,29 @@ export interface NetworkReliabilityResponse { /** Error information if request failed */ error?: { message: string }; } + +/** + * Network redeemed transfer balance code + */ +export interface RedeemedTransferBalanceCode { + balance_code_id: string; + balance_byte_count: number; + redeem_time: string; + end_time: string; + secret: string; +} + +/** + * Response for fetching network redeemed transfer balance codes + */ +export interface RedeemedTransferBalanceCodesResponse { + balance_codes: RedeemedTransferBalanceCode[]; + error?: { message: string }; +} + +/** + * Response from redeeming a transfer balance code + */ +export interface RedeemTransferBalanceCodeResponse { + error?: { message: string }; +} \ No newline at end of file