diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index f8b7082..01bed6c 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import "./globals.css"; import { StacksProvider } from "@/context/StacksContext"; +import Navbar from "@/components/Navbar/Navbar"; export const metadata: Metadata = { title: "S-pay | Premium Stacks Payments", @@ -21,6 +22,7 @@ export default function RootLayout({ + {children} diff --git a/frontend/app/merchant/register/page.tsx b/frontend/app/merchant/register/page.tsx new file mode 100644 index 0000000..ea3b917 --- /dev/null +++ b/frontend/app/merchant/register/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { RegisterMerchantForm } from "@/components/RegisterMerchantForm/RegisterMerchantForm"; +import styles from "./register.module.css"; + +export default function MerchantRegisterPage() { + return ( +
+

Register as Merchant

+

+ Requires 50 STX verification stake (register-merchant) +

+ +
+ ); +} diff --git a/frontend/app/merchant/register/register.module.css b/frontend/app/merchant/register/register.module.css new file mode 100644 index 0000000..180a9df --- /dev/null +++ b/frontend/app/merchant/register/register.module.css @@ -0,0 +1,18 @@ +.page { + min-height: 80vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.title { + font-size: 2rem; + margin-bottom: 0.5rem; +} + +.subtitle { + color: #a0a0aa; + margin-bottom: 2rem; +} diff --git a/frontend/app/page.module.css b/frontend/app/page.module.css index 89a854d..ca249c3 100644 --- a/frontend/app/page.module.css +++ b/frontend/app/page.module.css @@ -48,39 +48,6 @@ line-height: 1.6; } -.actions { - display: flex; - gap: 1.5rem; - justify-content: center; -} - -.primaryBtn { - background: var(--primary); - color: white; - padding: 1rem 2.5rem; - border-radius: 12px; - font-weight: 600; - transition: var(--transition-smooth); -} - -.primaryBtn:hover { - transform: translateY(-2px); - box-shadow: 0 0 20px var(--primary-glow); -} - -.secondaryBtn { - border: 1px solid var(--glass-border); - color: var(--foreground); - padding: 1rem 2.5rem; - border-radius: 12px; - font-weight: 600; - transition: var(--transition-smooth); -} - -.secondaryBtn:hover { - background: rgba(255, 255, 255, 0.05); -} - @media (max-width: 768px) { .title { font-size: 3rem; diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 2daab69..02b3287 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,4 +1,5 @@ import styles from "./page.module.css"; +import { ConnectWallet } from "@/components/ConnectWallet/ConnectWallet"; export default function Home() { return ( @@ -10,23 +11,11 @@ export default function Home() { Powered by Stacks.

- Experience the most premium payment gateway for the Bitcoin ecosystem. + Experience the most premium payment gateway for the Bitcoin ecosystem. Fast, decentralized, and built for modern commerce.

-
- - -
+ ); } -// Final polish 62 -// Final polish 63 -// Final polish 64 -// Final polish 65 -// Final polish 66 -// Final polish 67 -// Final polish 68 -// Final polish 69 -// Final polish 70 diff --git a/frontend/app/pay/page.tsx b/frontend/app/pay/page.tsx new file mode 100644 index 0000000..84c87da --- /dev/null +++ b/frontend/app/pay/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { PaymentForm } from "@/components/PaymentForm/PaymentForm"; +import styles from "./pay.module.css"; + +export default function PayPage() { + return ( +
+

Process Payment

+

+ Send STX via S-pay protocol (process-payment) +

+ +
+ ); +} diff --git a/frontend/app/pay/pay.module.css b/frontend/app/pay/pay.module.css new file mode 100644 index 0000000..180a9df --- /dev/null +++ b/frontend/app/pay/pay.module.css @@ -0,0 +1,18 @@ +.page { + min-height: 80vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.title { + font-size: 2rem; + margin-bottom: 0.5rem; +} + +.subtitle { + color: #a0a0aa; + margin-bottom: 2rem; +} diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx new file mode 100644 index 0000000..977b569 --- /dev/null +++ b/frontend/app/register/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { RegisterUserForm } from "@/components/RegisterUserForm/RegisterUserForm"; +import styles from "./register.module.css"; + +export default function RegisterPage() { + return ( +
+

Register as User

+

+ Pick a unique username to join S-pay (string-ascii 24 max) +

+ +
+ ); +} diff --git a/frontend/app/register/register.module.css b/frontend/app/register/register.module.css new file mode 100644 index 0000000..180a9df --- /dev/null +++ b/frontend/app/register/register.module.css @@ -0,0 +1,18 @@ +.page { + min-height: 80vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.title { + font-size: 2rem; + margin-bottom: 0.5rem; +} + +.subtitle { + color: #a0a0aa; + margin-bottom: 2rem; +} diff --git a/frontend/app/vault/page.tsx b/frontend/app/vault/page.tsx new file mode 100644 index 0000000..5b9a8c8 --- /dev/null +++ b/frontend/app/vault/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { VaultDepositForm } from "@/components/VaultDepositForm/VaultDepositForm"; +import styles from "./vault.module.css"; + +export default function VaultPage() { + return ( +
+

Vault Deposit

+

+ Deposit STX to your S-pay vault (vault-deposit) +

+ +
+ ); +} diff --git a/frontend/app/vault/vault.module.css b/frontend/app/vault/vault.module.css new file mode 100644 index 0000000..180a9df --- /dev/null +++ b/frontend/app/vault/vault.module.css @@ -0,0 +1,18 @@ +.page { + min-height: 80vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.title { + font-size: 2rem; + margin-bottom: 0.5rem; +} + +.subtitle { + color: #a0a0aa; + margin-bottom: 2rem; +} diff --git a/frontend/components/ConnectWallet/ConnectWallet.module.css b/frontend/components/ConnectWallet/ConnectWallet.module.css new file mode 100644 index 0000000..8855cab --- /dev/null +++ b/frontend/components/ConnectWallet/ConnectWallet.module.css @@ -0,0 +1,42 @@ +.actions { + display: flex; + gap: 1.5rem; + justify-content: center; + align-items: center; +} + +.address { + font-family: monospace; + font-size: 0.95rem; + color: var(--foreground); + background: rgba(255, 255, 255, 0.05); + padding: 0.5rem 1rem; + border-radius: 8px; +} + +.primaryBtn { + background: var(--primary); + color: white; + padding: 1rem 2.5rem; + border-radius: 12px; + font-weight: 600; + transition: var(--transition-smooth); +} + +.primaryBtn:hover { + transform: translateY(-2px); + box-shadow: 0 0 20px var(--primary-glow); +} + +.secondaryBtn { + border: 1px solid var(--glass-border); + color: var(--foreground); + padding: 1rem 2.5rem; + border-radius: 12px; + font-weight: 600; + transition: var(--transition-smooth); +} + +.secondaryBtn:hover { + background: rgba(255, 255, 255, 0.05); +} diff --git a/frontend/components/ConnectWallet/ConnectWallet.tsx b/frontend/components/ConnectWallet/ConnectWallet.tsx new file mode 100644 index 0000000..4649bfe --- /dev/null +++ b/frontend/components/ConnectWallet/ConnectWallet.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useStacks } from "@/context/StacksContext"; +import styles from "./ConnectWallet.module.css"; + +export function ConnectWallet() { + const { userData, handleConnect, handleDisconnect } = useStacks(); + const address = userData?.profile?.stxAddress?.mainnet ?? userData?.profile?.stxAddress?.testnet; + + return ( +
+ {address ? ( + <> + {address.slice(0, 6)}…{address.slice(-4)} + + + ) : ( + <> + + + + )} +
+ ); +} diff --git a/frontend/components/Navbar/Navbar.module.css b/frontend/components/Navbar/Navbar.module.css index ae3cf90..f2aa914 100644 --- a/frontend/components/Navbar/Navbar.module.css +++ b/frontend/components/Navbar/Navbar.module.css @@ -49,6 +49,21 @@ color: var(--foreground); } +.actions { + display: flex; + align-items: center; + gap: 1rem; +} + +.address { + font-family: monospace; + font-size: 0.85rem; + color: var(--foreground); + background: rgba(255, 255, 255, 0.05); + padding: 0.4rem 0.8rem; + border-radius: 8px; +} + .connectBtn { background: rgba(255, 255, 255, 0.05); border: 1px solid var(--glass-border); diff --git a/frontend/components/Navbar/Navbar.tsx b/frontend/components/Navbar/Navbar.tsx index b6b4cda..10f2525 100644 --- a/frontend/components/Navbar/Navbar.tsx +++ b/frontend/components/Navbar/Navbar.tsx @@ -1,9 +1,13 @@ "use client"; import Link from "next/link"; +import { useStacks } from "@/context/StacksContext"; import styles from "./Navbar.module.css"; export default function Navbar() { + const { userData, handleConnect, handleDisconnect } = useStacks(); + const addr = userData?.profile?.stxAddress?.mainnet ?? userData?.profile?.stxAddress?.testnet; + return ( diff --git a/frontend/components/PaymentForm/PaymentForm.module.css b/frontend/components/PaymentForm/PaymentForm.module.css new file mode 100644 index 0000000..4716d90 --- /dev/null +++ b/frontend/components/PaymentForm/PaymentForm.module.css @@ -0,0 +1,22 @@ +.form { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 400px; +} + +.input { + padding: 0.6rem 1rem; + border-radius: 8px; + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.05); + color: var(--foreground); +} + +.btn { + background: var(--primary); + color: white; + padding: 0.75rem; + border-radius: 8px; + font-weight: 600; +} diff --git a/frontend/components/PaymentForm/PaymentForm.tsx b/frontend/components/PaymentForm/PaymentForm.tsx new file mode 100644 index 0000000..d6d5383 --- /dev/null +++ b/frontend/components/PaymentForm/PaymentForm.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useState } from "react"; +import { useProcessPayment } from "@/hooks/useProcessPayment"; +import styles from "./PaymentForm.module.css"; + +export function PaymentForm() { + const [recipient, setRecipient] = useState(""); + const [amountStx, setAmountStx] = useState(""); + const { processPayment } = useProcessPayment(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const amount = BigInt(Math.floor(parseFloat(amountStx || "0") * 1e6)); + if (recipient && amount > 0n) processPayment(amount, recipient); + }; + + return ( +
+ setRecipient(e.target.value)} + placeholder="Recipient principal" + className={styles.input} + /> + setAmountStx(e.target.value)} + placeholder="Amount (STX)" + className={styles.input} + /> + +
+ ); +} diff --git a/frontend/components/RegisterMerchantForm/RegisterMerchantForm.module.css b/frontend/components/RegisterMerchantForm/RegisterMerchantForm.module.css new file mode 100644 index 0000000..d32b08f --- /dev/null +++ b/frontend/components/RegisterMerchantForm/RegisterMerchantForm.module.css @@ -0,0 +1,26 @@ +.form { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 400px; +} + +.input { + padding: 0.6rem 1rem; + border-radius: 8px; + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.05); + color: var(--foreground); +} + +.btn { + background: var(--primary); + color: white; + padding: 0.75rem; + border-radius: 8px; + font-weight: 600; +} + +.hint { + color: #a0a0aa; +} diff --git a/frontend/components/RegisterMerchantForm/RegisterMerchantForm.tsx b/frontend/components/RegisterMerchantForm/RegisterMerchantForm.tsx new file mode 100644 index 0000000..dcf294b --- /dev/null +++ b/frontend/components/RegisterMerchantForm/RegisterMerchantForm.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useState } from "react"; +import { useRegisterMerchant } from "@/hooks/useRegisterMerchant"; +import { useStacks } from "@/context/StacksContext"; +import styles from "./RegisterMerchantForm.module.css"; + +export function RegisterMerchantForm() { + const [businessName, setBusinessName] = useState(""); + const [website, setWebsite] = useState(""); + const { registerMerchant } = useRegisterMerchant(); + const { userData } = useStacks(); + const isConnected = !!userData; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (businessName.trim() && website.trim()) + registerMerchant(businessName.trim(), website.trim()); + }; + + if (!isConnected) return

Connect wallet first

; + + return ( +
+ setBusinessName(e.target.value)} + placeholder="Business name (max 64 chars)" + maxLength={64} + className={styles.input} + /> + setWebsite(e.target.value)} + placeholder="Website (max 128 chars)" + maxLength={128} + className={styles.input} + /> + +
+ ); +} diff --git a/frontend/components/RegisterUserForm/RegisterUserForm.module.css b/frontend/components/RegisterUserForm/RegisterUserForm.module.css new file mode 100644 index 0000000..c864cbc --- /dev/null +++ b/frontend/components/RegisterUserForm/RegisterUserForm.module.css @@ -0,0 +1,26 @@ +.form { + display: flex; + gap: 0.75rem; +} + +.input { + padding: 0.6rem 1rem; + border-radius: 8px; + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.05); + color: var(--foreground); + min-width: 200px; +} + +.btn { + background: var(--primary); + color: white; + padding: 0.6rem 1.25rem; + border-radius: 8px; + font-weight: 600; +} + +.hint { + color: #a0a0aa; + font-size: 0.9rem; +} diff --git a/frontend/components/RegisterUserForm/RegisterUserForm.tsx b/frontend/components/RegisterUserForm/RegisterUserForm.tsx new file mode 100644 index 0000000..86bb849 --- /dev/null +++ b/frontend/components/RegisterUserForm/RegisterUserForm.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useState } from "react"; +import { useRegisterUser } from "@/hooks/useRegisterUser"; +import styles from "./RegisterUserForm.module.css"; + +export function RegisterUserForm() { + const [username, setUsername] = useState(""); + const { registerUser, isConnected } = useRegisterUser(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (username.trim()) registerUser(username.trim()); + }; + + if (!isConnected) { + return

Connect wallet to register

; + } + + return ( +
+ setUsername(e.target.value)} + placeholder="Username (max 24 chars)" + maxLength={24} + className={styles.input} + /> + +
+ ); +} diff --git a/frontend/components/VaultDepositForm/VaultDepositForm.module.css b/frontend/components/VaultDepositForm/VaultDepositForm.module.css new file mode 100644 index 0000000..3845ba4 --- /dev/null +++ b/frontend/components/VaultDepositForm/VaultDepositForm.module.css @@ -0,0 +1,21 @@ +.form { + display: flex; + gap: 0.75rem; +} + +.input { + padding: 0.6rem 1rem; + border-radius: 8px; + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.05); + color: var(--foreground); + min-width: 150px; +} + +.btn { + background: var(--primary); + color: white; + padding: 0.6rem 1.25rem; + border-radius: 8px; + font-weight: 600; +} diff --git a/frontend/components/VaultDepositForm/VaultDepositForm.tsx b/frontend/components/VaultDepositForm/VaultDepositForm.tsx new file mode 100644 index 0000000..b8a1463 --- /dev/null +++ b/frontend/components/VaultDepositForm/VaultDepositForm.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useState } from "react"; +import { useVaultDeposit } from "@/hooks/useVaultDeposit"; +import styles from "./VaultDepositForm.module.css"; + +export function VaultDepositForm() { + const [amountStx, setAmountStx] = useState(""); + const { vaultDeposit } = useVaultDeposit(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const amount = BigInt(Math.floor(parseFloat(amountStx || "0") * 1e6)); + if (amount > 0n) vaultDeposit(amount); + }; + + return ( +
+ setAmountStx(e.target.value)} + placeholder="Amount (STX)" + className={styles.input} + /> + +
+ ); +} diff --git a/frontend/context/StacksContext.tsx b/frontend/context/StacksContext.tsx index 8ed417d..f1fb025 100644 --- a/frontend/context/StacksContext.tsx +++ b/frontend/context/StacksContext.tsx @@ -1,8 +1,14 @@ "use client"; -import React, { createContext, useContext, useState, useEffect } from 'react'; -import { AppConfig, UserSession, showConnect } from '@stacks/connect'; -import { StacksMainnet } from '@stacks/network'; +import React, { createContext, useContext, useState, useEffect } from "react"; +import { + AppConfig, + UserSession, + showConnect, + openContractCall, + openSTXTransfer, +} from "@stacks/connect"; +import { StacksMainnet } from "@stacks/network"; export interface StacksUserData { profile: { @@ -13,12 +19,21 @@ export interface StacksUserData { }; } +export interface ContractCallBase { + contractAddress: string; + contractName: string; + functionName: string; + functionArgs: unknown[]; +} + interface StacksContextType { userSession: UserSession; userData: StacksUserData | null; handleConnect: () => void; handleDisconnect: () => void; - network: any; + network: unknown; + callContract: (options: ContractCallBase) => void; + transferStx: (recipient: string, amountMicroStx: bigint) => void; } const StacksContext = createContext(undefined); @@ -55,8 +70,39 @@ export function StacksProvider({ children }: { children: React.ReactNode }) { setUserData(null); }; + const callContract = (options: ContractCallBase) => { + openContractCall({ + ...options, + userSession, + network, + onFinish: () => {}, + onCancel: () => {}, + }); + }; + + const transferStx = (recipient: string, amountMicroStx: bigint) => { + openSTXTransfer({ + recipient, + amount: amountMicroStx, + userSession, + network, + onFinish: () => {}, + onCancel: () => {}, + }); + }; + return ( - + {children} ); diff --git a/frontend/hooks/useMerchantWithdraw.ts b/frontend/hooks/useMerchantWithdraw.ts new file mode 100644 index 0000000..d909113 --- /dev/null +++ b/frontend/hooks/useMerchantWithdraw.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useCallback } from "react"; +import { useStacks } from "@/context/StacksContext"; +import { buildMerchantWithdrawOptions } from "@/lib/contract-calls"; + +export function useMerchantWithdraw() { + const { callContract } = useStacks(); + + const merchantWithdraw = useCallback( + (amountMicroStx: bigint | number) => { + callContract(buildMerchantWithdrawOptions(amountMicroStx)); + }, + [callContract] + ); + + return { merchantWithdraw }; +} diff --git a/frontend/hooks/useProcessPayment.ts b/frontend/hooks/useProcessPayment.ts new file mode 100644 index 0000000..461bbf2 --- /dev/null +++ b/frontend/hooks/useProcessPayment.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useCallback } from "react"; +import { useStacks } from "@/context/StacksContext"; +import { buildProcessPaymentOptions } from "@/lib/contract-calls"; + +export function useProcessPayment() { + const { callContract } = useStacks(); + + const processPayment = useCallback( + (amountMicroStx: bigint | number, recipient: string) => { + callContract(buildProcessPaymentOptions(amountMicroStx, recipient)); + }, + [callContract] + ); + + return { processPayment }; +} diff --git a/frontend/hooks/useProtocolStatus.ts b/frontend/hooks/useProtocolStatus.ts new file mode 100644 index 0000000..75dd10c --- /dev/null +++ b/frontend/hooks/useProtocolStatus.ts @@ -0,0 +1,25 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useStacks } from "@/context/StacksContext"; +import { getProtocolStatus } from "@/lib/read-only"; + +export function useProtocolStatus() { + const { userData } = useStacks(); + const [status, setStatus] = useState(null); + + const address = + userData?.profile?.stxAddress?.mainnet ?? userData?.profile?.stxAddress?.testnet; + + const fetchStatus = useCallback(async () => { + const sender = address ?? "SP2DBFGMT7SATSJPCCA38SDDPBNNQ86QWADJ3E6WT"; + const result = await getProtocolStatus(sender); + setStatus(result); + }, [address]); + + useEffect(() => { + fetchStatus(); + }, [fetchStatus]); + + return { status, refetch: fetchStatus }; +} diff --git a/frontend/hooks/useReclaimStake.ts b/frontend/hooks/useReclaimStake.ts new file mode 100644 index 0000000..7270b0d --- /dev/null +++ b/frontend/hooks/useReclaimStake.ts @@ -0,0 +1,15 @@ +"use client"; + +import { useCallback } from "react"; +import { useStacks } from "@/context/StacksContext"; +import { buildReclaimStakeOptions } from "@/lib/contract-calls"; + +export function useReclaimStake() { + const { callContract } = useStacks(); + + const reclaimStake = useCallback(() => { + callContract(buildReclaimStakeOptions()); + }, [callContract]); + + return { reclaimStake }; +} diff --git a/frontend/hooks/useRegisterMerchant.ts b/frontend/hooks/useRegisterMerchant.ts new file mode 100644 index 0000000..b2b1b7d --- /dev/null +++ b/frontend/hooks/useRegisterMerchant.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useCallback } from "react"; +import { useStacks } from "@/context/StacksContext"; +import { buildRegisterMerchantOptions } from "@/lib/contract-calls"; + +export function useRegisterMerchant() { + const { callContract } = useStacks(); + + const registerMerchant = useCallback( + (businessName: string, website: string) => { + callContract(buildRegisterMerchantOptions(businessName, website)); + }, + [callContract] + ); + + return { registerMerchant }; +} diff --git a/frontend/hooks/useRegisterUser.ts b/frontend/hooks/useRegisterUser.ts new file mode 100644 index 0000000..22e35fa --- /dev/null +++ b/frontend/hooks/useRegisterUser.ts @@ -0,0 +1,19 @@ +"use client"; + +import { useCallback } from "react"; +import { useStacks } from "@/context/StacksContext"; +import { buildRegisterUserOptions } from "@/lib/contract-calls"; + +export function useRegisterUser() { + const { callContract, userData } = useStacks(); + + const registerUser = useCallback( + (username: string) => { + const opts = buildRegisterUserOptions(username); + callContract(opts); + }, + [callContract] + ); + + return { registerUser, isConnected: !!userData }; +} diff --git a/frontend/hooks/useTransferStx.ts b/frontend/hooks/useTransferStx.ts new file mode 100644 index 0000000..6bdadfa --- /dev/null +++ b/frontend/hooks/useTransferStx.ts @@ -0,0 +1,17 @@ +"use client"; + +import { useCallback } from "react"; +import { useStacks } from "@/context/StacksContext"; + +export function useTransferStx() { + const { transferStx } = useStacks(); + + const transfer = useCallback( + (recipient: string, amountMicroStx: bigint) => { + transferStx(recipient, amountMicroStx); + }, + [transferStx] + ); + + return { transfer }; +} diff --git a/frontend/hooks/useUserData.ts b/frontend/hooks/useUserData.ts new file mode 100644 index 0000000..71a8e15 --- /dev/null +++ b/frontend/hooks/useUserData.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { principalCV } from "@stacks/transactions"; +import { useStacks } from "@/context/StacksContext"; +import { callReadOnly } from "@/lib/read-only"; + +export function useUserData() { + const { userData } = useStacks(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + const address = + userData?.profile?.stxAddress?.mainnet ?? userData?.profile?.stxAddress?.testnet; + + const fetchUserData = useCallback(async () => { + if (!address) return; + setLoading(true); + try { + const result = await callReadOnly( + "get-user-data", + [principalCV(address)], + address + ); + setData(result); + } finally { + setLoading(false); + } + }, [address]); + + useEffect(() => { + fetchUserData(); + }, [fetchUserData]); + + return { userData: data, loading, refetch: fetchUserData }; +} diff --git a/frontend/hooks/useVaultDeposit.ts b/frontend/hooks/useVaultDeposit.ts new file mode 100644 index 0000000..76b7162 --- /dev/null +++ b/frontend/hooks/useVaultDeposit.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useCallback } from "react"; +import { useStacks } from "@/context/StacksContext"; +import { buildVaultDepositOptions } from "@/lib/contract-calls"; + +export function useVaultDeposit() { + const { callContract } = useStacks(); + + const vaultDeposit = useCallback( + (amountMicroStx: bigint | number) => { + callContract(buildVaultDepositOptions(amountMicroStx)); + }, + [callContract] + ); + + return { vaultDeposit }; +} diff --git a/frontend/hooks/useVaultWithdraw.ts b/frontend/hooks/useVaultWithdraw.ts new file mode 100644 index 0000000..36bee29 --- /dev/null +++ b/frontend/hooks/useVaultWithdraw.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useCallback } from "react"; +import { useStacks } from "@/context/StacksContext"; +import { buildVaultWithdrawOptions } from "@/lib/contract-calls"; + +export function useVaultWithdraw() { + const { callContract } = useStacks(); + + const vaultWithdraw = useCallback( + (amountMicroStx: bigint | number) => { + callContract(buildVaultWithdrawOptions(amountMicroStx)); + }, + [callContract] + ); + + return { vaultWithdraw }; +} diff --git a/frontend/lib/constants.ts b/frontend/lib/constants.ts new file mode 100644 index 0000000..b1fb526 --- /dev/null +++ b/frontend/lib/constants.ts @@ -0,0 +1,10 @@ +/** + * S-pay contract configuration. + * Uses mainnet s-pay-v3 deployment. + */ +export const SPAY_CONTRACT = { + address: "SP2DBFGMT7SATSJPCCA38SDDPBNNQ86QWADJ3E6WT", + name: "s-pay-v3", +} as const; + +export const SPAY_FULL_CONTRACT = `${SPAY_CONTRACT.address}.${SPAY_CONTRACT.name}`; diff --git a/frontend/lib/contract-calls.ts b/frontend/lib/contract-calls.ts new file mode 100644 index 0000000..cdf01e5 --- /dev/null +++ b/frontend/lib/contract-calls.ts @@ -0,0 +1,72 @@ +/** + * Contract call helpers using @stacks/transactions for Clarity types + * and @stacks/connect for openContractCall signing. + */ +import { stringAsciiCV, uintCV, principalCV } from "@stacks/transactions"; +import { SPAY_CONTRACT } from "./constants"; + +export function buildRegisterUserOptions(username: string) { + return { + contractAddress: SPAY_CONTRACT.address, + contractName: SPAY_CONTRACT.name, + functionName: "register-user", + functionArgs: [stringAsciiCV(username)], + }; +} + +export function buildVaultDepositOptions(amount: bigint | number) { + return { + contractAddress: SPAY_CONTRACT.address, + contractName: SPAY_CONTRACT.name, + functionName: "vault-deposit", + functionArgs: [uintCV(amount)], + }; +} + +export function buildProcessPaymentOptions(amount: bigint | number, recipient: string) { + return { + contractAddress: SPAY_CONTRACT.address, + contractName: SPAY_CONTRACT.name, + functionName: "process-payment", + functionArgs: [uintCV(amount), principalCV(recipient)], + }; +} + +export function buildRegisterMerchantOptions( + businessName: string, + website: string +) { + return { + contractAddress: SPAY_CONTRACT.address, + contractName: SPAY_CONTRACT.name, + functionName: "register-merchant", + functionArgs: [stringAsciiCV(businessName), stringAsciiCV(website)], + }; +} + +export function buildVaultWithdrawOptions(amount: bigint | number) { + return { + contractAddress: SPAY_CONTRACT.address, + contractName: SPAY_CONTRACT.name, + functionName: "vault-withdraw", + functionArgs: [uintCV(amount)], + }; +} + +export function buildMerchantWithdrawOptions(amount: bigint | number) { + return { + contractAddress: SPAY_CONTRACT.address, + contractName: SPAY_CONTRACT.name, + functionName: "merchant-withdraw", + functionArgs: [uintCV(amount)], + }; +} + +export function buildReclaimStakeOptions() { + return { + contractAddress: SPAY_CONTRACT.address, + contractName: SPAY_CONTRACT.name, + functionName: "reclaim-stake", + functionArgs: [], + }; +} diff --git a/frontend/lib/read-only.ts b/frontend/lib/read-only.ts new file mode 100644 index 0000000..cd86b8c --- /dev/null +++ b/frontend/lib/read-only.ts @@ -0,0 +1,30 @@ +/** + * Read-only contract calls using @stacks/transactions for encoding/decoding. + */ +import { cvToHex, deserializeCV, cvToValue } from "@stacks/transactions"; +import type { ClarityValue } from "@stacks/transactions"; +import { SPAY_CONTRACT } from "./constants"; + +const API_BASE = "https://api.mainnet.hiro.so/v2"; + +export async function callReadOnly( + functionName: string, + functionArgs: ClarityValue[], + senderAddress: string +): Promise { + const args = functionArgs.map((cv) => cvToHex(cv)); + const url = `${API_BASE}/contracts/call-read/${SPAY_CONTRACT.address}/${SPAY_CONTRACT.name}/${functionName}`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sender: senderAddress, arguments: args }), + }); + if (!res.ok) throw new Error(`Read call failed: ${res.statusText}`); + const data = await res.json(); + return deserializeCV(data.result) as T; +} + +export async function getProtocolStatus(senderAddress: string) { + const cv = await callReadOnly("get-protocol-status", [], senderAddress); + return cvToValue(cv); +}