diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 61a5064f..b6797201 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -1,3 +1,4 @@ +import ConnectWalletButton from "@/components/connect-wallet"; import { MobileSidebar } from "@/components/mobile-sidebar"; import { Sidebar } from "@/components/sidebar"; @@ -18,6 +19,7 @@ export default function Layout({ {/* Mobile Header with Menu */}
+
{children}
diff --git a/app/(dashboard)/projects/my-projects/page.tsx b/app/(dashboard)/projects/my-projects/page.tsx new file mode 100644 index 00000000..ac487794 --- /dev/null +++ b/app/(dashboard)/projects/my-projects/page.tsx @@ -0,0 +1,12 @@ +import { MyProjectsList } from "@/components/projects/my-project-list"; + +export const dynamic = "force-dynamic"; + +export default function ProjectsPage() { + return ( +
+

Projects

+ +
+ ); +} diff --git a/app/api/projects/create/route.ts b/app/api/projects/create/route.ts index 6d2c6b0e..a2e45e4a 100644 --- a/app/api/projects/create/route.ts +++ b/app/api/projects/create/route.ts @@ -17,6 +17,8 @@ export async function POST(request: Request) { } try { const formData = await request.formData(); + const projectId = formData.get("projectId") as string; + const signedTx = formData.get("signedTx") as string; const title = formData.get("title") as string; const description = formData.get("description") as string; const fundingGoal = Number(formData.get("fundingGoal")); @@ -48,6 +50,7 @@ export async function POST(request: Request) { const project = await prisma.project.create({ data: { + id: projectId, userId: session.user.id, title, description, @@ -55,7 +58,7 @@ export async function POST(request: Request) { category, bannerUrl, profileUrl, - blockchainTx: null, + blockchainTx: signedTx || null, }, }); diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index a614de84..79322cc9 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import type { Prisma, ValidationStatus } from "@prisma/client"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth.config"; export async function GET(request: Request) { try { @@ -53,3 +55,60 @@ export async function GET(request: Request) { ); } } + +export async function POST(request: Request) { + try { + const { forUser } = await request.json(); + + const session = await getServerSession(authOptions); + + const where: Prisma.ProjectWhereInput = {}; + + if (forUser && session?.user?.id) { + where.userId = session.user.id; + } + + // if (category) { + // where.category = category; + // } + + // if (status) { + // where.ideaValidation = status as ValidationStatus; + // } + + const projects = await prisma.project.findMany({ + where, + include: { + user: { + select: { + id: true, + name: true, + image: true, + }, + }, + votes: { + select: { + id: true, + userId: true, + }, + }, + _count: { + select: { + votes: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return NextResponse.json(projects); + } catch (error) { + console.error("Error fetching projects:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/projects/upload-metadata/route.ts b/app/api/projects/upload-metadata/route.ts new file mode 100644 index 00000000..4d3b976c --- /dev/null +++ b/app/api/projects/upload-metadata/route.ts @@ -0,0 +1,83 @@ +import { NextResponse } from "next/server"; +import { v2 as cloudinary } from "cloudinary"; + +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}); + +async function uploadImage(file: File): Promise { + const buffer = await file.arrayBuffer(); + const base64 = Buffer.from(buffer).toString("base64"); + const dataURI = `data:${file.type};base64,${base64}`; + + return new Promise((resolve, reject) => { + cloudinary.uploader.upload( + dataURI, + { resource_type: "auto", folder: "project_images" }, + (error, result) => { + if (error) reject(null); + else resolve(result?.secure_url || null); + }, + ); + }); +} + +async function uploadMetadata( + metadata: object, +): Promise<{ secure_url: string }> { + const jsonStr = JSON.stringify(metadata); + const buffer = Buffer.from(jsonStr); + const base64 = buffer.toString("base64"); + const dataURI = `data:application/json;base64,${base64}`; + + return new Promise<{ secure_url: string }>((resolve, reject) => { + cloudinary.uploader.upload( + dataURI, + { resource_type: "raw", folder: "project_metadata" }, + (error, result) => { + if (error) reject(error); + else resolve(result as { secure_url: string }); + }, + ); + }); +} + +export async function POST(request: Request) { + try { + const formData = await request.formData(); + const title = formData.get("title") as string; + const description = formData.get("description") as string; + const category = formData.get("category") as string; + const bannerImage = formData.get("bannerImage") as File | null; + const profileImage = formData.get("profileImage") as File | null; + + if (!title || !description || !category) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 }, + ); + } + + let bannerUrl = null; + let profileUrl = null; + if (bannerImage) bannerUrl = await uploadImage(bannerImage); + if (profileImage) profileUrl = await uploadImage(profileImage); + + const metadata = { title, description, category, bannerUrl, profileUrl }; + + const metadataUpload = await uploadMetadata(metadata); + + return NextResponse.json( + { metadataUri: metadataUpload.secure_url }, + { status: 201 }, + ); + } catch (error) { + console.error("Metadata upload error:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/components/connect-wallet.tsx b/components/connect-wallet.tsx index 3aa983e4..9475fcc2 100644 --- a/components/connect-wallet.tsx +++ b/components/connect-wallet.tsx @@ -1,23 +1,26 @@ "use client"; - import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Loader2, LogOut, Copy, Check } from "lucide-react"; import { toast } from "sonner"; -import { connect, disconnect, getPublicKey } from "@/hooks/useStellarWallet"; +import { useWalletStore } from "@/store/useWalletStore"; +import { formatAddress } from "@/lib/utils"; const ConnectWalletButton = ({ className = "" }) => { + const { + publicKey, + connecting, + connect: connectWallet, + disconnect: disconnectWallet, + } = useWalletStore(); + const [isChecking, setIsChecking] = useState(true); - const [isConnecting, setIsConnecting] = useState(false); - const [walletAddress, setWalletAddress] = useState(null); const [isCopied, setIsCopied] = useState(false); useEffect(() => { const checkConnection = async () => { try { - const address = await getPublicKey(); - if (address) { - setWalletAddress(address); + if (publicKey) { toast.success("Wallet Reconnected", { description: "Welcome back!", }); @@ -30,49 +33,39 @@ const ConnectWalletButton = ({ className = "" }) => { }; checkConnection(); - }, []); + }, [publicKey]); - const connectWallet = async () => { + const handleConnectWallet = async () => { try { - setIsConnecting(true); - await connect(async () => { - const address = await getPublicKey(); - if (!address) throw new Error("Failed to retrieve wallet address"); - - setWalletAddress(address); + await connectWallet(); + if (publicKey) { toast.success("Wallet Connected", { description: "Successfully connected to wallet", }); - }); + } } catch (error) { console.log(error); toast.error("Connection Failed", { description: "Failed to connect to the wallet.", }); - } finally { - setIsConnecting(false); } }; - const disconnectWallet = async () => { - await disconnect(); - setWalletAddress(null); + const handleDisconnectWallet = async () => { + await disconnectWallet(); toast.info("Wallet Disconnected", { description: "You have been disconnected.", }); }; const copyToClipboard = async () => { - if (!walletAddress) return; - + if (!publicKey) return; try { - await navigator.clipboard.writeText(walletAddress); + await navigator.clipboard.writeText(publicKey); setIsCopied(true); toast.success("Address Copied", { description: "Wallet address copied to clipboard", }); - - // Reset the copied state after 2 seconds setTimeout(() => { setIsCopied(false); }, 2000); @@ -83,27 +76,23 @@ const ConnectWalletButton = ({ className = "" }) => { } }; - const formatAddress = (address: string) => { - return `${address.slice(0, 6)}...${address.slice(-4)}`; - }; - return (
{isChecking ? ( - ) : isConnecting ? ( + ) : connecting ? ( - ) : walletAddress ? ( + ) : publicKey ? ( ) : ( - )} - - {walletAddress && ( - )} diff --git a/components/project-form.tsx b/components/project-form.tsx index b183a148..7961368a 100644 --- a/components/project-form.tsx +++ b/components/project-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; @@ -25,6 +25,9 @@ import { SelectValue, } from "@/components/ui/select"; import { toast } from "sonner"; +import { useWalletStore } from "@/store/useWalletStore"; +import { signTransaction } from "@/hooks/useStellarWallet"; +import createProject from "@/src/contracts/project_contract"; const projectFormSchema = z.object({ userId: z.string().min(1, "User ID is required"), @@ -38,6 +41,7 @@ const projectFormSchema = z.object({ category: z.string().min(1, "Category is required"), bannerImage: z.union([z.string().url(), z.instanceof(File)]).optional(), profileImage: z.union([z.string().url(), z.instanceof(File)]).optional(), + walletAddress: z.string(), ileUrl: z.string().optional(), }); @@ -53,6 +57,8 @@ const categories = [ export function ProjectForm({ userId }: { userId?: string }) { const router = useRouter(); const [isLoading, setIsLoading] = useState(false); + const [status, setStatus] = useState(""); + const { publicKey } = useWalletStore(); const form = useForm({ resolver: zodResolver(projectFormSchema), @@ -65,17 +71,81 @@ export function ProjectForm({ userId }: { userId?: string }) { }, }); + // Set wallet address in form when publicKey changes + useEffect(() => { + if (publicKey) { + form.setValue("walletAddress", publicKey); + createProject.options.publicKey = publicKey; + createProject.options.signTransaction = signTransaction; + } + }, [publicKey, form]); + + async function handleUploadMetadata( + data: ProjectFormValues, + ): Promise { + setStatus("Uploading metadata..."); + + const formData = new FormData(); + formData.append("title", data.title); + formData.append("description", data.description); + formData.append("category", data.category); + + if (data.bannerImage instanceof File) { + formData.append("bannerImage", data.bannerImage); + } + if (data.profileImage instanceof File) { + formData.append("profileImage", data.profileImage); + } + + const response = await fetch("/api/projects/upload-metadata", { + method: "POST", + body: formData, + }); + + const resData = await response.json(); + if (!response.ok) { + throw new Error(resData.error || "Failed to upload metadata"); + } + + return resData.metadataUri; + } + async function onSubmit(data: ProjectFormValues) { try { setIsLoading(true); + if (!publicKey) { + throw new Error("Wallet is not connected"); + } + + const metadataUri = await handleUploadMetadata(data); + + const projectId = crypto.randomUUID(); + + setStatus("Building transaction..."); + const tx = await createProject.create_project({ + project_id: projectId, + creator: publicKey, + metadata_uri: metadataUri, + funding_target: BigInt(data.fundingGoal), + milestone_count: 3, + }); + + setStatus("Signing transaction..."); + const { getTransactionResponse } = await tx.signAndSend(); + // const txHash = getTransactionResponse?.txHash; + + // console.log({ result, getTransactionResponse }); + setStatus("Sending signed transaction to server..."); const formData = new FormData(); formData.append("userId", userId || ""); formData.append("title", data.title); formData.append("description", data.description); formData.append("fundingGoal", data.fundingGoal); formData.append("category", data.category); - + formData.append("projectId", projectId); + formData.append("signedTx", getTransactionResponse?.txHash ?? ""); + formData.append("metadataUri", metadataUri); if (data.bannerImage) { if (typeof data.bannerImage === "string") { formData.append("bannerImageUrl", data.bannerImage); @@ -113,12 +183,14 @@ export function ProjectForm({ userId }: { userId?: string }) { ); } finally { setIsLoading(false); + setStatus(""); } } return (
+ {status &&

{status}

} ([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const { data: session } = useSession(); + + useEffect(() => { + async function fetchProjects() { + try { + const response = await fetch("/api/projects", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + forUser: true, // or false, depending on your needs + }), + }); + + if (!response.ok) { + throw new Error("Failed to fetch projects"); + } + + const data = await response.json(); + setProjects(data); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + console.error(err); + } finally { + setLoading(false); + } + } + + fetchProjects(); + }, []); + + if (loading) { + return
Loading projects...
; + } + + if (error) { + return ( +
Error: {error}
+ ); + } + + if (!projects.length) { + return
No projects found
; + } + + return ( +
+ {projects.map((project) => { + // Check if the current user has voted for this project + const userVoted = session?.user?.id + ? project.votes.some((vote) => vote.userId === session.user?.id) + : false; + + return ( + + + {project.bannerUrl ? ( +
+ {project.title} +
+ ) : ( +
+ No banner image +
+ )} + + + {project.profileUrl && ( +
+ {`${project.title} +
+ )} +
+
+

+ {project.title} +

+
+

+ {project.description} +

+
+
+ + + +
+ + {project.category.slice(0, 1).toUpperCase() + + project.category.slice(1).toLowerCase()} + + + {formatValidationStatus(project.ideaValidation)} + +
+
+ + {project.ideaValidation === "VALIDATED" + ? "FUNDING STAGE" + : "IDEA VALIDATION STAGE"} + +
+ + {/* Vote Button - only show if project is in PENDING state */} + {project.ideaValidation === "PENDING" && ( +
+ +
+ )} +
+
+ ); + })} +
+ ); +} + +function formatValidationStatus(status: ValidationStatus | null | undefined) { + if (!status) return "Unknown"; + return status.charAt(0) + status.slice(1).toLowerCase(); +} diff --git a/contracts/project_contract/src/create_project/create_project.rs b/contracts/project_contract/src/create_project/create_project.rs index 1926bdbb..ba0687d2 100644 --- a/contracts/project_contract/src/create_project/create_project.rs +++ b/contracts/project_contract/src/create_project/create_project.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contract, contractimpl, Address, Env}; +use soroban_sdk::{contract, contractimpl, Address, Env, String}; use crate::project::Project; use super::{CreateProjectEvent, CreateProjectStorage}; @@ -9,13 +9,11 @@ pub struct CreateProject; #[contractimpl] impl CreateProject { - pub fn create_project(env: Env, project_id: u64, creator: Address, funding_target: u64, milestone_count: u32) { - let project = Project::new(&env, project_id, creator.clone(), funding_target, milestone_count); + pub fn create_project(env: Env, project_id: String, creator: Address, metadata_uri: String, funding_target: u64, milestone_count: u32) { + let project = Project::new(&env, project_id.clone(), creator.clone(), metadata_uri, funding_target, milestone_count); - // Save to storage CreateProjectStorage::save(&env, &project); - // Emit event CreateProjectEvent::emit(&env, project_id, creator); } } diff --git a/contracts/project_contract/src/create_project/create_project_event.rs b/contracts/project_contract/src/create_project/create_project_event.rs index 2e0ddffd..38f11f13 100644 --- a/contracts/project_contract/src/create_project/create_project_event.rs +++ b/contracts/project_contract/src/create_project/create_project_event.rs @@ -1,9 +1,9 @@ -use soroban_sdk::{Env, Address, Symbol}; +use soroban_sdk::{Address, Env, String, Symbol}; pub struct CreateProjectEvent; impl CreateProjectEvent { - pub fn emit(env: &Env, project_id: u64, creator: Address) { + pub fn emit(env: &Env, project_id: String, creator: Address) { env.events().publish( (Symbol::new(env, "create_project"), project_id), creator, diff --git a/contracts/project_contract/src/create_project/create_project_storage.rs b/contracts/project_contract/src/create_project/create_project_storage.rs index 8da7cd20..d3ac5d95 100644 --- a/contracts/project_contract/src/create_project/create_project_storage.rs +++ b/contracts/project_contract/src/create_project/create_project_storage.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{Env, Address}; +use soroban_sdk::{Env, String}; use crate::project::Project; pub struct CreateProjectStorage; @@ -7,8 +7,8 @@ impl CreateProjectStorage { pub fn save(env: &Env, project: &Project) { env.storage().instance().set(&project.project_id, project); } - - pub fn get(env: &Env, project_id: u64) -> Option { - env.storage().instance().get(&project_id) + + pub fn get(env: &Env, project_id: &String) -> Option { + env.storage().instance().get(project_id) } } diff --git a/contracts/project_contract/src/project.rs b/contracts/project_contract/src/project.rs index 8e183613..d03f4cfe 100644 --- a/contracts/project_contract/src/project.rs +++ b/contracts/project_contract/src/project.rs @@ -1,31 +1,44 @@ -#![no_std] - -use soroban_sdk::{contracttype, Address, Env, Vec}; +use soroban_sdk::{contracttype, Address, Env, String, Vec}; #[derive(Clone)] #[contracttype] pub struct Project { - pub project_id: u64, + pub project_id: String, pub creator: Address, + pub metadata_uri: String, pub funding_target: u64, pub milestone_count: u32, pub current_milestone: u32, pub total_funded: u64, pub backers: Vec<(Address, u64)>, pub validated: bool, + pub is_successful: bool, + pub is_closed: bool, + pub created_at: u64, } impl Project { - pub fn new(env: &Env, project_id: u64, creator: Address, funding_target: u64, milestone_count: u32) -> Self { + pub fn new( + env: &Env, + project_id: String, + creator: Address, + metadata_uri: String, + funding_target: u64, + milestone_count: u32, + ) -> Self { Self { project_id, creator, + metadata_uri, funding_target, milestone_count, current_milestone: 0, total_funded: 0, backers: Vec::new(env), validated: false, + is_successful: false, + is_closed: false, + created_at: env.ledger().timestamp(), } } } diff --git a/contracts/project_contract/src/tests/create_project_test.rs b/contracts/project_contract/src/tests/create_project_test.rs index 3677cc84..db57c58b 100644 --- a/contracts/project_contract/src/tests/create_project_test.rs +++ b/contracts/project_contract/src/tests/create_project_test.rs @@ -1,28 +1,34 @@ #[cfg(test)] mod tests { - use soroban_sdk::{Env, Address, String}; - - use crate::create_project::{CreateProjectStorage, CreateProject}; + use soroban_sdk::{Env, Address, String}; + use crate::create_project::{CreateProjectStorage, CreateProject, CreateProjectClient}; #[test] fn test_create_project() { let env = Env::default(); - let contract_id = env.register(CreateProject, ()); + let client = CreateProjectClient::new(&env, &contract_id); - env.as_contract(&contract_id, || { - let creator_key = String::from_str(&env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"); - let creator = Address::from_string(&creator_key); - - CreateProject::create_project(env.clone(), 1, creator.clone(), 10000, 5); - - let project = CreateProjectStorage::get(&env, 1).unwrap(); - - assert_eq!(project.project_id, 1); - assert_eq!(project.creator, creator); - assert_eq!(project.funding_target, 10000); - assert_eq!(project.milestone_count, 5); - assert_eq!(project.total_funded, 0); + let creator_key = String::from_str(&env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"); + let creator = Address::from_string(&creator_key); + let project_id = String::from_str(&env, "project-1"); + let meta_uri = String::from_str(&env, "ipfs://QmExample"); + + client.create_project(&project_id, &creator, &meta_uri, &10000, &5); + + let project = env.as_contract(&contract_id, || { + CreateProjectStorage::get(&env, &project_id).expect("Project not found") }); + + assert_eq!(project.project_id, project_id); + assert_eq!(project.creator, creator); + assert_eq!(project.metadata_uri, meta_uri); + assert_eq!(project.funding_target, 10000); + assert_eq!(project.milestone_count, 5); + assert_eq!(project.total_funded, 0); + assert_eq!(project.current_milestone, 0); + assert_eq!(project.validated, false); + assert_eq!(project.is_successful, false); + assert_eq!(project.is_closed, false); } } diff --git a/contracts/project_contract/test_snapshots/tests/create_project_test/tests/test_create_project.1.json b/contracts/project_contract/test_snapshots/tests/create_project_test/tests/test_create_project.1.json new file mode 100644 index 00000000..d79bb2cd --- /dev/null +++ b/contracts/project_contract/test_snapshots/tests/create_project_test/tests/test_create_project.1.json @@ -0,0 +1,179 @@ +{ + "generators": { + "address": 1, + "nonce": 0 + }, + "auth": [[], [], []], + "ledger": { + "protocol_version": 22, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + [ + { + "contract_data": { + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "string": "project-1" + }, + "val": { + "map": [ + { + "key": { + "symbol": "backers" + }, + "val": { + "vec": [] + } + }, + { + "key": { + "symbol": "created_at" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "creator" + }, + "val": { + "address": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" + } + }, + { + "key": { + "symbol": "current_milestone" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "funding_target" + }, + "val": { + "u64": 10000 + } + }, + { + "key": { + "symbol": "is_closed" + }, + "val": { + "bool": false + } + }, + { + "key": { + "symbol": "is_successful" + }, + "val": { + "bool": false + } + }, + { + "key": { + "symbol": "metadata_uri" + }, + "val": { + "string": "ipfs://QmExample" + } + }, + { + "key": { + "symbol": "milestone_count" + }, + "val": { + "u32": 5 + } + }, + { + "key": { + "symbol": "project_id" + }, + "val": { + "string": "project-1" + } + }, + { + "key": { + "symbol": "total_funded" + }, + "val": { + "u64": 0 + } + }, + { + "key": { + "symbol": "validated" + }, + "val": { + "bool": false + } + } + ] + } + } + ] + } + } + } + }, + "ext": "v0" + }, + 4095 + ] + ], + [ + { + "contract_code": { + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } + }, + [ + { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + 4095 + ] + ] + ] + }, + "events": [] +} diff --git a/lib/utils.ts b/lib/utils.ts index 4b138ca6..f2938055 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -12,3 +12,7 @@ export function formatValidationStatus( if (!status) return "Unknown"; return status.charAt(0) + status.slice(1).toLowerCase(); } + +export const formatAddress = (address: string) => { + return `${address.slice(0, 6)}...${address.slice(-4)}`; +}; diff --git a/package-lock.json b/package-lock.json index 4c3f2350..4ba46e49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,9 @@ "sonner": "^2.0.1", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", - "zod": "^3.24.2" + "uuid": "^11.1.0", + "zod": "^3.24.2", + "zustand": "^5.0.3" }, "devDependencies": { "@biomejs/biome": "1.9.4", @@ -11044,6 +11046,15 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/next-auth/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/next-themes": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.4.tgz", @@ -12132,6 +12143,10 @@ "integrity": "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==", "license": "MIT" }, + "node_modules/project_contract": { + "resolved": "packages/project_contract", + "link": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -14614,12 +14629,16 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { @@ -15150,9 +15169,37 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zustand": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz", + "integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "packages/project_contract": { "version": "0.0.0", - "extraneous": true, "dependencies": { "@stellar/stellar-sdk": "13.0.0", "buffer": "6.0.3" @@ -15160,6 +15207,48 @@ "devDependencies": { "typescript": "^5.6.2" } + }, + "packages/project_contract/node_modules/@stellar/stellar-base": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-13.0.1.tgz", + "integrity": "sha512-Xbd12mc9Oj/130Tv0URmm3wXG77XMshZtZ2yNCjqX5ZbMD5IYpbBs3DVCteLU/4SLj/Fnmhh1dzhrQXnk4r+pQ==", + "license": "Apache-2.0", + "dependencies": { + "@stellar/js-xdr": "^3.1.2", + "base32.js": "^0.1.0", + "bignumber.js": "^9.1.2", + "buffer": "^6.0.3", + "sha.js": "^2.3.6", + "tweetnacl": "^1.0.3" + }, + "optionalDependencies": { + "sodium-native": "^4.3.0" + } + }, + "packages/project_contract/node_modules/@stellar/stellar-sdk": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-13.0.0.tgz", + "integrity": "sha512-+wvmKi+XWwu27nLYTM17EgBdpbKohEkOfCIK4XKfsI4WpMXAqvnqSm98i9h5dAblNB+w8BJqzGs1JY0PtTGm4A==", + "license": "Apache-2.0", + "dependencies": { + "@stellar/stellar-base": "^13.0.1", + "axios": "^1.7.7", + "bignumber.js": "^9.1.2", + "eventsource": "^2.0.2", + "feaxios": "^0.0.20", + "randombytes": "^2.1.0", + "toml": "^3.0.0", + "urijs": "^1.19.1" + } + }, + "packages/project_contract/node_modules/feaxios": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.20.tgz", + "integrity": "sha512-g3hm2YDNffNxA3Re3Hd8ahbpmDee9Fv1Pb1C/NoWsjY7mtD8nyNeJytUzn+DK0Hyl9o6HppeWOrtnqgmhOYfWA==", + "license": "MIT", + "dependencies": { + "is-retry-allowed": "^3.0.0" + } } } } diff --git a/package.json b/package.json index a1c59999..8836310c 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,9 @@ "sonner": "^2.0.1", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", - "zod": "^3.24.2" + "uuid": "^11.1.0", + "zod": "^3.24.2", + "zustand": "^5.0.3" }, "workspaces": ["packages/*"], "devDependencies": { diff --git a/store/useWalletStore.ts b/store/useWalletStore.ts new file mode 100644 index 00000000..e563933a --- /dev/null +++ b/store/useWalletStore.ts @@ -0,0 +1,58 @@ +import { connect, disconnect, getPublicKey } from "@/hooks/useStellarWallet"; +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; + +type WalletStore = { + isConnected: boolean; + publicKey: string | null; + connecting: boolean; + connect: () => Promise; + disconnect: () => Promise; + setWallet: () => void; +}; + +export const useWalletStore = create()( + persist( + (set, get) => ({ + isConnected: false, + publicKey: null, + connecting: false, + + connect: async () => { + try { + set({ connecting: true }); + await connect(async () => { + const publicKey = await getPublicKey(); + set({ + isConnected: Boolean(publicKey), + publicKey, + connecting: false, + }); + }); + } catch (error) { + console.error("Failed to connect wallet:", error); + set({ connecting: false }); + } + }, + + disconnect: async () => { + try { + await disconnect(async () => { + set({ + isConnected: false, + publicKey: null, + }); + }); + } catch (error) { + console.error("Failed to disconnect wallet:", error); + } + }, + + setWallet: () => set({ isConnected: !get().isConnected }), + }), + { + name: "wallet-storage", + storage: createJSONStorage(() => localStorage), + }, + ), +);