From 9764baaff467f70fc51c12ff428d749825831606 Mon Sep 17 00:00:00 2001 From: AugistineCreates Date: Wed, 29 Apr 2026 12:42:22 +0100 Subject: [PATCH] feat: add reward tiers and backer perks for campaigns --- .../db/migrations/20260429_reward_tiers.sql | 25 +++ backend/src/middleware/validation.js | 28 ++++ backend/src/routes/campaigns.js | 116 +++++++++++++- backend/src/routes/contributions.js | 10 +- backend/src/services/ledgerMonitor.js | 29 ++++ frontend/src/components/ContributeModal.jsx | 97 +++++++++++- frontend/src/pages/Campaign.jsx | 39 +++++ frontend/src/pages/CreateCampaign.jsx | 146 +++++++++++++++++- frontend/src/services/api.js | 2 + 9 files changed, 483 insertions(+), 9 deletions(-) create mode 100644 backend/db/migrations/20260429_reward_tiers.sql diff --git a/backend/db/migrations/20260429_reward_tiers.sql b/backend/db/migrations/20260429_reward_tiers.sql new file mode 100644 index 0000000..4b08c61 --- /dev/null +++ b/backend/db/migrations/20260429_reward_tiers.sql @@ -0,0 +1,25 @@ +-- Migration: Reward Tiers and Backer Perks +-- Created at: 2026-04-29 + +CREATE TABLE reward_tiers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + min_amount NUMERIC(20, 7) NOT NULL, + asset_type TEXT NOT NULL, -- Must match campaign asset + "limit" INT, -- NULL means unlimited + claimed_count INT NOT NULL DEFAULT 0, + estimated_delivery DATE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE contribution_rewards ( + contribution_id UUID PRIMARY KEY REFERENCES contributions(id) ON DELETE CASCADE, + reward_tier_id UUID NOT NULL REFERENCES reward_tiers(id) ON DELETE CASCADE, + claimed_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX reward_tiers_campaign_idx ON reward_tiers (campaign_id); +CREATE INDEX reward_tiers_min_amount_idx ON reward_tiers (min_amount); +CREATE INDEX contribution_rewards_tier_idx ON contribution_rewards (reward_tier_id); diff --git a/backend/src/middleware/validation.js b/backend/src/middleware/validation.js index f691026..60db7cd 100644 --- a/backend/src/middleware/validation.js +++ b/backend/src/middleware/validation.js @@ -117,6 +117,34 @@ const createCampaignValidation = [ .optional() .isBoolean() .withMessage('show_backer_amounts must be a boolean'), + body('reward_tiers') + .optional({ nullable: true }) + .custom((value) => { + if (value == null) return true; + if (!Array.isArray(value)) throw new Error('reward_tiers must be an array'); + if (value.length > 10) throw new Error('Campaigns can define at most 10 reward tiers'); + for (const [index, tier] of value.entries()) { + if (!tier || typeof tier !== 'object') { + throw new Error(`Tier ${index + 1} must be an object`); + } + if (!String(tier.title || '').trim()) { + throw new Error(`Tier ${index + 1} title is required`); + } + const minAmount = Number(tier.min_amount); + if (!Number.isFinite(minAmount) || minAmount <= 0) { + throw new Error(`Tier ${index + 1} min_amount must be greater than zero`); + } + if (tier.limit !== undefined && tier.limit !== null) { + const limit = Number(tier.limit); + if (!Number.isInteger(limit) || limit < 1) { + throw new Error(`Tier ${index + 1} limit must be a positive integer`); + } + } + } + return true; + }), + body('reward_tiers.*.title').optional().customSanitizer(stripHtml), + body('reward_tiers.*.description').optional().customSanitizer(stripHtml), ]; const createCampaignUpdateValidation = [ diff --git a/backend/src/routes/campaigns.js b/backend/src/routes/campaigns.js index 6e5913f..9de35c8 100644 --- a/backend/src/routes/campaigns.js +++ b/backend/src/routes/campaigns.js @@ -121,6 +121,46 @@ function normalizeMilestonesInput(input) { return normalized; } +function normalizeRewardTiersInput(input, assetType) { + if (input == null) return []; + if (!Array.isArray(input)) { + throw new Error('reward_tiers must be an array'); + } + if (input.length === 0) return []; + if (input.length > 10) { + throw new Error('Campaigns can define at most 10 reward tiers'); + } + + return input.map((tier, index) => { + const title = String(tier?.title || '').trim(); + if (!title) { + throw new Error(`Tier ${index + 1} title is required`); + } + + const minAmount = Number(tier?.min_amount); + if (!Number.isFinite(minAmount) || minAmount <= 0) { + throw new Error(`Tier ${index + 1} min_amount must be greater than zero`); + } + + let limit = null; + if (tier.limit !== undefined && tier.limit !== null) { + limit = parseInt(tier.limit, 10); + if (isNaN(limit) || limit < 1) { + throw new Error(`Tier ${index + 1} limit must be a positive integer`); + } + } + + return { + title, + description: String(tier?.description || '').trim() || null, + min_amount: minAmount.toFixed(7), + asset_type: assetType, + limit, + estimated_delivery: tier.estimated_delivery || null, + }; + }); +} + async function logWithdrawalEvent(client, { withdrawalRequestId, actorUserId, action, note, metadata }) { const runner = client || db; await runner.query( @@ -326,6 +366,56 @@ router.get('/:id/stream', async (req, res) => { }); }); +// GET /campaigns/:id/tiers — list tiers with availability +router.get('/:id/tiers', async (req, res) => { + const { rows } = await db.query( + `SELECT id, campaign_id, title, description, min_amount, asset_type, "limit", claimed_count, estimated_delivery, created_at + FROM reward_tiers + WHERE campaign_id = $1 + ORDER BY min_amount ASC`, + [req.params.id] + ); + res.json(rows); +}); + +// POST /campaigns/:id/tiers — creator adds a tier post-creation +router.post('/:id/tiers', requireAuth, requireCampaignMember('owner'), async (req, res) => { + const { rows: campaignRows } = await db.query('SELECT asset_type FROM campaigns WHERE id = $1', [req.params.id]); + if (!campaignRows.length) return res.status(404).json({ error: 'Campaign not found' }); + + let normalized; + try { + // Normalize a single tier (passed as body) or an array + const input = Array.isArray(req.body) ? req.body : [req.body]; + normalized = normalizeRewardTiersInput(input, campaignRows[0].asset_type); + } catch (err) { + return res.status(400).json({ error: err.message }); + } + + const client = await db.connect(); + try { + await client.query('BEGIN'); + const created = []; + for (const tier of normalized) { + const { rows } = await client.query( + `INSERT INTO reward_tiers + (campaign_id, title, description, min_amount, asset_type, "limit", estimated_delivery) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [req.params.id, tier.title, tier.description, tier.min_amount, tier.asset_type, tier.limit, tier.estimated_delivery] + ); + created.push(rows[0]); + } + await client.query('COMMIT'); + res.status(201).json(Array.isArray(req.body) ? created : created[0]); + } catch (err) { + await client.query('ROLLBACK'); + res.status(500).json({ error: 'Could not add reward tier' }); + } finally { + client.release(); + } +}); + // Get live on-chain balance for a campaign router.get('/:id/balance', async (req, res) => { const { rows } = await db.query( @@ -456,7 +546,7 @@ router.post('/:id/trigger-refunds', requireAuth, requireRole('admin'), async (re // Create campaign (authenticated) router.post('/', requireAuth, requireRole('creator', 'admin'), async (req, res) => { - const { title, description, target_amount, asset_type, deadline, milestones, min_contribution, max_contribution } = req.body; + const { title, description, target_amount, asset_type, deadline, milestones, reward_tiers, min_contribution, max_contribution } = req.body; if (!title || !target_amount || !asset_type) { return res.status(400).json({ error: 'title, target_amount and asset_type are required' }); } @@ -473,6 +563,13 @@ router.post('/', requireAuth, requireRole('creator', 'admin'), async (req, res) return res.status(400).json({ error: err.message }); } + let normalizedRewards; + try { + normalizedRewards = normalizeRewardTiersInput(reward_tiers, asset_type); + } catch (err) { + return res.status(400).json({ error: err.message }); + } + // Get creator's info const { rows: userRows } = await db.query( 'SELECT email, wallet_public_key, kyc_status FROM users WHERE id = $1', @@ -535,6 +632,23 @@ router.post('/', requireAuth, requireRole('creator', 'admin'), async (req, res) ); } + for (const tier of normalizedRewards) { + await client.query( + `INSERT INTO reward_tiers + (campaign_id, title, description, min_amount, asset_type, "limit", estimated_delivery) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + campaign.id, + tier.title, + tier.description, + tier.min_amount, + tier.asset_type, + tier.limit, + tier.estimated_delivery, + ] + ); + } + // Soroban Initialization: // In a real scenario, we would call the contracts here. // milestones.initialize(creator, platform, escrow, milestones_vec) diff --git a/backend/src/routes/contributions.js b/backend/src/routes/contributions.js index 5dabfaa..45c7b9f 100644 --- a/backend/src/routes/contributions.js +++ b/backend/src/routes/contributions.js @@ -123,10 +123,13 @@ router.get('/finalization/:txHash', requireAuth, async (req, res) => { st.initiated_by_user_id, st.metadata, st.created_at, st.updated_at, c.creator_id, ct.id AS contribution_row_id, ct.sender_public_key, ct.amount, - ct.asset, ct.created_at AS contribution_created_at + ct.asset, ct.created_at AS contribution_created_at, + rt.id AS reward_id, rt.title AS reward_title, rt.description AS reward_description FROM stellar_transactions st JOIN campaigns c ON c.id = st.campaign_id LEFT JOIN contributions ct ON ct.id = st.contribution_id + LEFT JOIN contribution_rewards cr ON cr.contribution_id = ct.id + LEFT JOIN reward_tiers rt ON rt.id = cr.reward_tier_id WHERE st.tx_hash = $1 AND st.kind = 'contribution'`, [txHash] ); @@ -163,6 +166,11 @@ router.get('/finalization/:txHash', requireAuth, async (req, res) => { amount: row.amount, asset: row.asset, created_at: row.contribution_created_at, + reward: row.reward_id ? { + id: row.reward_id, + title: row.reward_title, + description: row.reward_description, + } : null, } : null, metadata: row.metadata, diff --git a/backend/src/services/ledgerMonitor.js b/backend/src/services/ledgerMonitor.js index 752105b..e65252a 100644 --- a/backend/src/services/ledgerMonitor.js +++ b/backend/src/services/ledgerMonitor.js @@ -235,6 +235,35 @@ async function handlePayment(campaignId, walletPublicKey, payment) { await markContributionIndexed(client, txHash, inserted[0].id); + // Reward Tier Assignment Logic + const { rows: tiers } = await client.query( + `SELECT id, title, "limit", claimed_count + FROM reward_tiers + WHERE campaign_id = $1 AND min_amount <= $2 AND asset_type = $3 + ORDER BY min_amount DESC`, + [campaignId, destinationAmount, destinationAsset] + ); + + let assignedTier = null; + for (const tier of tiers) { + if (tier.limit === null || tier.claimed_count < tier.limit) { + assignedTier = tier; + break; + } + } + + if (assignedTier) { + await client.query( + `UPDATE reward_tiers SET claimed_count = claimed_count + 1 WHERE id = $1`, + [assignedTier.id] + ); + await client.query( + `INSERT INTO contribution_rewards (contribution_id, reward_tier_id) VALUES ($1, $2)`, + [inserted[0].id, assignedTier.id] + ); + console.log(`[monitor] Reward tier "${assignedTier.title}" assigned to contribution ${inserted[0].id}`); + } + if (anchorMetadata?.anchor_deposit_id) { await client.query( `UPDATE anchor_deposits diff --git a/frontend/src/components/ContributeModal.jsx b/frontend/src/components/ContributeModal.jsx index 346616b..69d356f 100644 --- a/frontend/src/components/ContributeModal.jsx +++ b/frontend/src/components/ContributeModal.jsx @@ -63,6 +63,10 @@ export default function ContributeModal({ campaign, onClose, onSuccess }) { const [freighterChecked, setFreighterChecked] = useState(false); const [existingContributions, setExistingContributions] = useState([]); const [displayName, setDisplayName] = useState(''); + const [tiers, setTiers] = useState([]); + const [unlockedTier, setUnlockedTier] = useState(null); + const [finalizing, setFinalizing] = useState(false); + const [finalizedData, setFinalizedData] = useState(null); const anchorPopupRef = useRef(null); useEffect(() => { @@ -70,6 +74,9 @@ export default function ContributeModal({ campaign, onClose, onSuccess }) { api.getContributions(campaign.id) .then(setExistingContributions) .catch(() => setExistingContributions([])); + api.getCampaignTiers(campaign.id) + .then(setTiers) + .catch(() => setTiers([])); } }, [campaign?.id]); @@ -341,8 +348,13 @@ export default function ContributeModal({ campaign, onClose, onSuccess }) { : await submitWithCustodial(); if (paymentMethod === 'anchor') return; setResult(data); - setPhase('success'); - onSuccess(); + if (data.tx_hash) { + setPhase('finalizing'); + startFinalizationPolling(data.tx_hash); + } else { + setPhase('success'); + onSuccess(); + } } catch (err) { if (paymentMethod === 'anchor' && anchorPopupRef.current && !anchorPopupRef.current.closed) { anchorPopupRef.current.close(); @@ -358,6 +370,49 @@ export default function ContributeModal({ campaign, onClose, onSuccess }) { } } + async function startFinalizationPolling(txHash) { + setFinalizing(true); + let attempts = 0; + const maxAttempts = 15; + const interval = 2000; + + const poll = async () => { + try { + const data = await api.getContributionFinalization(txHash, token); + if (data.finalization_status === 'finalized') { + setFinalizedData(data); + setFinalizing(false); + setPhase('success'); + onSuccess(); + return; + } + if (data.finalization_status === 'failed') { + setFinalizing(false); + setPhase('success'); // Still show success for submission, but maybe a note + onSuccess(); + return; + } + } catch (err) { + console.error('Finalization poll error', err); + } + + attempts += 1; + if (attempts < maxAttempts) { + setTimeout(poll, interval); + } else { + setFinalizing(false); + setPhase('success'); + onSuccess(); + } + }; + + setTimeout(poll, interval); + } + + const qualifyingTier = [...tiers] + .sort((a, b) => Number(b.min_amount) - Number(a.min_amount)) + .find((t) => Number(amount) >= Number(t.min_amount) && (t.limit === null || t.claimed_count < t.limit)); + function handleClose() { if (anchorPopupRef.current && !anchorPopupRef.current.closed) { anchorPopupRef.current.close(); @@ -600,6 +655,18 @@ export default function ContributeModal({ campaign, onClose, onSuccess }) { )} + {qualifyingTier && ( +
+
+
🎁
+
+
Unlocked Reward
+
{qualifyingTier.title}
+
+
+
+ )} + {error && (

{error} @@ -660,14 +727,36 @@ export default function ContributeModal({ campaign, onClose, onSuccess }) { Close + ) : phase === 'finalizing' ? ( +

+
+

Finalizing contribution…

+

+ Waiting for the Stellar ledger to index your payment. This usually takes 3–5 seconds. +

+

+ Transaction Hash: {result?.tx_hash?.slice(0, 16)}… +

+
) : (

- Payment submitted + Support Confirmed!

- Your contribution is on its way. It usually confirms in a few seconds on Stellar. + Your contribution of {amount} {campaign.asset_type} has been successfully processed.

+ + {finalizedData?.contribution?.reward && ( +
+
🎉
+
Reward Unlocked
+
{finalizedData.contribution.reward.title}
+

+ {finalizedData.contribution.reward.description} +

+
+ )} {result?.tx_hash && (

Transaction{' '} diff --git a/frontend/src/pages/Campaign.jsx b/frontend/src/pages/Campaign.jsx index 7323670..cbfa280 100644 --- a/frontend/src/pages/Campaign.jsx +++ b/frontend/src/pages/Campaign.jsx @@ -58,6 +58,7 @@ export default function Campaign() { const [inviteSuccess, setInviteSuccess] = useState(false); const [showEmbedSection, setShowEmbedSection] = useState(false); const [embedCopied, setEmbedCopied] = useState(false); + const [tiers, setTiers] = useState([]); useEffect(() => { setLoadError(''); @@ -76,6 +77,7 @@ export default function Campaign() { api.getCampaignBackers(id).then(setContributions).catch(() => setContributions([])); api.getMilestones(id).then(setMilestones).catch(() => setMilestones([])); api.getCampaignUpdates(id, { limit: 20 }).then(setUpdates).catch(() => setUpdates([])); + api.getCampaignTiers(id).then(setTiers).catch(() => setTiers([])); }, [id, token, contributed]); useEffect(() => { @@ -308,6 +310,43 @@ export default function Campaign() {

)} + {tiers.length > 0 && ( +
+

Reward Tiers

+
+ {tiers.map((tier) => ( +
+
+
{tier.title}
+
+ {Number(tier.min_amount).toLocaleString()} {tier.asset_type} +
+
+ {tier.description && ( +

+ {tier.description} +

+ )} +
+ + {tier.limit ? ( + = tier.limit ? '#dc2626' : '#666' }}> + {tier.limit - tier.claimed_count} of {tier.limit} remaining + + ) : ( + 'Unlimited' + )} + + {tier.estimated_delivery && ( + Est. delivery: {new Date(tier.estimated_delivery).toLocaleDateString()} + )} +
+
+ ))} +
+
+ )} + {campaign.status === 'active' ? ( user ? ( + +
+ + setRewardTierField(index, 'title', e.target.value)} + placeholder="e.g. Early Bird Special" + required + /> +
+
+ +