Skip to content
Merged
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
11 changes: 10 additions & 1 deletion apps/web/app/api/auth/me/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { NextResponse } from 'next/server';
import { getSessionDid } from '@/lib/auth';
import { memberByDid } from '@/lib/members';

export async function GET() {
const did = await getSessionDid();
return NextResponse.json({ did: did ?? null });
let email: string | null = null;
if (did) {
try {
email = (await memberByDid(did))?.email ?? null;
} catch {
email = null;
}
}
return NextResponse.json({ did: did ?? null, email });
}
51 changes: 32 additions & 19 deletions apps/web/app/api/bounties/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import { dbErrorResponse } from '@/lib/api-error';
import { getSessionDid } from '@/lib/auth';
import { generatePublicId } from '@/lib/id';
import { memberByDid } from '@/lib/members';

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

const COINPAY_BASE = 'https://coinpayportal.com';
const API_KEY = process.env.COINPAY_API_KEY!;
const MERCHANT_ID = process.env.COINPAY_MERCHANT_ID!;
const APP_URL = process.env.NEXT_PUBLIC_BASE_URL!;

export async function GET() {
Expand All @@ -32,13 +34,24 @@
const did = await getSessionDid();
if (!did) return NextResponse.json({ error: 'Authentication required' }, { status: 401 });

const { title, description, url, reward_usd, store_id, store_name } = await req.json();
const { title, description, url, reward_usd, store_id, store_name, email: bodyEmail } = await req.json();

if (!title?.trim()) return NextResponse.json({ error: 'Title is required' }, { status: 400 });
const reward = parseFloat(reward_usd);
if (isNaN(reward) || reward < 0.10) return NextResponse.json({ error: 'Minimum reward is $0.10' }, { status: 400 });
if (!store_id && !store_name?.trim()) return NextResponse.json({ error: 'Store is required' }, { status: 400 });

// CoinPay checkout sessions need the payer's email: the one on file from
// the CoinPay login, else the one typed on the form.
let email = '';
try {
email = (await memberByDid(did))?.email?.trim().toLowerCase() ?? '';
} catch { /* members table unavailable: fall back to the form */ }
if (!email && typeof bodyEmail === 'string') email = bodyEmail.trim().toLowerCase();
if (!EMAIL.test(email) || email.length > 320) {
return NextResponse.json({ error: 'A valid email is required to fund the bounty' }, { status: 400 });
}

try {
const db = getDb();
const publicId = generatePublicId();
Expand All @@ -59,36 +72,36 @@
)
`;

// Create a CoinPay payment for the creator to fund the bounty
// Docs: POST /api/payments/create
let paymentAddress: string | null = null;
let paymentId: string | null = null;
// Hosted CoinPay checkout session: the creator funds the bounty with any
// coin the business has a wallet for, or PayPal. payment_id holds the
// cs_ session id until the webhook replaces it with the paid payment id.
let payUrl: string | null = null;
let sessionId: string | null = null;

try {
const coinpay = createCoinPayClient({ apiKey: API_KEY, baseUrl: COINPAY_BASE });
const checkout = await coinpay.createCheckout({
amountUsd: reward,
currency: 'usdc_pol', // default to USDC on Polygon — low fees
// Keep the API's implicit default ('crypto'); 'both' would require Stripe Connect.
paymentMethod: 'crypto',
description: `Coupon bounty: ${title.trim()}`,
redirectUrl: `${APP_URL}/bounties/${publicId}?funded=1`,
customerEmail: email,
accountId: did,
property: 'c0upons.com',
description: `Coupon bounty: ${title.trim()}`.slice(0, 500),
successUrl: `${APP_URL}/bounties/${publicId}?funded=1`,
cancelUrl: `${APP_URL}/bounties/${publicId}`,
metadata: { type: 'bounty_fund', bounty_id: publicId, creator_did: did },
businessId: MERCHANT_ID,
});
paymentId = checkout.paymentId;
paymentAddress = checkout.payment.payment_address ?? null;
await db.sql`UPDATE bounties SET payment_id = ${paymentId} WHERE public_id = ${publicId}`;
sessionId = checkout.sessionId;
payUrl = checkout.checkoutUrl;
await db.sql`UPDATE bounties SET payment_id = ${sessionId} WHERE public_id = ${publicId}`;
} catch (e) {
console.error('CoinPay payment create failed:', e);
console.error('CoinPay checkout session create failed:', e);
}

return NextResponse.json({
id: publicId,
public_id: publicId,
payment_id: paymentId,
payment_address: paymentAddress,
pay_url: paymentId ? `${COINPAY_BASE}/pay/${paymentId}` : null,
checkout_session_id: sessionId,
pay_url: payUrl,
}, { status: 201 });
} catch (e) {
console.error('Failed to create bounty:', e);
Expand Down
47 changes: 21 additions & 26 deletions apps/web/app/api/webhooks/coinpay/route.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyCoinPayWebhook } from '@profullstack/stack/coinpay';
import { verifyAndReadCoinPayWebhook } from '@profullstack/stack/coinpay';

const SECRET = process.env.COINPAY_WEBHOOK_SECRET!;

export async function POST(req: NextRequest) {
const raw = await req.text();
const sigHeader = req.headers.get('x-coinpay-signature');

if (!verifyCoinPayWebhook({ signature: sigHeader, rawBody: raw, secret: SECRET })) {
console.error('CoinPay webhook: invalid signature');
const hook = verifyAndReadCoinPayWebhook({
signature: req.headers.get('x-coinpay-signature'),
rawBody: raw,
secret: SECRET,
});
if (!hook) {
console.error('CoinPay webhook: invalid signature or payload');
return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
}

const event = JSON.parse(raw);
console.log('CoinPay webhook:', event.type, event.id);
console.log('CoinPay webhook:', hook.type, hook.paymentId, hook.checkoutSessionId ?? '-');

switch (event.type) {
case 'test.webhook':
break;
case 'payment.completed':
case 'payment.confirmed': {
const meta = event.data?.metadata ?? event.metadata ?? {};
if (meta.type === 'bounty_fund' && meta.bounty_id) {
const db = (await import('@/lib/db')).getDb();
await db.sql`
UPDATE bounties
SET status = 'funded', payment_id = ${event.data?.payment_id ?? event.id},
updated_at = ${new Date().toISOString()}
WHERE public_id = ${meta.bounty_id} AND status = 'open'
`;
console.log('Bounty funded:', meta.bounty_id);
}
break;
if (hook.paid) {
const meta = hook.metadata;
const bountyId = typeof meta.bounty_id === 'string' ? meta.bounty_id : null;
if (meta.type === 'bounty_fund' && bountyId) {
const db = (await import('@/lib/db')).getDb();
await db.sql`
UPDATE bounties
SET status = 'funded', payment_id = ${hook.paymentId ?? hook.checkoutSessionId},
updated_at = ${new Date().toISOString()}
WHERE public_id = ${bountyId} AND status = 'open'
`;
console.log('Bounty funded:', bountyId, hook.rail);
}
case 'payment.failed':
break;
}

return NextResponse.json({ received: true });
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/bounties/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export default async function BountyPage({ params }: { params: Promise<{ id: str
{/* Actions */}
{bounty.status === 'open' && isCreator && bounty.payment_id && (
<a
href={`${COINPAY_BASE}/pay/${bounty.payment_id}`}
href={`${COINPAY_BASE}/${bounty.payment_id.startsWith('cs_') ? 'checkout' : 'pay'}/${bounty.payment_id}`}
target="_blank"
rel="noopener noreferrer"
className="w-full text-center bg-orange-500 hover:bg-orange-600 text-white font-semibold px-6 py-3 rounded-xl transition-colors"
Expand Down
18 changes: 16 additions & 2 deletions apps/web/app/bounties/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ interface Store { id: number; name: string; slug: string; }
export default function NewBountyPage() {
const router = useRouter();
const [did, setDid] = useState<string | null>(null);
const [emailOnFile, setEmailOnFile] = useState<string | null>(null);
const [stores, setStores] = useState<Store[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
Expand All @@ -20,10 +21,11 @@ export default function NewBountyPage() {
reward_usd: '0.10',
store_id: '',
store_name: '',
email: '',
});

useEffect(() => {
fetch('/api/auth/me').then(r => r.json()).then(d => setDid(d.did ?? null)).catch(() => {});
fetch('/api/auth/me').then(r => r.json()).then(d => { setDid(d.did ?? null); setEmailOnFile(d.email ?? null); }).catch(() => {});
fetch('/api/stores').then(r => r.json()).then(d => setStores(Array.isArray(d) ? d : [])).catch(() => {});
}, []);

Expand All @@ -36,6 +38,7 @@ export default function NewBountyPage() {
if (isNaN(reward) || reward < 0.10) { setError('Minimum reward is $0.10'); return; }
if (!form.store_id && !form.store_name.trim()) { setError('Please select or name a store'); return; }
if (!form.title.trim()) { setError('Title is required'); return; }
if (!emailOnFile && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) { setError('Enter your email for the payment receipt'); return; }
setLoading(true);
try {
const res = await fetch('/api/bounties', {
Expand All @@ -48,6 +51,7 @@ export default function NewBountyPage() {
reward_usd: reward,
store_id: form.store_id ? parseInt(form.store_id) : null,
store_name: !form.store_id ? form.store_name.trim() : null,
email: emailOnFile ? undefined : form.email.trim(),
}),
});
const data = await res.json();
Expand Down Expand Up @@ -161,8 +165,18 @@ export default function NewBountyPage() {
required
/>
</div>
{!emailOnFile && (
<input
type="email"
placeholder="Your email (for the payment receipt) *"
value={form.email}
onChange={e => set('email', e.target.value)}
className="mt-3 w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-orange-400"
required
/>
)}
<p className="text-xs text-gray-400 mt-1">
You will be redirected to CoinPay to fund this bounty. The reward is held until a valid coupon is submitted.
You will be redirected to CoinPay to fund this bounty with any supported coin or PayPal. The reward is held until a valid coupon is submitted.
</p>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"@profullstack/bridges": "^0.1.0",
"@profullstack/footer": "latest",
"@profullstack/libsql-pg": "^0.1.4",
"@profullstack/stack": "^0.1.3",
"@profullstack/stack": "0.5.0",
"@profullstack/x402-gateway": "^0.11.0",
"@serwist/next": "^9.5.0",
"next": "16.3.8",
Expand Down
6 changes: 4 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading