diff --git a/components/auth/ResendOtpForm.tsx b/components/auth/ResendOtpForm.tsx index dd6e0ea..efde9c4 100644 --- a/components/auth/ResendOtpForm.tsx +++ b/components/auth/ResendOtpForm.tsx @@ -12,6 +12,7 @@ interface ResendOtpFormProps { export default function ResendOtpForm({ email, onResent }: ResendOtpFormProps) { const [apiMessage, setApiMessage] = useState(null); const [loading, setLoading] = useState(false); + const url = process.env.NEXT_PUBLIC_BACKEND_URL const handleResend = async (e: React.FormEvent) => { e.preventDefault(); @@ -19,7 +20,7 @@ export default function ResendOtpForm({ email, onResent }: ResendOtpFormProps) { setLoading(true); try { const res = await fetch( - 'https://velo-node-backend.onrender.com/auth/resend-otp', + `${url}/auth/resend-otp`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/components/dashboard/service-flow.tsx b/components/dashboard/service-flow.tsx index e6b0ebd..302b8d6 100644 --- a/components/dashboard/service-flow.tsx +++ b/components/dashboard/service-flow.tsx @@ -1,22 +1,44 @@ "use client"; - import TextInput from "@/components/ui/TextInput"; import { motion, AnimatePresence } from "framer-motion"; import { Check, X, - Eye, - EyeOff, - Wifi, - Tv, PhoneCall, + Wifi, + Zap, ArrowRight, - Fingerprint, - Link, + Loader2, + AlertCircle, } from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { TransactionPinDialog } from "../ui/transaction-pin-dialog"; +import { normalizeStarknetAddress } from "../lib/utils"; +import { useAuth } from "../context/AuthContext"; +import { useWalletData } from "../hooks"; +import useExchangeRates from "../hooks/useExchangeRate"; +import { AddressDropdown } from "../modals/addressDropDown"; import Image from "next/image"; -import React, { useEffect, useState } from "react"; +import { + apiClient, + type DataPlan, + type ElectricityCompany, + type MeterType, + type ExpectedAmount, + type SupportedNetwork, +} from "@/lib/api-client"; +import { validatePhoneNumber } from "@/lib/utils"; + +export enum Blockchain { + ETHEREUM = "ethereum", + BITCOIN = "bitcoin", + SOLANA = "solana", + STELLAR = "stellar", + POLKADOT = "polkadot", + STARKNET = "starknet", + USDT_ERC20 = "usdt-erc20", +} -type PurchaseType = "airtime" | "data" | "tv"; +type PurchaseType = "airtime" | "data" | "electricity"; interface PurchaseProps { type: PurchaseType; @@ -31,41 +53,53 @@ type TransactionData = { providerLogo: string; providerName: string; planName: string; + meterToken?: string; }; type Provider = { - service_id: string; - service_name: string; - logo: string; -}; - -type Variation = { - variation_id: string; - service_name: string; - service_id: string; - price: string; - data_plan?: string; - package_bouquet?: string; + serviceID: string; + name: string; + image: string; + code?: string; + minAmount?: number; + maxAmount?: number; }; export default function Purchase({ type }: PurchaseProps) { const [step, setStep] = useState(1); - const [otp, setOtp] = useState(["", "", "", ""]); const [loading, setLoading] = useState(false); const [success, setSuccess] = useState(null); - const [showOTPFull] = useState(false); - const [reference, setReference] = useState(""); + const [showPinDialog, setShowPinDialog] = useState(false); + const [isSending, setIsSending] = useState(false); + const [selectedToken, setSelectedToken] = useState("ethereum"); + const [toAddress, setToAddress] = useState(""); + const [txHash, setTxHash] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [verifyingMeter, setVerifyingMeter] = useState(false); + const [meterVerified, setMeterVerified] = useState(false); + const [meterVerificationMessage, setMeterVerificationMessage] = useState(""); const [formData, setFormData] = useState({ service_id: "", amount: "", customer_id: "", - variation: null as Variation | null, + meterType: "prepaid" as "prepaid" | "postpaid", + dataplan: null as DataPlan | null, + expectedAmount: null as ExpectedAmount | null, transactionData: null as TransactionData | null, + phoneNo: "", }); + const { sendTransaction } = useAuth(); + const { rates } = useExchangeRates(); + const { addresses, balances } = useWalletData(); + const [providers, setProviders] = useState([]); - const [variations, setVariations] = useState([]); + const [dataPlans, setDataPlans] = useState([]); + const [electricityCompanies, setElectricityCompanies] = useState< + ElectricityCompany[] + >([]); + const [meterTypes, setMeterTypes] = useState([]); const presetAmounts = [100, 200, 500, 1000, 2000, 5000]; @@ -77,9 +111,11 @@ export default function Purchase({ type }: PurchaseProps) { step1Title: "Select network", step1Description: "Choose your network provider", customerLabel: "Phone Number", - placeholder: "+2348012345678", + placeholder: "8012345678", showAmountGrid: true, showVariations: false, + showMeterType: false, + showVerifyButton: false, }, data: { title: "Purchase Data", @@ -87,296 +123,469 @@ export default function Purchase({ type }: PurchaseProps) { step1Title: "Select network", step1Description: "Choose your network provider", customerLabel: "Phone Number", - placeholder: "Enter phone number", + placeholder: "8012345678", showAmountGrid: false, showVariations: true, + showMeterType: false, + showVerifyButton: false, }, - tv: { - title: "Cable Subscription", - icon: Tv, + electricity: { + title: "Electricity Bill", + icon: Zap, step1Title: "Select provider", - step1Description: "Choose your TV service provider", - customerLabel: "Smartcard Number", - placeholder: "Enter smartcard number", - showAmountGrid: false, - showVariations: true, + step1Description: "Choose your electricity provider", + customerLabel: "Meter Number", + placeholder: "Enter meter number", + showAmountGrid: true, + showVariations: false, + showMeterType: true, + showVerifyButton: true, }, }; - return config[type]; }; + const getToAddress = (chain: string) => { + const walletMap: { [key: string]: string | undefined } = { + ethereum: process.env.NEXT_PUBLIC_ETH_WALLET, + bitcoin: process.env.NEXT_PUBLIC_BTC_WALLET, + solana: process.env.NEXT_PUBLIC_SOL_WALLET, + stellar: process.env.NEXT_PUBLIC_XLM_WALLET, + polkadot: process.env.NEXT_PUBLIC_DOT_WALLET, + starknet: process.env.NEXT_PUBLIC_STRK_WALLET, + "usdt-erc20": process.env.NEXT_PUBLIC_USDT_WALLET, + }; + return walletMap[chain] || ""; + }; + const config = getConfig(); - // Get dynamic header based on step and user selection - const getHeaderTitle = () => { - if (step === 1) return config.step1Title; - if (step === 2) return "Summary"; - if (step === 3) return "Enter PIN"; - if (step === 4) - return success ? "Transaction Successful" : "Transaction Failed"; - return config.title; + const resetForm = useCallback(() => { + setToAddress(""); + setTxHash(""); + setErrorMessage(""); + setMeterVerified(false); + setMeterVerificationMessage(""); + }, []); + + const handleTokenSelect = useCallback((chain: string) => { + setSelectedToken(chain); + setToAddress(getToAddress(chain.toLowerCase())); + // resetForm(); + }, []); + + // Fetch providers based on type + const fetchProviders = async () => { + setLoading(true); + try { + if (type === "electricity") { + const { companies } = await apiClient.getElectricitySupportedOptions(); + setElectricityCompanies(companies); + + const mappedProviders: Provider[] = companies.map((company) => ({ + serviceID: company.value, + name: company.label, + image: `/img/${company.value}.png`, + code: company.code, + minAmount: company.minAmount, + maxAmount: company.maxAmount, + })); + setProviders(mappedProviders); + + // console.log("providers xxxxxxxxx ", mappedProviders); + } else { + // For airtime and data + const networks = + type === "data" + ? await apiClient.getDataSupportedNetworks() + : await apiClient.getAirtimeSupportedNetworks(); + + const mappedProviders: Provider[] = networks.map((network) => ({ + serviceID: network.value, + name: network.label, + image: `/img/${network.value.toLowerCase()}.png`, // Ensure lowercase + })); + setProviders(mappedProviders); + + // console.log("Airtime/Data providers loaded:", mappedProviders); + } + } catch (error) { + console.error("Failed to fetch providers:", error); + setErrorMessage("Failed to load providers. Please try again."); + } finally { + setLoading(false); + } }; - // Fetch providers on mount - useEffect(() => { - const fetchProviders = async () => { - setLoading(true); - try { - const mockProviders: Provider[] = [ - { service_id: "mtn", service_name: "MTN", logo: "/img/mtn.png" }, - { - service_id: "airtel", - service_name: "Airtel", - logo: "/img/airtel.png", - }, - { service_id: "glo", service_name: "Glo", logo: "/img/glo.png" }, - { - service_id: "9mobile", - service_name: "9mobile", - logo: "/img/9mobile.png", - }, - ]; - - const tvProviders: Provider[] = [ - { service_id: "dstv", service_name: "DSTV", logo: "/img/dstv.png" }, - { service_id: "gotv", service_name: "GOTV", logo: "/img/gotv.png" }, - { - service_id: "startimes", - service_name: "StarTimes", - logo: "/img/startimes.png", - }, - { - service_id: "showmax", - service_name: "Showmax", - logo: "/img/shomax.png", - }, - ]; - - setProviders(type === "tv" ? tvProviders : mockProviders); - } catch (error) { - console.error("Failed to fetch providers:", error); - } finally { - setLoading(false); + // Fetch data plans for selected network + const fetchDataPlans = async (network: string) => { + setLoading(true); + try { + const plans = await apiClient.getDataPlans(network, false); + setDataPlans(plans); + } catch (error) { + console.error("Failed to fetch data plans:", error); + setErrorMessage("Failed to load data plans. Please try again."); + } finally { + setLoading(false); + } + }; + + // Fetch meter types for electricity + const fetchMeterTypes = async () => { + setLoading(true); + try { + const { meterTypes: types } = + await apiClient.getElectricitySupportedOptions(); + setMeterTypes(types); + } catch (error) { + console.error("Failed to fetch meter types:", error); + } finally { + setLoading(false); + } + }; + + // Verify meter number + const handleVerifyMeter = async () => { + if (!formData.customer_id || !formData.service_id) { + setMeterVerificationMessage( + "Please enter meter number and select provider" + ); + return; + } + + setVerifyingMeter(true); + setMeterVerificationMessage(""); + + try { + const result = await apiClient.verifyElectricityMeter( + formData.service_id, + formData.customer_id + ); + + // console.log("results: ", result); + + // FIXED: Check the direct success property and data.valid + if (result.success && result.data && result.data.valid) { + setMeterVerified(true); + // console.log("meter", result.data); + + // Show customer name if available + const customerInfo = result.data.customerName + ? `✓ ${result.data.customerName} ` + : `✓ Meter verified: ${result.data.company}`; + + setMeterVerificationMessage(customerInfo); + } else { + setMeterVerified(false); + setMeterVerificationMessage(result.message || "✗ Invalid meter number"); } - }; + } catch (error: any) { + setMeterVerified(false); + setMeterVerificationMessage( + error.message || "Verification failed. Please try again." + ); + } finally { + setVerifyingMeter(false); + } + }; + + // Get expected crypto amount + const fetchExpectedAmount = async () => { + try { + let expectedAmount: ExpectedAmount; + if (type === "airtime") { + const amount = parseFloat(formData.amount); + expectedAmount = await apiClient.getAirtimeExpectedAmount( + amount, + selectedToken + ); + } else if (type === "electricity") { + const amount = parseFloat(formData.amount); + expectedAmount = await apiClient.getElectricityExpectedAmount( + amount, + selectedToken + ); + } else if (type === "data" && formData.dataplan) { + expectedAmount = await apiClient.getDataExpectedAmount( + formData.dataplan.dataplanId, + formData.service_id, + selectedToken + ); + } else { + throw new Error("Invalid purchase configuration"); + } + + setFormData((prev) => ({ ...prev, expectedAmount })); + } catch (error: any) { + console.error("Failed to fetch expected amount:", error); + setErrorMessage(error.message || "Failed to calculate crypto amount"); + } + }; + + // console.log("calidate phone number", validatePhoneNumber("08101843464")); + + useEffect(() => { fetchProviders(); + if (type === "electricity") { + fetchMeterTypes(); + } }, [type]); useEffect(() => { - if (!formData.service_id) return; + if (type === "data" && formData.service_id) { + fetchDataPlans(formData.service_id); + } + }, [type, formData.service_id]); - const fetchVariations = async () => { - setLoading(true); - try { - const mockVariations: Variation[] = - type === "data" - ? [ - { - variation_id: "1", - service_id: formData.service_id, - service_name: " 1 Day", - price: "100", - data_plan: "100MB ", - }, - { - variation_id: "2", - service_id: formData.service_id, - service_name: "7 Days", - price: "500", - data_plan: "1GB", - }, - { - variation_id: "3", - service_id: formData.service_id, - service_name: "30 Days", - price: "2000", - data_plan: "5GB", - }, - { - variation_id: "4", - service_id: formData.service_id, - service_name: "30 Days", - price: "2000", - data_plan: "5GB", - }, - { - variation_id: "5", - service_id: formData.service_id, - service_name: "30 Days", - price: "2000", - data_plan: "5GB", - }, - { - variation_id: "6", - service_id: formData.service_id, - service_name: "30 Days", - price: "2000", - data_plan: "5GB", - }, - ] - : [ - { - variation_id: "1", - service_id: formData.service_id, - service_name: "DStv Yanga", - price: "4000", - package_bouquet: "DStv Yanga - Entertainment", - }, - { - variation_id: "2", - service_id: formData.service_id, - service_name: "DStv Compact", - price: "10500", - package_bouquet: "DStv Compact - Premium", - }, - { - variation_id: "3", - service_id: formData.service_id, - service_name: "DStv Premium", - price: "24500", - package_bouquet: "DStv Premium - Ultimate", - }, - { - variation_id: "4", - service_id: formData.service_id, - service_name: "DStv Premium", - price: "24500", - package_bouquet: "DStv Premium - Ultimate", - }, - { - variation_id: "5", - service_id: formData.service_id, - service_name: "DStv Premium", - price: "24500", - package_bouquet: "DStv Premium - Ultimate", - }, - { - variation_id: "6", - service_id: formData.service_id, - service_name: "DStv Premium", - price: "24500", - package_bouquet: "DStv Premium - Ultimate", - }, - ]; - - setVariations(mockVariations); - } catch (error) { - console.error("Failed to fetch variations:", error); - } finally { - setLoading(false); + useEffect(() => { + if (step === 2 && selectedToken) { + fetchExpectedAmount(); + } + }, [step, selectedToken, formData.amount, formData.dataplan]); + + const currentWalletBalance = useMemo(() => { + const balanceInfo = balances.find((b) => b.chain === selectedToken); + return parseFloat(balanceInfo?.balance || "0"); + }, [balances, selectedToken]); + + const currentWalletAddress = useMemo(() => { + if (!addresses) return ""; + const addressInfo = addresses.find((addr) => addr.chain === selectedToken); + return addressInfo?.address || ""; + }, [addresses, selectedToken]); + + // console.log("current wallet", currentWalletAddress); + const currentNetwork = useMemo(() => { + if (!addresses) return "mainnet"; + const addressInfo = addresses.find((addr) => addr.chain === selectedToken); + return addressInfo?.network || "mainnet"; + }, [addresses, selectedToken]); + + const requiredCryptoAmount = useMemo(() => { + if (!formData.expectedAmount?.cryptoAmount) return 0; + + const amount = formData.expectedAmount.cryptoAmount; + // Always round UP to 7 decimal places + return Math.ceil(amount * 1e7) / 1e7; + }, [formData.expectedAmount]); + + const validationError = useMemo(() => { + if (!currentWalletAddress) { + return "No wallet found for this currency"; + } + if (!toAddress.trim()) { + return "Recipient address is required"; + } + if (!formData.amount || parseFloat(formData.amount) <= 0) { + return "Amount must be greater than 0"; + } + if (requiredCryptoAmount > currentWalletBalance) { + return "Insufficient balance"; + } + if (type === "electricity" && config.showVerifyButton && !meterVerified) { + return "Please verify meter number first"; + } + return null; + }, [ + currentWalletAddress, + toAddress, + formData.amount, + requiredCryptoAmount, + currentWalletBalance, + type, + config.showVerifyButton, + meterVerified, + ]); + + // console.log("validation Error", validationError); + + const handleSendWithPin = async (pin: string) => { + setErrorMessage(""); + + if (validationError) { + setErrorMessage(validationError); + setShowPinDialog(false); + return; + } + + setIsSending(true); + + try { + // Step 1: Send cryptocurrency transaction + let normalizedToAddress = toAddress.trim(); + + if (selectedToken === "starknet") { + try { + normalizedToAddress = normalizeStarknetAddress(toAddress, "starknet"); + } catch (error) { + throw new Error( + error instanceof Error + ? `Invalid Starknet address: ${error.message}` + : "Invalid Starknet address format" + ); + } + } + const transactionResponse = await sendTransaction({ + chain: selectedToken, + network: currentNetwork, + toAddress: normalizedToAddress, + amount: requiredCryptoAmount.toString(), + fromAddress: currentWalletAddress, + transactionPin: pin, + }); + + setTxHash(transactionResponse.txHash); + setShowPinDialog(false); + + // Step 2: Submit purchase to backend with transaction hash + await handleSubmitPurchase(transactionResponse.txHash); + + setStep(4); + } catch (error: any) { + console.error("Transaction error:", error); + let errMsg = "Failed to send transaction. Please try again."; + + if (error.message) { + errMsg = error.message; + } else if (typeof error === "string") { + errMsg = error; + } + + setErrorMessage(errMsg); + setShowPinDialog(false); + } finally { + setIsSending(false); + } + }; + + const handleSubmitPurchase = async (transactionHash: string) => { + setLoading(true); + + try { + let response; + + if (type === "airtime") { + response = await apiClient.purchaseAirtime({ + type: "airtime", + amount: parseFloat(formData.amount), + chain: selectedToken, + phoneNumber: validatePhoneNumber(formData.customer_id), + mobileNetwork: formData.service_id, + transactionHash, + }); + } else if (type === "data" && formData.dataplan) { + response = await apiClient.purchaseData({ + type: "data", + dataplanId: formData.dataplan.dataplanId, + amount: parseFloat(formData.dataplan.amount.replace(/[N₦,]/g, "")), + chain: selectedToken, + phoneNumber: validatePhoneNumber(formData.customer_id), + mobileNetwork: formData.service_id, + transactionHash, + }); + } else if (type === "electricity") { + response = await apiClient.purchaseElectricity({ + type: "electricity", + amount: parseFloat(formData.amount), + chain: selectedToken, + company: formData.service_id, + meterType: formData.meterType, + meterNumber: formData.customer_id, + phoneNumber: validatePhoneNumber(formData.phoneNo), + transactionHash, + }); + } else { + throw new Error("Invalid purchase type"); } - }; - if (config.showVariations) { - fetchVariations(); + if (response.success) { + setSuccess(true); + + const transactionData: TransactionData = { + dateTime: new Date().toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }), + paymentMethod: "Cashley", + status: "Completed", + description: response.message, + transactionId: response.data.purchaseId, + providerLogo: + providers.find((p) => p.serviceID === formData.service_id)?.image || + "", + providerName: + providers.find((p) => p.serviceID === formData.service_id)?.name || + "", + planName: + response.data.planName || + formData.dataplan?.name || + `₦${formData.amount}`, + meterToken: response.data.meterToken, + }; + + setFormData((prev) => ({ ...prev, transactionData })); + } else { + setSuccess(false); + setErrorMessage(response.message || "Purchase failed"); + } + } catch (error: any) { + console.error("Purchase error:", error); + setSuccess(false); + setErrorMessage(error.message || "Failed to complete purchase"); + } finally { + setLoading(false); } - }, [formData.service_id, type, config.showVariations]); + }; const handleBack = () => { if (step === 1) { window.history.back(); } else { setStep((prev) => prev - 1); + setErrorMessage(""); } }; const handleNext = () => { + setErrorMessage(""); setStep((prev) => prev + 1); }; - const handleNumberClick = (num: string) => { - if (num === "←") { - const lastFilledIndex = otp.reduce( - (acc, digit, idx) => (digit ? idx : acc), - -1 - ); - if (lastFilledIndex >= 0) { - const newOtp = [...otp]; - newOtp[lastFilledIndex] = ""; - setOtp(newOtp); - } - } else if (num === "✓") { - if (otp.every((d) => d)) { - handleSubmit(); - } - } else { - const emptyIndex = otp.findIndex((digit) => !digit); - if (emptyIndex !== -1) { - const newOtp = [...otp]; - newOtp[emptyIndex] = num; - setOtp(newOtp); - } + const handleConfirm = () => { + if (validationError) { + setErrorMessage(validationError); + return; } + setShowPinDialog(true); }; - const handleSubmit = async () => { - setLoading(true); - try { - await new Promise((resolve) => setTimeout(resolve, 2000)); - - // Create transaction data - const transactionData: TransactionData = { - dateTime: new Date().toLocaleString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "2-digit", - second: "2-digit", - }), - paymentMethod: "Cashley", - status: "Completed", - description: - type === "data" - ? "Data plan" - : type === "airtime" - ? "Airtime" - : "TV Subscription", - transactionId: `TRX${Date.now()}`, - providerLogo: - providers.find((p) => p.service_id === formData.service_id)?.logo || - "", - providerName: - providers.find((p) => p.service_id === formData.service_id) - ?.service_name || "", - planName: - formData.variation?.data_plan || - formData.variation?.package_bouquet || - "", - }; - - setSuccess(true); - setReference(transactionData.transactionId); - setFormData((prev) => ({ ...prev, transactionData })); - handleNext(); - } catch (error) { - setSuccess(false); - handleNext(); - } finally { - setLoading(false); - } + const data = { + serviceId: formData.service_id, + customerId: formData.customer_id, }; - // const handleReset = () => { - // setStep(1); - // setOtp(["", "", "", ""]); - // setSuccess(null); - // setFormData({ - // service_id: "", - // amount: "", - // customer_id: "", - // variation: null, - // }); - // }; + // console.log("data", data); const isStep1Valid = () => { if (!formData.service_id) return false; if (!formData.customer_id) return false; - if (config.showVariations && !formData.variation) return false; - if (config.showAmountGrid && !formData.amount) return false; + if (type === "data" && !formData.dataplan) return false; + if ((type === "airtime" || type === "electricity") && !formData.amount) + return false; + if ( + type === "electricity" && + config.showVerifyButton && + !meterVerified && + !formData.phoneNo + ) + return false; return true; }; @@ -392,68 +601,156 @@ export default function Purchase({ type }: PurchaseProps) { >

{config.step1Title}

+

{config.step1Description}

+ {/* Provider Selection */} - setFormData((prev) => ({ ...prev, service_id })) - } + onChange={(service_id: string) => { + setFormData((prev) => ({ + ...prev, + service_id, + dataplan: null, + amount: "", + })); + setMeterVerified(false); + setMeterVerificationMessage(""); + }} + loading={loading} /> + {/* Meter Type Selection (Electricity only) */} + {config.showMeterType && formData.service_id && ( + + setFormData((prev) => ({ ...prev, meterType })) + } + /> + )} + + {/* Data Plan Selection */} {config.showVariations && formData.service_id && ( - + setFormData((prev) => ({ ...prev, - variation, - amount: variation.price, + dataplan, + amount: dataplan.amount.replace(/[N₦,]/g, ""), })) } - type={type} + loading={loading} /> )} + {/* Amount Grid (Airtime & Electricity) */} {config.showAmountGrid && formData.service_id && ( + onChange={(amount: string) => setFormData((prev) => ({ ...prev, amount })) } presetAmounts={presetAmounts} + minAmount={ + providers.find((p) => p.serviceID === formData.service_id) + ?.minAmount + } + maxAmount={ + providers.find((p) => p.serviceID === formData.service_id) + ?.maxAmount + } /> )} + {/* Customer ID Input */} {formData.service_id && - (config.showAmountGrid || formData.variation) && ( + (config.showAmountGrid || formData.dataplan) && (
-
-
+234
- 234
+ )} + - setFormData((prev) => ({ ...prev, customer_id })) - } + onChange={(e) => { + setFormData((prev) => ({ + ...prev, + customer_id: e.target.value, + })); + setMeterVerified(false); + setMeterVerificationMessage(""); + }} placeholder={config.placeholder} type="tel" + className="flex-1 p-4 rounded-2xl border-none outline-none " + /> + {config.showVerifyButton && formData.customer_id && ( + + )} +
+
+ + { + setFormData((prev) => ({ + ...prev, + phoneNo: e.target.value, + })); + setMeterVerified(false); + setMeterVerificationMessage(""); + }} + placeholder="08123456789" + type="tel" + className="flex-1 p-4 rounded-2xl border-none outline-none " />
+ {meterVerificationMessage && ( +
+ {meterVerificationMessage} +
+ )}
)} + {errorMessage && ( +
+ + {errorMessage} +
+ )} + - - handleNumberClick("←")} - onConfirm={() => handleNumberClick("✓")} - disableConfirm={!otp.every((d) => d)} - loading={loading} + setShowPinDialog(false)} + onPinComplete={handleSendWithPin} + isLoading={isSending} + title="Authorize Transaction" + description="Enter your transaction PIN to confirm this purchase" /> ); case 4: return ( -
- + +
{success ? ( - + ) : ( - + )} @@ -615,188 +966,216 @@ export default function Purchase({ type }: PurchaseProps) {

{success ? "Transaction Successful" : "Transaction Failed"}

-

+

{success ? "Your transaction has been processed successfully" : "Something went wrong. Please try again."}

+
- Amount Sent + Amount Sent - ₦{parseInt(formData.amount).toLocaleString()} + ₦{parseInt(formData.amount || "0").toLocaleString()}
+
-
Beneficiary
+
+ Beneficiary +
-
- service provider -
{formData.customer_id}
+
+
+ provider +
+
+

+ {type === "electricity" + ? formData.customer_id + : `234${formData.customer_id}`} +

+

+ { + providers.find( + (p) => p.serviceID === formData.service_id + )?.name + } +

+
- +
{success && formData.transactionData && ( -
-
- Date & Time - - {formData.transactionData.dateTime} - -
-
- Payment method - - {formData.transactionData.paymentMethod} - -
-
- Status - - {formData.transactionData.status} - -
-
- Description - - {formData.transactionData.description} - -
-
- Transaction ID - - {formData.transactionData.transactionId} - -
+
+ + + + + + + {formData.transactionData.meterToken && ( +
+

+ Meter Token +

+

+ {formData.transactionData.meterToken} +

+
+ )} +
+ )} + + {!success && ( +
+

{errorMessage}

)} - -
- -
-
+ +
+
+ +
+
+ ); + + default: + return null; } }; return ( -
- {/* Content */} -
+
+
{renderStep()}
); } -// Sub-components -interface ProviderSelectProps { - providers: Provider[]; - value: string; - onChange: (service_id: string) => void; -} +// === MISSING SUB-COMPONENTS (Added) === -function ProviderSelect({ providers, value, onChange }: ProviderSelectProps) { - return ( -
-
- {providers.map((provider) => ( -
- onChange(provider.service_id)} - className={`flex flex-col items-center w-full rounded-2xl overflow-hidden transition-all bg-card`} +function DataPlanSelect({ + dataPlans, + value, + onSelect, + loading, +}: { + dataPlans: DataPlan[]; + value: DataPlan | null; + onSelect: (plan: DataPlan) => void; + loading: boolean; +}) { + if (loading) { + return ( +
+ +
+ {[...Array(4)].map((_, i) => ( +
-
-
- {provider.service_name} -
-
- - {provider.service_name} - - -
- ))} +
+
+ ))} +
-
- ); -} - -interface VariationSelectProps { - variations: Variation[]; - value: Variation | null; - onSelect: (variation: Variation) => void; - type: PurchaseType; -} + ); + } -function VariationSelect({ - variations, - value, - onSelect, - type, -}: VariationSelectProps) { return (
- -
- {variations.map((variation) => ( + +
+ {dataPlans.map((plan) => (
onSelect(variation)} - className={`w-full p-4 rounded-2xl text-left transition-all bg-card - `} + onClick={() => onSelect(plan)} + className="w-full p-4 rounded-2xl text-left transition-all " > -
+
-

- {type === "tv" - ? variation.package_bouquet - : variation.data_plan} -

-

- {variation.service_name} -

+

{plan.name}

+

{plan.validity}

- - ₦{parseInt(variation.price).toLocaleString()} + + ₦ + {parseInt(plan.amount.replace(/[N₦,]/g, "")).toLocaleString()}
@@ -807,216 +1186,277 @@ function VariationSelect({ ); } -interface AmountGridProps { - value: string; - onChange: (value: string) => void; - presetAmounts: number[]; -} - -function AmountGrid({ value, onChange, presetAmounts }: AmountGridProps) { +function MeterTypeSelect({ + meterTypes, + value, + onChange, +}: { + meterTypes: MeterType[]; + value: "prepaid" | "postpaid"; + onChange: (type: "prepaid" | "postpaid") => void; +}) { return (
- -
- {presetAmounts.map((amount) => ( + +
+ {meterTypes.map((type) => ( onChange(amount.toString())} - className={`p-4 rounded-2xl transition-all ${ - value === amount.toString() - ? "bg-primary/10 font-black" - : "bg-card" + onClick={() => onChange(type.value as "prepaid" | "postpaid")} + className={`p-4 rounded-2xl font-bold transition-all ${ + value === type.value + ? "ring-2 ring-blue-500 text-blue-700" + : " text-gray-700" }`} > - ₦{amount.toLocaleString()} + {type.label} ))}
- -
); } -function ReviewItem({ label, value }: { label: string; value: string }) { +// === REUSABLE TRANSACTION DETAIL COMPONENT === +function TransactionDetail({ + label, + value, + monospace = false, + link, + className = "", +}: { + label: string; + value: string; + monospace?: boolean; + link?: string; + className?: string; +}) { return ( -
- {label} - {value} +
+ {label} + {link ? ( + + {value.slice(0, 10)}...{value.slice(-8)} + + ) : ( + + {value} + + )}
); } - - - - -export function Keypad({ - numbers, - onNumberClick, - onDelete, - onConfirm, - disableConfirm, - loading, - step +// Sub-components +function ProviderSelect({ + providers, + value, + onChange, + loading, }: { - numbers: string[]; - onNumberClick: (num: string) => void; - onDelete: () => void; - onConfirm: () => void; - disableConfirm: boolean; - loading: boolean; - step?: number + providers: Provider[]; + value: string; + onChange: (service_id: string) => void; + loading: boolean; }) { + if (loading) { return ( -
- {numbers.map((num, idx) => { - if (num === "←") { - return ( - - ⌫ - - ); - } else if (num === "✓") { - return ( - - - - ); - } else if (num === "#" && step) { - return ( - - {step == 1 ? : } - - ); - } - else { - return ( - onNumberClick(num)} - className="text-lg w-20 h-20 rounded-full bg-card font-semibold mx-auto hover:bg-stone-100 transition-all shadow-sm" - > - {num} - - ); - } - })} +
+
+ {[...Array(4)].map((_, i) => ( +
+
+
+ ))}
+
); + } + return ( +
+
+ {providers.map((provider) => ( +
+ onChange(provider.serviceID)} + className="flex flex-col items-center w-full rounded-2xl overflow-hidden transition-all " + > +
+ {provider.name} { + // Fallback to a default image or show provider name + e.currentTarget.style.display = "none"; + e.currentTarget.parentElement!.innerHTML = `
${provider.name.substring( + 0, + 3 + )}
`; + }} + /> +
+ + {provider.name} + +
+
+ ))} +
+
+ ); } +function AmountGrid({ + value, + onChange, + presetAmounts, + minAmount, + maxAmount, +}: { + value: string; + onChange: (amount: string) => void; + presetAmounts: number[]; + minAmount?: number; + maxAmount?: number; +}) { + const numericValue = parseFloat(value) || 0; + const isBelowMin = + minAmount !== undefined && numericValue > 0 && numericValue < minAmount; + const isAboveMax = maxAmount !== undefined && numericValue > maxAmount; + const filteredPresets = presetAmounts.filter((amount) => { + if (minAmount !== undefined && amount < minAmount) return false; + if (maxAmount !== undefined && amount > maxAmount) return false; + return true; + }); -interface ButtonProps { - type?: - | "primary" - | "secondary" - | "blue" - | "orange" - | "purple" - | "card" - | undefined; - width?: string; - text?: string; - children?: React.ReactNode; - onclick?: React.MouseEventHandler; - loading?: boolean; - disabled?: boolean; - href?: string; -} + return ( +
+
+ + {(minAmount || maxAmount) && ( + + {minAmount && `Min: ₦${minAmount.toLocaleString()}`} + {minAmount && maxAmount && " • "} + {maxAmount && `Max: ₦${maxAmount.toLocaleString()}`} + + )} +
-export function Button({ - type, - width = "w-full", - text, - onclick, - children, - loading, - disabled = false, - href, -}: ButtonProps): React.JSX.Element { - const buttonClasses = ` - ${width} - ${type === "primary" - ? "bg-primary" - : type === "secondary" - ? "primary-orange-to-purple" - : type === "blue" - ? "bg-blue" - : type === "orange" - ? "bg-orange" - : type === "purple" - ? "bg-purple" - : type === "card" - ? "bg-card" - : "bg-transparent" - } - cursor-pointer - py-3 - rounded-3xl - text-white - font-bold - text-lg - relative - ${disabled || loading ? "opacity-50 cursor-not-allowed" : ""} - `.replace(/\s+/g, ' ').trim(); - - const LoadingOverlay = () => ( - -
- +
+ {filteredPresets.length > 0 ? ( + filteredPresets.map((amount) => ( + onChange(amount.toString())} + className={`p-4 rounded-2xl transition-all font-medium ${ + value === amount.toString() + ? "font-black ring-2 ring-blue-500" + : "" + }`} + > + ₦{amount.toLocaleString()} + + )) + ) : ( +

+ No preset amounts available +

+ )} +
+ +
+ { + const val = e.target.value; + if (val === "" || /^\d*$/.test(val)) { + onChange(val); + } + }} + placeholder="Enter custom amount" + type="text" + inputMode="numeric" + className={`w-full p-4 rounded-2xl border-2 pr-10 transition-all ${ + isBelowMin || isAboveMax ? "border-red-500 " : "border-transparent " + } outline-none`} + /> + {(isBelowMin || isAboveMax) && ( +
+ +
+ )} +
+ + {(isBelowMin || isAboveMax) && ( +

+ {isBelowMin + ? `Amount must be at least ₦${minAmount?.toLocaleString()}` + : `Amount cannot exceed ₦${maxAmount?.toLocaleString()}`} +

+ )} +
); +} - if (href && !disabled && !loading) { - return ( - - {text || children} - - ); - } +function ReviewItem({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} +function Button({ + onclick, + text, + disabled, + type, +}: { + onclick: () => void; + text: string; + disabled?: boolean; + type?: "secondary"; +}) { + const baseClasses = + "w-full py-4 rounded-full font-bold text-lg relative transition-all"; + const typeClasses = + type === "secondary" + ? "bg-gray-200 text-black hover:bg-gray-300" + : "0 text-white hover:bg-blue-600"; + const disabledClasses = disabled + ? "opacity-50 cursor-not-allowed" + : "cursor-pointer"; return ( ); -} \ No newline at end of file +} diff --git a/components/dashboard/tabs/airtime.tsx b/components/dashboard/tabs/airtime.tsx index b9bc6d6..44c5dea 100644 --- a/components/dashboard/tabs/airtime.tsx +++ b/components/dashboard/tabs/airtime.tsx @@ -1,7 +1,23 @@ -import React from 'react' +"use client" + +import React, { useEffect } from 'react' import Purchase from '../service-flow' export default function Airtime() { + + const fetchProvider = async () => { + try{ + const res = await fetch("https://www.nellobytesystems.com/APIAirtimeDiscountV2.asp?UserID=CK101265322") + const data = await res.json() + console.log(data) + }catch(err){ + console.log(err) + } + } + + useEffect(() => { + fetchProvider() + }, []) return ( ) diff --git a/components/dashboard/tabs/dashboard.tsx b/components/dashboard/tabs/dashboard.tsx index 7c29769..5878cfc 100644 --- a/components/dashboard/tabs/dashboard.tsx +++ b/components/dashboard/tabs/dashboard.tsx @@ -8,7 +8,6 @@ import { RecentActivity } from "../recent-activity"; import { WalletOverview } from "../wallet-overview"; import { useAuth } from "@/components/context/AuthContext"; import { useState } from "react"; -import { useWalletData } from "@/components/hooks/useWalletData"; interface RecentActivity { id: string; @@ -27,10 +26,8 @@ export interface DashboardProps { export default function DashboardHome({ activeTab }: DashboardProps) { const { user } = useAuth(); - const { addresses, } = useWalletData(); const [hideBalalance, setHideBalance] = useState(false); - const handleViewBalance = () => { setHideBalance(!hideBalalance); }; diff --git a/components/dashboard/tabs/electricity.tsx b/components/dashboard/tabs/electricity.tsx new file mode 100644 index 0000000..da29901 --- /dev/null +++ b/components/dashboard/tabs/electricity.tsx @@ -0,0 +1,24 @@ +"use client" + +import React, { useEffect } from 'react' +import Purchase from '../service-flow' + +export default function Electricity() { + + const fetchProvider = async () => { + try{ + const res = await fetch("https://www.nellobytesystems.com/APIAirtimeDiscountV2.asp?UserID=CK101265322") + const data = await res.json() + console.log(data) + }catch(err){ + console.log(err) + } + } + + useEffect(() => { + fetchProvider() + }, []) + return ( + + ) +} diff --git a/components/dashboard/tabs/services.tsx b/components/dashboard/tabs/services.tsx index 4f40112..88b8d0c 100644 --- a/components/dashboard/tabs/services.tsx +++ b/components/dashboard/tabs/services.tsx @@ -1,13 +1,13 @@ import React, { useState } from "react"; -import { PhoneCall, Wifi, Tv, Search, Zap } from "lucide-react"; +import { PhoneCall, Wifi, Tv, Search, Zap, Lightbulb } from "lucide-react"; import Airtime from "./airtime"; import Data from "./data"; -import TV from "./tv"; +import Electricity from "./electricity"; const TABS = [ { key: "Airtime", label: "Airtime", icon: PhoneCall }, { key: "Data", label: "Data", icon: Wifi }, - { key: "TV", label: "TV", icon: Tv }, + { key: "Electricity", label: "Electricity", icon: Lightbulb }, ]; export default function Services() { @@ -16,44 +16,60 @@ export default function Services() { return (
-
- {/* Left info / quick actions */} - */} {/* Main content */} -
+
@@ -61,13 +77,21 @@ export default function Services() {

{activeTab}

-

{activeTab === "Airtime" ? "Quick airtime purchases" : activeTab === "Data" ? "Flexible data plans" : "Cable subscriptions"}

+

+ {activeTab === "Airtime" + ? "Quick airtime purchases" + : activeTab === "Data" + ? "Flexible data plans" + : "Cable subscriptions"} +

Step -
1 of 3
+
+ 1 of 3 +
@@ -93,7 +117,7 @@ export default function Services() {
{activeTab === "Airtime" && } {activeTab === "Data" && } - {activeTab === "TV" && } + {activeTab === "Electricity" && }
diff --git a/components/dashboard/tabs/tv.tsx b/components/dashboard/tabs/tv.tsx deleted file mode 100644 index e6de392..0000000 --- a/components/dashboard/tabs/tv.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react' -import Purchase from '../service-flow' - -export default function TV() { - return ( - - ) -} diff --git a/components/hooks/useNotifications.ts b/components/hooks/useNotifications.ts index dab5396..de4346a 100644 --- a/components/hooks/useNotifications.ts +++ b/components/hooks/useNotifications.ts @@ -100,7 +100,7 @@ export const useNotifications = () => { const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); - console.log("XXXXXXXXXXXXXXXXXXXXXXXXXX",notifications) + // console.log("XXXXXXXXXXXXXXXXXXXXXXXXXX",notifications) // Use useRef to track shown notifications - persists across renders without causing re-renders const shownNotificationIds = useRef>(new Set()); diff --git a/components/hooks/useSilentQuery.ts b/components/hooks/useSilentQuery.ts index 2e410cd..f9458c2 100644 --- a/components/hooks/useSilentQuery.ts +++ b/components/hooks/useSilentQuery.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from "react"; interface UseSilentQueryOptions { ttl?: number; @@ -6,84 +6,101 @@ interface UseSilentQueryOptions { cacheKey: string; } -// Simple in-memory cache for this session only -const sessionCache = new Map(); +// Simple in-memory cache (per session) +const sessionCache = new Map< + string, + { data: any; timestamp: number; ttl: number } +>(); export function useSilentQuery( fetchFn: () => Promise, options: UseSilentQueryOptions ) { const { ttl = 5 * 60 * 1000, backgroundRefresh = true, cacheKey } = options; - + const [data, setData] = useState(null); const [error, setError] = useState(null); + const mountedRef = useRef(true); + + // Cleanup flag to prevent state update on unmounted component + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); - // Simple cache getter + /** 🔹 Get cached data safely */ const getCachedData = useCallback((): T | null => { const cached = sessionCache.get(cacheKey); if (!cached) return null; - + const isExpired = Date.now() - cached.timestamp > cached.ttl; if (isExpired) { sessionCache.delete(cacheKey); return null; } - + return cached.data; }, [cacheKey]); - // Simple cache setter - const setCachedData = useCallback((data: T) => { - sessionCache.set(cacheKey, { - data, - timestamp: Date.now(), - ttl - }); - }, [cacheKey, ttl]); - - const fetchData = useCallback(async (isBackgroundRefresh = false) => { - try { - const result = await fetchFn(); - - // Cache the result - setCachedData(result); - - // Update state - setData(result); - setError(null); - } catch (err) { - if (!isBackgroundRefresh) { - setError(err instanceof Error ? err.message : 'Failed to fetch data'); + /** 🔹 Store data in cache */ + const setCachedData = useCallback( + (data: T) => { + sessionCache.set(cacheKey, { + data, + timestamp: Date.now(), + ttl, + }); + }, + [cacheKey, ttl] + ); + + /** 🔹 Fetch fresh data */ + const fetchData = useCallback( + async (isBackgroundRefresh = false) => { + try { + const result = await fetchFn(); + setCachedData(result); + + // Avoid state updates if unmounted + if (!mountedRef.current) return; + + setData(result); + setError(null); + } catch (err) { + if (!mountedRef.current) return; + if (!isBackgroundRefresh) { + setError(err instanceof Error ? err.message : "Failed to fetch data"); + } } - } - }, [fetchFn, cacheKey, setCachedData]); + }, + [fetchFn, setCachedData] + ); - // Initial load - completely silent + /** 🔹 Initialize once on mount */ useEffect(() => { const initializeData = async () => { - - // Check simple cache first const cachedData = getCachedData(); - + if (cachedData) { setData(cachedData); - - // Always refresh in background for fresh data + if (backgroundRefresh) { + // silent refresh after 2s setTimeout(() => { fetchData(true); }, 2000); } } else { - // No cache - fetch immediately await fetchData(true); } }; initializeData(); - }, [fetchData, cacheKey, backgroundRefresh, ttl, getCachedData]); + // ✅ Only depend on stable inputs + }, [cacheKey, backgroundRefresh, getCachedData, fetchData]); - // Background refresh interval + /** 🔹 Background refresh every 20s (if enabled) */ useEffect(() => { if (!backgroundRefresh) return; @@ -91,20 +108,15 @@ export function useSilentQuery( if (!document.hidden) { fetchData(true); } - }, 20000); + }, 20000); - return () => { - clearInterval(interval); - }; + return () => clearInterval(interval); }, [fetchData, backgroundRefresh]); + /** 🔹 Manual refetch */ const refetch = useCallback(async () => { await fetchData(false); }, [fetchData]); - return { - data, - error, - refetch, - }; -} \ No newline at end of file + return { data, error, refetch }; +} diff --git a/components/hooks/useWalletData.ts b/components/hooks/useWalletData.ts index eb7d1ef..2a8c49d 100644 --- a/components/hooks/useWalletData.ts +++ b/components/hooks/useWalletData.ts @@ -28,15 +28,7 @@ export const useWalletData = (): WalletData => { const { token } = useAuth(); const { rates, isLoading: ratesLoading } = useExchangeRates(); - // Test if API client methods work - const testAPICall = useCallback(async () => { - try { - const result = await apiClient.getWalletAddresses(); - return result; - } catch (error) { - throw error; - } - }, []); + // Use silent queries const { @@ -47,7 +39,9 @@ export const useWalletData = (): WalletData => { async () => { try { const result = await apiClient.getWalletAddresses(); - return result; + const wallets = result.filter(address => address.chain !== "usdt_trc20") + // console.log("filleterd wallets data",wallets) + return wallets; } catch (error) { throw error; } @@ -67,7 +61,9 @@ export const useWalletData = (): WalletData => { async () => { try { const result = await apiClient.getWalletBalances(); - return result; + const wallets = result.filter(address => address.chain !== "usdt_trc20") + // console.log("fileterd balance", wallets) + return wallets; } catch (error) { throw error; } @@ -177,6 +173,7 @@ export const useWalletData = (): WalletData => { // Temporary loading state for debugging const isLoading = !addressesData && !balancesData && !error; + // console.log("breakdown ", breakdown) return { addresses, diff --git a/components/landingpage/landing/hero.tsx b/components/landingpage/landing/hero.tsx index 15f455a..9382965 100644 --- a/components/landingpage/landing/hero.tsx +++ b/components/landingpage/landing/hero.tsx @@ -2,16 +2,11 @@ import { Button } from "@/components/ui/buttons" import { ArrowRight, ChevronDown, Play } from "lucide-react" -import { Paprika } from "next/font/google" import Image from "next/image" import Link from "next/link" import { useState , useRef, useEffect} from "react" -const paprika = Paprika({ - subsets: ["latin"], - weight: ["400"], - variable: "--font-paprika", -}); + export function Hero() { const sectionRef = useRef(null) diff --git a/components/modals/profile-page.tsx b/components/modals/profile-page.tsx index 648c102..0a65e85 100644 --- a/components/modals/profile-page.tsx +++ b/components/modals/profile-page.tsx @@ -32,6 +32,7 @@ export default function ProfilePage() { const [formData, setFormData] = useState(getDefaultProfile()); const [selectedBank, setSelectedBank] = useState(null); const [showBankVerification, setShowBankVerification] = useState(false); + const url = process.env.NEXT_PUBLIC_BACKEND_URL console.log(profile) useEffect(() => { // Fetch profile from backend on mount @@ -39,7 +40,7 @@ console.log(profile) try { const token = localStorage.getItem('authToken'); const res = await fetch( - 'https://velo-node-backend.onrender.com/user/profile', + ` ${url}/user/profile`, { method: 'GET', headers: { diff --git a/lib/api-client.ts b/lib/api-client.ts index 3f63fc7..e37a97f 100644 --- a/lib/api-client.ts +++ b/lib/api-client.ts @@ -48,13 +48,87 @@ import { GetMerchantPaymentHistoryResponse, } from "@/types/authContext"; +const url = "http://localhost:5500"; + +// Service types +export interface SupportedNetwork { + value: string; + label: string; + name: string; +} + +export interface DataPlan { + dataplanId: string; + name: string; + amount: string; + validity: string; + networkCode: string; +} + +export interface ElectricityCompany { + value: string; + label: string; + code: string; + minAmount: number; + maxAmount: number; +} + +export interface MeterType { + value: string; + label: string; + code: string; +} + +export interface ExpectedAmount { + cryptoAmount: number; + cryptoCurrency: string; + fiatAmount: number; + chain: string; + instructions: string; + planDetails?: { + id?: string; + name: string; + amount?: string; + validity?: string; + }; +} + +export interface PurchaseResponse { + success: boolean; + message: string; + data: { + purchaseId: string; + amount?: number; + network?: string; + phoneNumber?: string; + providerReference?: string; + cryptoAmount: number; + cryptoCurrency: string; + deliveredAt: Date; + meterToken?: string; + planName?: string; + }; +} + +export interface MeterVerificationResponse { + success: boolean; + message: string; + data: { + valid: boolean; + meterNumber: string; + company: string; + details: any; + customerName: string + }; +} + class ApiClient { private baseURL: string; private cache = dataCache; private pendingRequests = new Map>(); constructor() { - this.baseURL = "https://velo-node-backend.onrender.com"; + this.baseURL = url; } // Core request method with caching @@ -133,16 +207,20 @@ class ApiClient { body: options.body ? JSON.stringify(options.body) : undefined, }); - // NEW: Check for 401 Unauthorized (token rejected by backend) + // Check for 401 Unauthorized (token rejected by backend) if (response.status === 401) { tokenManager.clearToken(); // Dispatch event to show expiration dialog - window.dispatchEvent(new CustomEvent('tokenExpired')); - throw new Error('Authentication token expired. Please login again.'); + window.dispatchEvent(new CustomEvent("tokenExpired")); + throw new Error("Authentication token expired. Please login again."); } if (!response.ok) { - throw new Error(`API error: ${response.status} ${response.statusText}`); + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.message || + `API error: ${response.status} ${response.statusText}` + ); } const data = await response.json(); @@ -199,10 +277,10 @@ class ApiClient { }); } - async ForgotPassword(email: string): Promise { + async ForgotPassword(email: string): Promise { return this.request("/auth/forgot-password", { method: "POST", - body: {email}, + body: { email }, }); } @@ -221,14 +299,15 @@ class ApiClient { body: { email }, }); } - async SetPin(pin: string): Promise { + + async SetPin(pin: string): Promise { return this.request("/user/transaction-pin", { method: "POST", body: { pin }, }); } - async TransactionPin(pin: string): Promise { + async TransactionPin(pin: string): Promise { return this.request("/user/transaction-pin/verify", { method: "POST", body: { pin }, @@ -236,17 +315,17 @@ class ApiClient { } // User methods - getUserProfile = async (): Promise => { + getUserProfile = async (): Promise => { return this.request( "/user/profile", { method: "GET" }, - { + { ttl: 30 * 60 * 1000, // 30 minutes - backgroundRefresh: true, - staleWhileRevalidate: true + backgroundRefresh: true, + staleWhileRevalidate: true, } ); - } + }; async updateUserProfile( profileData: Partial @@ -267,13 +346,13 @@ class ApiClient { "/wallet/addresses/mainnet", { method: "GET" }, { - ttl: 10 * 60 * 1000, + ttl: 10 * 60 * 1000, backgroundRefresh: true, } ).then((data) => data.addresses || []); } - async getWalletBalances(): Promise { + async getWalletBalances(): Promise { return this.request<{ balances: WalletBalance[] }>( "/wallet/balances/mainnet", { method: "GET" }, @@ -319,15 +398,15 @@ class ApiClient { ); } - getUnreadCount = async (): Promise => { + getUnreadCount = async (): Promise => { return this.request( "/notification/count", { method: "GET" }, { ttl: 30 * 1000, backgroundRefresh: true } - ).then((data) => (data?.unreadCount ?? 0)) - .catch(() => 0); - } - + ) + .then((data) => data?.unreadCount ?? 0) + .catch(() => 0); + }; async markNotificationAsRead( notificationId: string @@ -397,20 +476,23 @@ class ApiClient { return result; } -async executeSplitPayment(id: string, pin: string): Promise { + async executeSplitPayment( + id: string, + pin: string + ): Promise { const result = await this.request( - `/split-payment/${id}/execute`, - { - method: "POST", - body: { - transactionPin: pin - }, - } + `/split-payment/${id}/execute`, + { + method: "POST", + body: { + transactionPin: pin, + }, + } ); - this.cache.invalidateCache(['/split-payment/templates']); + this.cache.invalidateCache(["/split-payment/templates"]); return result; -} + } async getSplitPaymentTemplates( params?: TemplateParams @@ -558,6 +640,180 @@ async executeSplitPayment(id: string, pin: string): Promise { + return this.request<{ data: { networks: SupportedNetwork[] } }>( + "/airtime/supported-options", + { method: "GET" }, + { ttl: 10 * 60 * 1000 } + ).then((response) => response.data.networks); + } + + async getAirtimeExpectedAmount( + amount: number, + chain: string + ): Promise { + return this.request<{ data: ExpectedAmount }>( + `/airtime/expected-amount?amount=${amount}&chain=${chain}`, + { method: "GET" }, + { ttl: 30 * 1000 } // Cache for 30 seconds + ).then((response) => response.data); + } + + async purchaseAirtime(data: { + type: "airtime"; + amount: number; + chain: string; + phoneNumber: string; + mobileNetwork: string; + transactionHash: string; + }): Promise { + const result = await this.request("/airtime/purchase", { + method: "POST", + body: data, + }); + + // Invalidate related caches + this.cache.invalidateCache(["/airtime/history"]); + return result; + } + + async getAirtimeHistory(limit: number = 10) { + return this.request( + `/airtime/history?limit=${limit}`, + { method: "GET" }, + { ttl: 60 * 1000 } + ); + } + + // Data API + async getDataSupportedNetworks(): Promise { + return this.request<{ data: { networks: SupportedNetwork[] } }>( + "/data/supported-options", + { method: "GET" }, + { ttl: 10 * 60 * 1000 } + ).then((response) => response.data.networks); + } + + async getDataPlans( + network: string, + refresh: boolean = false + ): Promise { + return this.request<{ data: { plans: DataPlan[] } }>( + `/data/plans?network=${network}&refresh=${refresh}`, + { method: "GET" }, + { ttl: refresh ? 0 : 6 * 60 * 60 * 1000 } + ).then((response) => response.data.plans); + } + + async getDataExpectedAmount( + dataplanId: string, + network: string, + chain: string + ): Promise { + return this.request<{ data: ExpectedAmount }>( + `/data/expected-amount?dataplanId=${dataplanId}&network=${network}&chain=${chain}`, + { method: "GET" }, + { ttl: 30 * 1000 } + ).then((response) => response.data); + } + + async purchaseData(data: { + type: "data"; + dataplanId: string; + amount: number; + chain: string; + phoneNumber: string; + mobileNetwork: string; + transactionHash: string; + }): Promise { + const result = await this.request("/data/purchase", { + method: "POST", + body: data, + }); + + this.cache.invalidateCache(["/data/history"]); + return result; + } + + async getDataHistory(limit: number = 10) { + return this.request( + `/data/history?limit=${limit}`, + { method: "GET" }, + { ttl: 60 * 1000 } + ); + } + + // Electricity API + async getElectricitySupportedOptions(): Promise<{ + companies: ElectricityCompany[]; + meterTypes: MeterType[]; + }> { + return this.request<{ + data: { companies: ElectricityCompany[]; meterTypes: MeterType[] }; + }>( + "/electricity/supported-options", + { method: "GET" }, + { ttl: 10 * 60 * 1000 } + ).then((response) => ({ + companies: response.data.companies, + meterTypes: response.data.meterTypes, + })); + } + + async verifyElectricityMeter( + company: string, + meterNumber: string + ): Promise { + return this.request( + `/electricity/verify-meter?company=${company}&meterNumber=${meterNumber}`, + { method: "GET" } + ); + } + + async getElectricityExpectedAmount( + amount: number, + chain: string + ): Promise { + return this.request<{ data: ExpectedAmount }>( + `/electricity/expected-amount?amount=${amount}&chain=${chain}`, + { method: "GET" }, + { ttl: 30 * 1000 } + ).then((response) => response.data); + } + + async purchaseElectricity(data: { + type: "electricity"; + amount: number; + chain: string; + company: string; + meterType: string; + meterNumber: string; + phoneNumber: string; + transactionHash: string; + }): Promise { + const result = await this.request( + "/electricity/purchase", + { + method: "POST", + body: data, + } + ); + + this.cache.invalidateCache(["/electricity/history"]); + return result; + } + + async getElectricityHistory(limit: number = 10) { + return this.request( + `/electricity/history?limit=${limit}`, + { method: "GET" }, + { ttl: 60 * 1000 } + ); + } + // Cache management invalidateCache(keys: string[]): void { keys.forEach((key) => this.cache.delete(key)); @@ -569,4 +825,4 @@ async executeSplitPayment(id: string, pin: string): Promise { const token = tokenManager.getToken(); const response = await fetch( - `https://velo-node-backend.onrender.com/stats`, + `${API_BASE_URL}/stats`, { method: "GET", headers: { diff --git a/lib/service.ts b/lib/service.ts new file mode 100644 index 0000000..ca836bd --- /dev/null +++ b/lib/service.ts @@ -0,0 +1,245 @@ +// lib/api/service.ts +import axios from 'axios'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_BACKEND_URL; + +// Create axios instance with default config +const api = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// SSR-safe localStorage access +const getAuthToken = (): string | null => { + if (typeof window !== 'undefined') { + return localStorage.getItem('authToken'); + } + return null; +}; + +// SSR-safe redirect +const redirectToLogin = (): void => { + if (typeof window !== 'undefined') { + localStorage.removeItem('authToken'); + window.location.href = '/login'; + } +}; + +// Add auth token to requests +api.interceptors.request.use((config) => { + const token = getAuthToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Response interceptor for error handling +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + redirectToLogin(); + } + return Promise.reject(error); + } +); + +// Types (keep your existing types here) +export interface SupportedNetwork { + value: string; + label: string; + name: string; +} + +export interface DataPlan { + dataplanId: string; + name: string; + amount: string; + validity: string; + networkCode: string; +} + +export interface ElectricityCompany { + value: string; + label: string; + code: string; + minAmount: number; + maxAmount: number; +} + +export interface MeterType { + value: string; + label: string; + code: string; +} + +export interface ExpectedAmount { + cryptoAmount: number; + cryptoCurrency: string; + fiatAmount: number; + chain: string; + instructions: string; + planDetails?: { + id?: string; + name: string; + amount?: string; + validity?: string; + }; +} + +export interface PurchaseResponse { + success: boolean; + message: string; + data: { + purchaseId: string; + amount?: number; + network?: string; + phoneNumber?: string; + providerReference?: string; + cryptoAmount: number; + cryptoCurrency: string; + deliveredAt: Date; + meterToken?: string; + planName?: string; + }; +} + +export interface MeterVerificationResponse { + success: boolean; + message: string; + data: { + valid: boolean; + meterNumber: string; + company: string; + details: any; + }; +} + +// API functions (keep your existing functions) +export const airtimeAPI = { + getSupportedNetworks: async (): Promise => { + const response = await api.get('/airtime/supported-options'); + return response.data.data.networks; + }, + + getExpectedAmount: async (amount: number, chain: string): Promise => { + const response = await api.get('/airtime/expected-amount', { + params: { amount, chain }, + }); + return response.data.data; + }, + + purchase: async (data: { + type: 'airtime'; + amount: number; + chain: string; + phoneNumber: string; + mobileNetwork: string; + transactionHash: string; + }): Promise => { + const response = await api.post('/airtime/purchase', data); + return response.data; + }, + + getHistory: async (limit: number = 10) => { + const response = await api.get('/airtime/history', { params: { limit } }); + return response.data; + }, +}; + +export const dataAPI = { + getSupportedNetworks: async (): Promise => { + const response = await api.get('/data/supported-options'); + return response.data.data.networks; + }, + + getDataPlans: async (network: string, refresh: boolean = false): Promise => { + const response = await api.get('/data/plans', { + params: { network, refresh }, + }); + return response.data.data.plans; + }, + + getExpectedAmount: async ( + dataplanId: string, + network: string, + chain: string + ): Promise => { + const response = await api.get('/data/expected-amount', { + params: { dataplanId, network, chain }, + }); + return response.data.data; + }, + + purchase: async (data: { + type: 'data'; + dataplanId: string; + amount: number; + chain: string; + phoneNumber: string; + mobileNetwork: string; + transactionHash: string; + }): Promise => { + const response = await api.post('/data/purchase', data); + return response.data; + }, + + getHistory: async (limit: number = 10) => { + const response = await api.get('/data/history', { params: { limit } }); + return response.data; + }, +}; + +export const electricityAPI = { + getSupportedOptions: async (): Promise<{ + companies: ElectricityCompany[]; + meterTypes: MeterType[]; + }> => { + const response = await api.get('/electricity/supported-options'); + return { + companies: response.data.data.companies, + meterTypes: response.data.data.meterTypes, + }; + }, + + verifyMeter: async ( + company: string, + meterNumber: string + ): Promise => { + const response = await api.get('/electricity/verify-meter', { + params: { company, meterNumber }, + }); + return response.data; + }, + + getExpectedAmount: async (amount: number, chain: string): Promise => { + const response = await api.get('/electricity/expected-amount', { + params: { amount, chain }, + }); + return response.data.data; + }, + + purchase: async (data: { + type: 'electricity'; + amount: number; + chain: string; + company: string; + meterType: string; + meterNumber: string; + phoneNumber: string; + transactionHash: string; + }): Promise => { + const response = await api.post('/electricity/purchase', data); + return response.data; + }, + + getHistory: async (limit: number = 10) => { + const response = await api.get('/electricity/history', { params: { limit } }); + return response.data; + }, +}; + +export default api; \ No newline at end of file diff --git a/lib/utils.ts b/lib/utils.ts index 2ca1fe6..6ccd78d 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -20,4 +20,37 @@ export const socialStyleNumber = (_num: string | number): string => { return `${(num / 1_000).toFixed(1).replace(/\.0$/, "")}k`; } return num.toString(); +}; + + +export const validatePhoneNumber = (num: string): string => { + const cleanNum = num.replace(/\D/g, ''); + + let fixedNum = cleanNum; + + // Case 1: Numbers starting with 0 -> replace with 234 + if (cleanNum.startsWith("0")) { + if (cleanNum.length !== 11) { + throw new Error(`Invalid length: ${num}. Numbers starting with 0 should be 11 digits`); + } + fixedNum = `234${cleanNum.slice(1)}`; + } + // Case 2: Numbers starting with 234 -> keep as is + else if (cleanNum.startsWith("234")) { + if (cleanNum.length !== 13) { + throw new Error(`Invalid length: ${num}. Numbers starting with 234 should be 13 digits`); + } + fixedNum = cleanNum; + } + // Case 3: Numbers that don't start with 0 or 234 -> throw error + else { + throw new Error(`Invalid format: ${num}. Nigerian numbers must start with 0 or 234`); + } + + // Final validation for Nigerian number format + if (!/^234[7-9][0-9]{9}$/.test(fixedNum)) { + throw new Error(`Invalid Nigerian phone number: ${num}`); + } + + return fixedNum; }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 19efd8f..8fb4bbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5377,7 +5377,6 @@ "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz", "integrity": "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.25.0", "@noble/curves": "^1.4.2", @@ -5922,7 +5921,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.21.tgz", "integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5963,7 +5961,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5974,7 +5971,6 @@ "integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6071,7 +6067,6 @@ "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/types": "8.46.0", @@ -9242,7 +9237,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9969,7 +9963,6 @@ "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", "license": "MIT", - "peer": true, "dependencies": { "base-x": "^5.0.0" } @@ -10041,7 +10034,6 @@ "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -10485,8 +10477,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d": { "version": "1.0.2", @@ -11150,7 +11141,6 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11325,7 +11315,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -14275,7 +14264,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-15.5.2.tgz", "integrity": "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "15.5.2", "@swc/helpers": "0.5.15", @@ -15311,7 +15299,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -15337,7 +15324,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -16197,7 +16183,6 @@ "resolved": "https://registry.npmjs.org/starknet/-/starknet-7.6.4.tgz", "integrity": "sha512-FB20IaLCDbh/XomkB+19f5jmNxG+RzNdRO7QUhm7nfH81UPIt2C/MyWAlHCYkbv2wznSEb73wpxbp9tytokTgQ==", "license": "MIT", - "peer": true, "dependencies": { "@noble/curves": "1.7.0", "@noble/hashes": "1.6.0", @@ -16776,7 +16761,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17053,7 +17037,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17258,7 +17241,6 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -17269,7 +17251,6 @@ "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "node-gyp-build": "^4.3.0" }, @@ -17328,7 +17309,6 @@ "resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.7.tgz", "integrity": "sha512-DwJhCDpujuQuKdJ2H84VbTjEJJteaSmqsuUltsfbfdbotVfNeTE4K/qc/Wi57I9x8/2ed4JNdjEna7O6PfavRg==", "license": "MIT", - "peer": true, "dependencies": { "proxy-compare": "^3.0.1" }, @@ -17377,7 +17357,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", @@ -17757,7 +17736,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/public/img/aba_electric.png b/public/img/aba_electric.png new file mode 100644 index 0000000..7511b9e Binary files /dev/null and b/public/img/aba_electric.png differ diff --git a/public/img/abuja_electric.png b/public/img/abuja_electric.png new file mode 100644 index 0000000..11e9081 Binary files /dev/null and b/public/img/abuja_electric.png differ diff --git a/public/img/benin.jpeg b/public/img/benin.jpeg new file mode 100644 index 0000000..67f79fb Binary files /dev/null and b/public/img/benin.jpeg differ diff --git a/public/img/benin_electric.png b/public/img/benin_electric.png new file mode 100644 index 0000000..7dc2f46 Binary files /dev/null and b/public/img/benin_electric.png differ diff --git a/public/img/eko_electric.png b/public/img/eko_electric.png new file mode 100644 index 0000000..2c8fd2e Binary files /dev/null and b/public/img/eko_electric.png differ diff --git a/public/img/enugu_electric.png b/public/img/enugu_electric.png new file mode 100644 index 0000000..c1b08e4 Binary files /dev/null and b/public/img/enugu_electric.png differ diff --git a/public/img/ibadan_electric.png b/public/img/ibadan_electric.png new file mode 100644 index 0000000..3090d2b Binary files /dev/null and b/public/img/ibadan_electric.png differ diff --git a/public/img/ikeja_electric.png b/public/img/ikeja_electric.png new file mode 100644 index 0000000..7560aed Binary files /dev/null and b/public/img/ikeja_electric.png differ diff --git a/public/img/jos_electric.png b/public/img/jos_electric.png new file mode 100644 index 0000000..892ab3a Binary files /dev/null and b/public/img/jos_electric.png differ diff --git a/public/img/kaduna_electric.png b/public/img/kaduna_electric.png new file mode 100644 index 0000000..fb7b045 Binary files /dev/null and b/public/img/kaduna_electric.png differ diff --git a/public/img/kano_electric.png b/public/img/kano_electric.png new file mode 100644 index 0000000..5cad9c3 Binary files /dev/null and b/public/img/kano_electric.png differ diff --git a/public/img/portharcourt_electric.png b/public/img/portharcourt_electric.png new file mode 100644 index 0000000..6f062e9 Binary files /dev/null and b/public/img/portharcourt_electric.png differ diff --git a/public/img/yola_electric.png b/public/img/yola_electric.png new file mode 100644 index 0000000..137c95b Binary files /dev/null and b/public/img/yola_electric.png differ