From d11f10c1d916dbc82a40674d6170cabcd379307b Mon Sep 17 00:00:00 2001 From: ALEX AKPOJOSEVBE Date: Tue, 16 Jun 2026 08:53:12 -0700 Subject: [PATCH 1/2] Title: Add invite links & join-request flow (join page, API, share button) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Adds invite/join UX so pool creators can share a link and users can request to join. New/updated files: page.tsx — join page, fetches pool by contract_address join-actions.tsx — client join UI, on-chain reads, wallet connect + request action route.ts — POST API to create join requests in Supabase group-details.tsx — “Share Invite Link” button (copies URL + toast) for creators join_requests.sql — SQL to create join_requests table --- deployments/join_requests.sql | 8 +++ frontend/app/api/join-requests/route.ts | 26 +++++++ frontend/app/join/[contractId]/page.tsx | 41 +++++++++++ frontend/components/group/group-details.tsx | 22 +++++- frontend/components/join/join-actions.tsx | 79 +++++++++++++++++++++ 5 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 deployments/join_requests.sql create mode 100644 frontend/app/api/join-requests/route.ts create mode 100644 frontend/app/join/[contractId]/page.tsx create mode 100644 frontend/components/join/join-actions.tsx diff --git a/deployments/join_requests.sql b/deployments/join_requests.sql new file mode 100644 index 0000000..fc5c5c8 --- /dev/null +++ b/deployments/join_requests.sql @@ -0,0 +1,8 @@ +-- Migration: create join_requests table +CREATE TABLE IF NOT EXISTS join_requests ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + pool_id UUID REFERENCES pools(id) ON DELETE CASCADE, + requester_address TEXT NOT NULL, + status TEXT DEFAULT 'pending', + created_at TIMESTAMP DEFAULT NOW() +); diff --git a/frontend/app/api/join-requests/route.ts b/frontend/app/api/join-requests/route.ts new file mode 100644 index 0000000..935b69e --- /dev/null +++ b/frontend/app/api/join-requests/route.ts @@ -0,0 +1,26 @@ +import { supabase } from '@/lib/supabase' +import { NextRequest, NextResponse } from 'next/server' + +export async function POST(req: NextRequest) { + try { + const body = await req.json() + const { pool_id, requester_address } = body + if (!pool_id || !requester_address) { + return NextResponse.json({ error: 'pool_id and requester_address required' }, { status: 400 }) + } + + const { data, error } = await supabase.from('join_requests').insert([ + { pool_id, requester_address: requester_address.toLowerCase(), status: 'pending' } + ]) + + if (error) { + console.error('Join request error:', error) + return NextResponse.json({ error: error.message || 'Failed to create join request' }, { status: 500 }) + } + + return NextResponse.json({ success: true, request: data?.[0] }, { status: 201 }) + } catch (err) { + console.error('Join request exception:', err) + return NextResponse.json({ error: err instanceof Error ? err.message : 'Unknown error' }, { status: 500 }) + } +} diff --git a/frontend/app/join/[contractId]/page.tsx b/frontend/app/join/[contractId]/page.tsx new file mode 100644 index 0000000..3ee437c --- /dev/null +++ b/frontend/app/join/[contractId]/page.tsx @@ -0,0 +1,41 @@ +import { supabase } from '@/lib/supabase' +import { notFound } from 'next/navigation' +import JoinActions from '@/components/join/join-actions' +import { Card } from '@/components/ui/card' + +export default async function Page({ params }: { params: { contractId: string } }) { + const contractId = params.contractId + + // Fetch pool by contract_address + const { data: pool, error } = await supabase + .from('pools') + .select('*') + .eq('contract_address', contractId) + .limit(1) + .single() + + if (error || !pool) { + return ( + +

Pool not found

+

The invite link appears invalid or the pool no longer exists.

+
+ ) + } + + return ( +
+ +

{pool.name}

+
+ Type: {pool.type} + Members: {pool.members_count} +
+

Creator: {pool.creator_address}

+

Contract: {pool.contract_address}

+
+ + +
+ ) +} diff --git a/frontend/components/group/group-details.tsx b/frontend/components/group/group-details.tsx index 0d3dc94..33239f3 100644 --- a/frontend/components/group/group-details.tsx +++ b/frontend/components/group/group-details.tsx @@ -7,15 +7,17 @@ import { Calendar, TrendingUp, Users, Clock, Loader2, RefreshCw } from "lucide-r import { Button } from "@/components/ui/button" import { motion } from "framer-motion" import { useState, useEffect, useCallback } from "react" +import { useToast } from "@/hooks/use-toast" +import { useStellar } from "@/components/web3-provider" import { fetchRotationalState, fetchTargetState, fetchFlexibleState, stroopsToXlm, RotationalPoolState, TargetPoolState, FlexiblePoolState, } from "@/hooks/useJointSaveContracts" -import { useStellar } from "@/components/web3-provider" interface GroupData { id: string; name: string; type: "rotational" | "target" | "flexible" status: "active" | "completed" | "paused"; description: string | null + creator_address: string total_saved: number; target_amount: number | null; progress: number members_count: number; next_payout: string | null; next_recipient: string | null created_at: string; contribution_amount: number | null; frequency: string | null @@ -24,6 +26,7 @@ interface GroupData { export function GroupDetails({ groupId }: { groupId: string }) { const { address } = useStellar() + const { toast } = useToast() const [group, setGroup] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState("") @@ -152,9 +155,24 @@ export function GroupDetails({ groupId }: { groupId: string }) { {onchainState && Live onchain} - + )} + + {group.description &&

{group.description}

} diff --git a/frontend/components/join/join-actions.tsx b/frontend/components/join/join-actions.tsx new file mode 100644 index 0000000..042aabd --- /dev/null +++ b/frontend/components/join/join-actions.tsx @@ -0,0 +1,79 @@ +"use client" + +import { useState, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { useStellar } from '@/components/web3-provider' +import { useToast } from '@/hooks/use-toast' +import { fetchRotationalState, fetchTargetState, fetchFlexibleState, stroopsToXlm } from '@/hooks/useJointSaveContracts' + +export default function JoinActions({ poolId, contractId, poolType, contributionAmount, minimumDeposit }: { + poolId: string + contractId: string + poolType: string + contributionAmount?: number | null + minimumDeposit?: number | null +}) { + const { address, connect } = useStellar() + const { toast } = useToast() + const [onchainInfo, setOnchainInfo] = useState(null) + const [loading, setLoading] = useState(false) + + useEffect(() => { + let mounted = true + ;(async () => { + try { + if (!contractId) return + if (poolType === 'rotational') { + const s = await fetchRotationalState(contractId) + if (mounted) setOnchainInfo(s) + } else if (poolType === 'target') { + const s = await fetchTargetState(contractId) + if (mounted) setOnchainInfo(s) + } else { + const s = await fetchFlexibleState(contractId) + if (mounted) setOnchainInfo(s) + } + } catch (e) {} + })() + return () => { mounted = false } + }, [contractId, poolType]) + + const handleRequestJoin = async () => { + if (!address) return connect() + setLoading(true) + try { + const res = await fetch('/api/join-requests', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pool_id: poolId, requester_address: address }) + }) + if (!res.ok) throw new Error('Failed to send request') + toast({ title: 'Request submitted', description: 'The creator will review your request.' }) + } catch (e) { + toast({ title: 'Request failed' }) + } finally { + setLoading(false) + } + } + + const depositReq = () => { + if (poolType === 'rotational') return contributionAmount ? `${contributionAmount} XLM` : 'N/A' + if (poolType === 'target') return onchainInfo ? `${stroopsToXlm(onchainInfo.totalDeposited).toFixed(2)} / ${stroopsToXlm(onchainInfo.targetAmount).toFixed(2)} XLM` : 'N/A' + return minimumDeposit ? `${minimumDeposit} XLM` : 'N/A' + } + + return ( + +

Join Pool

+

Deposit requirement: {depositReq()}

+
+ {!address ? ( + + ) : ( + + )} +
+
+ ) +} From c4aeecf936a9d571d6d47ac720691b683acd3f94 Mon Sep 17 00:00:00 2001 From: ALEX AKPOJOSEVBE Date: Tue, 16 Jun 2026 17:07:47 -0700 Subject: [PATCH 2/2] fixes effected changes made --- scripts/README_capture_screenshots.md | 44 ++++++++++ scripts/capture_join_flow.js | 85 +++++++++++++++++++ .../20260616_create_join_requests.sql | 0 3 files changed, 129 insertions(+) create mode 100644 scripts/README_capture_screenshots.md create mode 100644 scripts/capture_join_flow.js rename deployments/join_requests.sql => supabase/migrations/20260616_create_join_requests.sql (100%) diff --git a/scripts/README_capture_screenshots.md b/scripts/README_capture_screenshots.md new file mode 100644 index 0000000..77c0ffd --- /dev/null +++ b/scripts/README_capture_screenshots.md @@ -0,0 +1,44 @@ +Run this Playwright script locally to capture screenshots of the join flow. + +Prerequisites +- Node.js installed +- The frontend dev server running at `http://localhost:3000` + +Install +``` +npm install -D playwright +npx playwright install chromium +``` + +Usage +``` +# from repository root +node scripts/capture_join_flow.js +``` + +Example +``` +node scripts/capture_join_flow.js CBZNGP52FLFZ4BOGC265FUAMP5KFMAYPQK3KTI5UHMYVMM3QCST3IMRI +``` + +If your frontend runs on a different port: +``` +# PowerShell +$env:BASE_URL = 'http://localhost:3000' +node scripts/capture_join_flow.js + +# cmd +set "BASE_URL=http://localhost:3000" +node scripts/capture_join_flow.js +``` + +Output +- `scripts/screenshots/share-button.png` or `scripts/screenshots/share-button-fallback.png` +- `scripts/screenshots/join-flow.png` + +Troubleshooting +- If the script says the page failed to load, make sure the frontend is running and reachable at the URL printed in the error. +- If the Share button is not found, the script saves a fallback full-page screenshot instead. +- If the browser download fails, use `npx playwright install chromium` to install only Chromium. + +If selectors don't match your app, update `scripts/capture_join_flow.js` with the correct button selectors. diff --git a/scripts/capture_join_flow.js b/scripts/capture_join_flow.js new file mode 100644 index 0000000..39d453d --- /dev/null +++ b/scripts/capture_join_flow.js @@ -0,0 +1,85 @@ +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +(async () => { + const contractId = process.argv[2] || process.env.CONTRACT_ID; + if (!contractId) { + console.error('Usage: node capture_join_flow.js '); + process.exit(1); + } + + const base = process.env.BASE_URL || 'http://localhost:3000'; + const url = `${base.replace(/\/$/, '')}/join/${contractId}`; + const outputDir = path.resolve(__dirname, 'screenshots'); + + if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }); + + const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); + const page = await browser.newPage(); + + try { + const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }); + if (!response || response.status() >= 400) { + throw new Error(`Page failed to load: ${response ? response.status() : 'no response'}`); + } + } catch (error) { + console.error(`Failed to load ${url}`); + console.error(error.message); + await browser.close(); + process.exit(1); + } + + await page.waitForTimeout(1000); + + const shareSelectorCandidates = [ + 'button:has-text("Share")', + '[aria-label="share"]', + '[data-test="share"]', + 'button[class*=share]', + 'a:has-text("Share")', + ]; + + let savedShare = false; + for (const sel of shareSelectorCandidates) { + const el = await page.$(sel); + if (el) { + await el.screenshot({ path: path.join(outputDir, 'share-button.png') }); + savedShare = true; + break; + } + } + + if (!savedShare) { + console.warn('Share button not found using default selectors. Saving a full-page screenshot for manual cropping.'); + await page.screenshot({ path: path.join(outputDir, 'share-button-fallback.png'), fullPage: true }); + } + + const actionSelectors = [ + 'button:has-text("Request")', + 'button:has-text("Join")', + '[data-test="request-join"]', + 'button[class*=join]', + ]; + + let clicked = false; + for (const sel of actionSelectors) { + const btn = await page.$(sel); + if (btn) { + await btn.click(); + clicked = true; + break; + } + } + + if (clicked) { + await page.waitForTimeout(1500); + } else { + console.warn('No join/request button found using default selectors. Capturing the page anyway.'); + } + + await page.screenshot({ path: path.join(outputDir, 'join-flow.png'), fullPage: true }); + + await browser.close(); + console.log(`Screenshots saved to ${outputDir}`); +})(); diff --git a/deployments/join_requests.sql b/supabase/migrations/20260616_create_join_requests.sql similarity index 100% rename from deployments/join_requests.sql rename to supabase/migrations/20260616_create_join_requests.sql