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 b002298..7144e43 100644 --- a/backend/src/middleware/validation.js +++ b/backend/src/middleware/validation.js @@ -162,6 +162,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 6d7b10e..b305a16 100644 --- a/backend/src/routes/campaigns.js +++ b/backend/src/routes/campaigns.js @@ -141,6 +141,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( @@ -503,6 +543,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) => { /** @@ -650,40 +740,60 @@ router.post('/:id/trigger-refunds', requireAuth, requireRole('admin'), async (re }); // Create campaign (authenticated) -router.post('/', requireAuth, requireRole('creator', 'admin'), createCampaignValidation, validateRequest, async (req, res) => { - /** - * @openapi - * /api/campaigns: - * post: - * tags: [Campaigns] - * summary: Create campaign - * security: - * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [title, target_amount, asset_type] - * properties: - * title: { type: string } - * description: { type: string, nullable: true } - * target_amount: { type: string } - * asset_type: { type: string } - * deadline: { type: string, nullable: true } - * milestones: { type: array, items: { type: object }, nullable: true } - * min_contribution: { type: string, nullable: true } - * max_contribution: { type: string, nullable: true } - * responses: - * 201: - * description: Created - * 401: - * description: Unauthorized - * 403: - * description: Forbidden - */ - const { title, description, target_amount, asset_type, deadline, milestones, min_contribution, max_contribution } = req.body; +router.post( + '/', + requireAuth, + requireRole('creator', 'admin'), + createCampaignValidation, + validateRequest, + async (req, res) => { + /** + * @openapi + * /api/campaigns: + * post: + * tags: [Campaigns] + * summary: Create campaign + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [title, target_amount, asset_type] + * properties: + * title: { type: string } + * description: { type: string, nullable: true } + * target_amount: { type: string } + * asset_type: { type: string } + * deadline: { type: string, nullable: true } + * milestones: { type: array, items: { type: object }, nullable: true } + * reward_tiers: { type: array, items: { type: object }, nullable: true } + * min_contribution: { type: string, nullable: true } + * max_contribution: { type: string, nullable: true } + * responses: + * 201: + * description: Created + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + */ + + const { + title, + description, + target_amount, + asset_type, + deadline, + milestones, + reward_tiers, + min_contribution, + max_contribution, + } = req.body + + // ...rest of handler logic here let normalizedMilestones; try { @@ -692,6 +802,13 @@ router.post('/', requireAuth, requireRole('creator', 'admin'), createCampaignVal 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', @@ -754,6 +871,23 @@ router.post('/', requireAuth, requireRole('creator', 'admin'), createCampaignVal ); } + 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 8de0708..c1eab33 100644 --- a/backend/src/routes/contributions.js +++ b/backend/src/routes/contributions.js @@ -157,10 +157,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] ); @@ -197,6 +200,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 4faa856..f643cfd 100644 --- a/backend/src/services/ledgerMonitor.js +++ b/backend/src/services/ledgerMonitor.js @@ -248,6 +248,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 b54664f..89340b8 100644 --- a/frontend/src/components/ContributeModal.jsx +++ b/frontend/src/components/ContributeModal.jsx @@ -68,6 +68,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(() => { @@ -75,6 +79,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]); @@ -349,8 +356,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(); @@ -366,6 +378,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(); @@ -608,18 +663,68 @@ export default function ContributeModal({ campaign, onClose, onSuccess }) { )} - {feeBps > 0 && destAmount && Number(destAmount) > 0 && ( -
- {(() => { - const feeAmt = (Number(destAmount) * feeBps / 10000).toFixed(7); - const netAmt = (Number(destAmount) - Number(feeAmt)).toFixed(7); - return ( - <> - Platform fee: {feeBps / 100}% = {feeAmt} {campaign.asset_type} - {' '}— campaign receives {netAmt} {campaign.asset_type} - - ); - })()} +{qualifyingTier && ( +
+
+
🎁
+
+
+ Unlocked Reward +
+
+ {qualifyingTier.title} +
+
+
+
+)} + +{feeBps > 0 && destAmount && Number(destAmount) > 0 && ( +
+ {(() => { + const feeAmt = (Number(destAmount) * feeBps / 10000).toFixed(7); + const netAmt = (Number(destAmount) - Number(feeAmt)).toFixed(7); + + return ( + <> + Platform fee: {feeBps / 100}% = {feeAmt}{" "} + {campaign.asset_type} — campaign receives{" "} + + {netAmt} {campaign.asset_type} + + + ); + })()} +
+)}
)} @@ -683,14 +788,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 27e6f7a..0a46c0e 100644 --- a/frontend/src/pages/Campaign.jsx +++ b/frontend/src/pages/Campaign.jsx @@ -66,17 +66,18 @@ export default function Campaign() { const [inviteSuccess, setInviteSuccess] = useState(false); const [showEmbedSection, setShowEmbedSection] = useState(false); const [embedCopied, setEmbedCopied] = useState(false); - const [linkCopied, setLinkCopied] = useState(false); - const [isEditingCampaign, setIsEditingCampaign] = useState(false); - const [editFormData, setEditFormData] = useState({ - title: "", - description: "", - deadline: "" - }); - const [editError, setEditError] = useState(""); - const [editSuccess, setEditSuccess] = useState(""); - const [editLoading, setEditLoading] = useState(false); +const [tiers, setTiers] = useState([]) +const [linkCopied, setLinkCopied] = useState(false) +const [isEditingCampaign, setIsEditingCampaign] = useState(false) +const [editFormData, setEditFormData] = useState({ + title: "", + description: "", + deadline: "", +}) +const [editError, setEditError] = useState("") +const [editSuccess, setEditSuccess] = useState("") +const [editLoading, setEditLoading] = useState(false) useEffect(() => { setLoadError(""); api @@ -93,19 +94,27 @@ export default function Campaign() { setIsOwner(false); } }) - .catch((err) => setLoadError(err.message || "Could not load campaign.")); - api - .getContributions(id) - .then(setContributions) - .catch(() => setContributions([])); - api - .getMilestones(id) - .then(setMilestones) - .catch(() => setMilestones([])); - api - .getCampaignUpdates(id, { limit: 20 }) - .then(setUpdates) - .catch(() => setUpdates([])); +.catch((err) => setLoadError(err.message || "Could not load campaign.")) + +api + .getContributions(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(() => { @@ -559,7 +568,116 @@ export default function Campaign() {

)} - {campaign.status === "active" ? ( +{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 + /> +
+
+ +