Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions frontend/app/api/join-requests/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
41 changes: 41 additions & 0 deletions frontend/app/join/[contractId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card className="p-8">
<h2 className="text-2xl font-bold mb-2">Pool not found</h2>
<p className="text-muted-foreground">The invite link appears invalid or the pool no longer exists.</p>
</Card>
)
}

return (
<div className="max-w-3xl mx-auto p-4">
<Card className="p-6 mb-4">
<h1 className="text-3xl font-bold mb-2">{pool.name}</h1>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm text-muted-foreground">Type: {pool.type}</span>
<span className="text-sm text-muted-foreground">Members: {pool.members_count}</span>
</div>
<p className="text-sm text-muted-foreground mb-2">Creator: {pool.creator_address}</p>
<p className="text-sm text-muted-foreground mb-4">Contract: <span className="font-mono break-all">{pool.contract_address}</span></p>
</Card>

<JoinActions poolId={pool.id} contractId={pool.contract_address} poolType={pool.type} contributionAmount={pool.contribution_amount} minimumDeposit={pool.minimum_deposit} />
</div>
)
}
22 changes: 20 additions & 2 deletions frontend/components/group/group-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +26,7 @@ interface GroupData {

export function GroupDetails({ groupId }: { groupId: string }) {
const { address } = useStellar()
const { toast } = useToast()
const [group, setGroup] = useState<GroupData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
Expand Down Expand Up @@ -152,9 +155,24 @@ export function GroupDetails({ groupId }: { groupId: string }) {
{onchainState && <Badge variant="outline" className="text-xs">Live onchain</Badge>}
</div>
</div>
<Button variant="ghost" size="icon" onClick={() => group && fetchOnchainData(group)} disabled={onchainLoading}>
<div className="flex items-center gap-2">
{group.creator_address && address && group.creator_address.toLowerCase() === address.toLowerCase() && (
<Button variant="outline" onClick={async () => {
try {
const url = `https://joint-save.vercel.app/join/${group.contract_address}`
await navigator.clipboard.writeText(url)
toast({ title: 'Link copied!' })
} catch (e) {
toast({ title: 'Failed to copy link' })
}
}}>
Share Invite Link
</Button>
)}
<Button variant="ghost" size="icon" onClick={() => group && fetchOnchainData(group)} disabled={onchainLoading}>
<RefreshCw className={`h-4 w-4 ${onchainLoading ? "animate-spin" : ""}`} />
</Button>
</div>
</div>

{group.description && <p className="text-muted-foreground mb-6">{group.description}</p>}
Expand Down
79 changes: 79 additions & 0 deletions frontend/components/join/join-actions.tsx
Original file line number Diff line number Diff line change
@@ -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<any>(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 (
<Card className="p-6">
<h2 className="text-xl font-semibold mb-2">Join Pool</h2>
<p className="text-sm text-muted-foreground mb-4">Deposit requirement: {depositReq()}</p>
<div className="space-x-2">
{!address ? (
<Button onClick={() => connect()}>Connect Wallet to Request Join</Button>
) : (
<Button onClick={handleRequestJoin} disabled={loading}>{loading ? 'Requesting...' : 'Request to Join'}</Button>
)}
</div>
</Card>
)
}
44 changes: 44 additions & 0 deletions scripts/README_capture_screenshots.md
Original file line number Diff line number Diff line change
@@ -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 <CONTRACT_ID>
```

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 <CONTRACT_ID>

# cmd
set "BASE_URL=http://localhost:3000"
node scripts/capture_join_flow.js <CONTRACT_ID>
```

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.
85 changes: 85 additions & 0 deletions scripts/capture_join_flow.js
Original file line number Diff line number Diff line change
@@ -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 <CONTRACT_ID>');
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}`);
})();
8 changes: 8 additions & 0 deletions supabase/migrations/20260616_create_join_requests.sql
Original file line number Diff line number Diff line change
@@ -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()
);
Loading