diff --git a/apps/web/src/components/modules/campaign/wizard/campaign-wizard-config.ts b/apps/web/src/components/modules/campaign/wizard/campaign-wizard-config.ts index 3ee75d2..bc8ace3 100644 --- a/apps/web/src/components/modules/campaign/wizard/campaign-wizard-config.ts +++ b/apps/web/src/components/modules/campaign/wizard/campaign-wizard-config.ts @@ -49,6 +49,17 @@ export interface MilestoneItem { description: string; } +/** Time-limited stretch goal configured in the campaign wizard. */ +export interface StretchGoalItem { + id: string; + title: string; + description: string; + /** Window length in hours (e.g. 24 for a 24-hour bonus). */ + windowHours: string; + rewardTitle: string; + rewardDescription: string; +} + export interface CampaignWizardData { // Step 1: Details title: string; @@ -105,6 +116,7 @@ export const INITIAL_WIZARD_DATA: CampaignWizardData = { description: "Setup operations and announce project kick-off.", }, ], + stretchGoals: [], impactStatement: "", targetBeneficiaries: "", diff --git a/apps/web/src/hooks/use-campaign-wizard.ts b/apps/web/src/hooks/use-campaign-wizard.ts index 9b2f3f9..f77954d 100644 --- a/apps/web/src/hooks/use-campaign-wizard.ts +++ b/apps/web/src/hooks/use-campaign-wizard.ts @@ -9,6 +9,7 @@ import { validateStep, WizardStepErrors, MilestoneItem, + StretchGoalItem, } from "@/components/modules/campaign/wizard/campaign-wizard-config"; export interface UseCampaignWizardReturn { @@ -23,6 +24,8 @@ export interface UseCampaignWizardReturn { updateField: (field: K, value: CampaignWizardData[K]) => void; addMilestone: (milestone: Omit) => void; removeMilestone: (id: string) => void; + addStretchGoal: (goal: Omit) => void; + removeStretchGoal: (id: string) => void; goNext: () => boolean; goBack: () => void; goToStep: (index: number) => void; @@ -77,6 +80,26 @@ export function useCampaignWizard(initialData: Partial = {}) setIsDirty(true); }, []); + const addStretchGoal = useCallback((goal: Omit) => { + const newItem: StretchGoalItem = { + ...goal, + id: `sg-${Date.now()}`, + }; + setData((prev) => ({ + ...prev, + stretchGoals: [...(prev.stretchGoals ?? []), newItem], + })); + setIsDirty(true); + }, []); + + const removeStretchGoal = useCallback((id: string) => { + setData((prev) => ({ + ...prev, + stretchGoals: (prev.stretchGoals ?? []).filter((g) => g.id !== id), + })); + setIsDirty(true); + }, []); + const validateCurrentStep = useCallback(() => { const stepErrors = validateStep(currentStep.id, data); setErrors(stepErrors); diff --git a/apps/web/src/services/campaign-stretch-goals.service.test.ts b/apps/web/src/services/campaign-stretch-goals.service.test.ts new file mode 100644 index 0000000..7e00f60 --- /dev/null +++ b/apps/web/src/services/campaign-stretch-goals.service.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from "vitest"; +import { + InMemoryCampaignDataSource, + createCampaign, +} from "./campaign.service"; +import { + DEFAULT_STRETCH_WINDOW_MS, + addCampaignStretchGoal, + applyContributionAndUnlockStretchGoals, + evaluateStretchGoalStatus, + getUnlockedRewardsForBackers, + isMainGoalReached, + isWithinStretchWindow, + syncCampaignStretchGoals, +} from "./campaign-stretch-goals.service"; + +describe("campaign stretch goals", () => { + const HOUR = 60 * 60 * 1000; + + it("defaults the stretch window to 24 hours", () => { + expect(DEFAULT_STRETCH_WINDOW_MS).toBe(24 * HOUR); + }); + + it("detects when the main goal is reached", async () => { + const source = new InMemoryCampaignDataSource(); + const campaign = await createCampaign( + { creator: "GCREATOR", name: "Trees", goalAmount: "1000" }, + source, + 1, + ); + expect(isMainGoalReached(campaign)).toBe(false); + expect(isMainGoalReached({ ...campaign, raisedAmount: "1000" })).toBe(true); + expect(isMainGoalReached({ ...campaign, raisedAmount: "999" })).toBe(false); + }); + + it("treats the window as [start, end)", () => { + const goal = { windowStartsAt: 100, windowEndsAt: 200 }; + expect(isWithinStretchWindow(goal, 99)).toBe(false); + expect(isWithinStretchWindow(goal, 100)).toBe(true); + expect(isWithinStretchWindow(goal, 199)).toBe(true); + expect(isWithinStretchWindow(goal, 200)).toBe(false); + }); + + it("lets the creator attach a 24-hour bonus stretch goal", async () => { + const source = new InMemoryCampaignDataSource(); + const t0 = 1_700_000_000_000; + const campaign = await createCampaign( + { creator: "GCREATOR", name: "Trees", goalAmount: "1000" }, + source, + t0, + ); + + const stretch = await addCampaignStretchGoal( + campaign.id, + "GCREATOR", + { + title: "24-hour flash bonus", + description: "Hit the main goal in 24h for a limited print", + durationMs: DEFAULT_STRETCH_WINDOW_MS, + rewards: [{ title: "Limited edition print", description: "Signed by the team" }], + }, + source, + t0, + ); + + expect(stretch.status).toBe("pending"); + expect(stretch.windowEndsAt - stretch.windowStartsAt).toBe(DEFAULT_STRETCH_WINDOW_MS); + expect(stretch.rewards).toHaveLength(1); + }); + + it("rejects stretch goals from anyone but the creator", async () => { + const source = new InMemoryCampaignDataSource(); + const campaign = await createCampaign( + { creator: "GCREATOR", name: "Trees", goalAmount: "1000" }, + source, + 1, + ); + await expect( + addCampaignStretchGoal( + campaign.id, + "GSTRANGER", + { title: "Nope", rewards: [{ title: "x" }] }, + source, + 2, + ), + ).rejects.toThrow(/creator/i); + }); + + it("unlocks special rewards when the main goal is hit inside the window", async () => { + const source = new InMemoryCampaignDataSource(); + const t0 = 1_000; + const campaign = await createCampaign( + { creator: "GCREATOR", name: "Trees", goalAmount: "1000" }, + source, + t0, + ); + await addCampaignStretchGoal( + campaign.id, + "GCREATOR", + { + title: "Flash bonus", + durationMs: 24 * HOUR, + rewards: [{ title: "Bonus NFT" }], + }, + source, + t0, + ); + + const { unlocked, campaign: funded } = await applyContributionAndUnlockStretchGoals( + campaign.id, + "1000", + source, + t0 + HOUR, + ); + + expect(unlocked).toHaveLength(1); + expect(unlocked[0].status).toBe("unlocked"); + expect(getUnlockedRewardsForBackers(funded, t0 + HOUR).map((r) => r.title)).toEqual([ + "Bonus NFT", + ]); + }); + + it("does not unlock if the main goal is reached after the window", async () => { + const source = new InMemoryCampaignDataSource(); + const t0 = 1_000; + const campaign = await createCampaign( + { creator: "GCREATOR", name: "Trees", goalAmount: "1000" }, + source, + t0, + ); + await addCampaignStretchGoal( + campaign.id, + "GCREATOR", + { + title: "Flash bonus", + durationMs: 24 * HOUR, + rewards: [{ title: "Bonus NFT" }], + }, + source, + t0, + ); + + const afterWindow = t0 + 24 * HOUR + 1; + const { unlocked, campaign: funded } = await applyContributionAndUnlockStretchGoals( + campaign.id, + "1000", + source, + afterWindow, + ); + + expect(unlocked).toHaveLength(0); + expect(funded.stretchGoals?.[0].status).toBe("expired"); + expect(getUnlockedRewardsForBackers(funded, afterWindow)).toEqual([]); + }); + + it("expires pending goals once the window closes without the main goal", async () => { + const source = new InMemoryCampaignDataSource(); + const t0 = 5_000; + const campaign = await createCampaign( + { creator: "GCREATOR", name: "Trees", goalAmount: "1000" }, + source, + t0, + ); + await addCampaignStretchGoal( + campaign.id, + "GCREATOR", + { title: "Flash", durationMs: HOUR, rewards: [{ title: "Sticker" }] }, + source, + t0, + ); + + const synced = await syncCampaignStretchGoals(campaign.id, source, t0 + HOUR + 1); + expect(synced[0].status).toBe("expired"); + }); + + it("keeps an already-unlocked goal unlocked after the window ends", () => { + const campaign = { + id: "c1", + creator: "G", + name: "n", + status: "ACTIVE" as const, + goalAmount: "100", + raisedAmount: "100", + sponsorCount: 1, + treeCount: 0, + createdAt: 1, + updatedAt: 1, + statusChangedAt: 1, + sponsors: [], + statusHistory: [], + }; + const unlocked = evaluateStretchGoalStatus( + { + id: "s1", + campaignId: "c1", + title: "Flash", + windowStartsAt: 0, + windowEndsAt: 10, + rewards: [{ id: "r1", title: "NFT" }], + status: "unlocked", + unlockedAt: 5, + createdBy: "G", + createdAt: 0, + }, + campaign, + 99, + ); + expect(unlocked.status).toBe("unlocked"); + }); + + it("requires a title and at least one reward", async () => { + const source = new InMemoryCampaignDataSource(); + const campaign = await createCampaign( + { creator: "GCREATOR", name: "Trees", goalAmount: "1000" }, + source, + 1, + ); + await expect( + addCampaignStretchGoal(campaign.id, "GCREATOR", { title: " ", rewards: [{ title: "x" }] }, source, 2), + ).rejects.toThrow(/title/i); + await expect( + addCampaignStretchGoal(campaign.id, "GCREATOR", { title: "Flash", rewards: [] }, source, 2), + ).rejects.toThrow(/reward/i); + }); +}); diff --git a/apps/web/src/services/campaign-stretch-goals.service.ts b/apps/web/src/services/campaign-stretch-goals.service.ts new file mode 100644 index 0000000..2881ba8 --- /dev/null +++ b/apps/web/src/services/campaign-stretch-goals.service.ts @@ -0,0 +1,215 @@ +/** + * Time-limited stretch goals (e.g. 24-hour bonuses). + * + * Creators can attach stretch goals that unlock special backer rewards + * when the campaign's main funding goal is reached *within* a configured + * window (windowStartsAt … windowEndsAt). Stretch goals that miss the + * window expire without unlocking rewards. + */ + +import { + getCampaign, + getCampaignDataSource, + type CampaignDataSource, + type CampaignRecord, +} from "./campaign.service"; + +export type StretchGoalStatus = "pending" | "unlocked" | "expired"; + +export interface StretchGoalReward { + id: string; + title: string; + description?: string; +} + +export interface StretchGoal { + id: string; + campaignId: string; + title: string; + description?: string; + /** Extra funding target beyond the main goal (integer string). Optional. */ + targetAmount?: string; + /** Inclusive start of the time window (unix ms). */ + windowStartsAt: number; + /** Exclusive end of the time window (unix ms). */ + windowEndsAt: number; + rewards: StretchGoalReward[]; + status: StretchGoalStatus; + unlockedAt?: number; + expiredAt?: number; + createdBy: string; + createdAt: number; +} + +export interface StretchGoalInput { + title: string; + description?: string; + targetAmount?: string; + /** Window length in milliseconds. Defaults to 24 hours. */ + durationMs?: number; + windowStartsAt?: number; + windowEndsAt?: number; + rewards: Array<{ title: string; description?: string }>; +} + +export const DEFAULT_STRETCH_WINDOW_MS = 24 * 60 * 60 * 1000; + +function parseAmount(value: string | undefined): bigint { + if (!value) return 0n; + if (!/^\d+$/.test(value)) { + throw new Error("amount must be a non-negative integer string"); + } + return BigInt(value); +} + +export function isMainGoalReached(campaign: CampaignRecord): boolean { + try { + return BigInt(campaign.raisedAmount ?? "0") >= BigInt(campaign.goalAmount || "0") && + BigInt(campaign.goalAmount || "0") > 0n; + } catch { + return false; + } +} + +export function isWithinStretchWindow( + goal: Pick, + now: number, +): boolean { + return now >= goal.windowStartsAt && now < goal.windowEndsAt; +} + +export function evaluateStretchGoalStatus( + goal: StretchGoal, + campaign: CampaignRecord, + now = Date.now(), +): StretchGoal { + if (goal.status === "unlocked") return goal; + + const inWindow = isWithinStretchWindow(goal, now); + const mainReached = isMainGoalReached(campaign); + const extraOk = + !goal.targetAmount || + BigInt(campaign.raisedAmount ?? "0") >= parseAmount(goal.targetAmount); + + if (inWindow && mainReached && extraOk) { + return { ...goal, status: "unlocked", unlockedAt: now }; + } + + if (!inWindow && now >= goal.windowEndsAt && goal.status !== "unlocked") { + return { ...goal, status: "expired", expiredAt: goal.expiredAt ?? now }; + } + + return goal; +} + +export function getUnlockedRewardsForBackers( + campaign: CampaignRecord, + now = Date.now(), +): StretchGoalReward[] { + const goals = (campaign.stretchGoals ?? []).map((g) => + evaluateStretchGoalStatus(g, campaign, now), + ); + return goals + .filter((g) => g.status === "unlocked") + .flatMap((g) => g.rewards); +} + +export async function addCampaignStretchGoal( + campaignId: string, + createdBy: string, + input: StretchGoalInput, + dataSource: CampaignDataSource = getCampaignDataSource(), + now = Date.now(), +): Promise { + const campaign = await getCampaign(campaignId, dataSource); + if (!campaign) throw new Error("Campaign not found"); + if (campaign.creator !== createdBy) { + throw new Error("Only the campaign creator can set stretch goals"); + } + if (!input.title?.trim()) throw new Error("Stretch goal title is required"); + if (!input.rewards?.length) throw new Error("At least one reward is required"); + if (input.rewards.some((r) => !r.title?.trim())) { + throw new Error("Each reward must have a title"); + } + if (input.targetAmount !== undefined) parseAmount(input.targetAmount); + + const windowStartsAt = input.windowStartsAt ?? now; + const windowEndsAt = + input.windowEndsAt ?? + windowStartsAt + (input.durationMs ?? DEFAULT_STRETCH_WINDOW_MS); + + if (windowEndsAt <= windowStartsAt) { + throw new Error("Stretch goal window must end after it starts"); + } + + const goal: StretchGoal = { + id: `${campaign.id}:stretch:${now}:${(campaign.stretchGoals ?? []).length}`, + campaignId: campaign.id, + title: input.title.trim(), + description: input.description?.trim(), + targetAmount: input.targetAmount, + windowStartsAt, + windowEndsAt, + rewards: input.rewards.map((r, i) => ({ + id: `${campaign.id}:stretch-reward:${now}:${i}`, + title: r.title.trim(), + description: r.description?.trim(), + })), + status: "pending", + createdBy, + createdAt: now, + }; + + const evaluated = evaluateStretchGoalStatus(goal, campaign, now); + + await dataSource.saveCampaign({ + ...campaign, + stretchGoals: [...(campaign.stretchGoals ?? []), evaluated], + updatedAt: now, + }); + + return evaluated; +} + +export async function syncCampaignStretchGoals( + campaignId: string, + dataSource: CampaignDataSource = getCampaignDataSource(), + now = Date.now(), +): Promise { + const campaign = await getCampaign(campaignId, dataSource); + if (!campaign) throw new Error("Campaign not found"); + const next = (campaign.stretchGoals ?? []).map((g) => + evaluateStretchGoalStatus(g, campaign, now), + ); + const changed = next.some((g, i) => g.status !== campaign.stretchGoals![i].status); + if (changed) { + await dataSource.saveCampaign({ ...campaign, stretchGoals: next, updatedAt: now }); + } + return next; +} + +export async function applyContributionAndUnlockStretchGoals( + campaignId: string, + amount: string, + dataSource: CampaignDataSource = getCampaignDataSource(), + now = Date.now(), +): Promise<{ campaign: CampaignRecord; unlocked: StretchGoal[] }> { + const campaign = await getCampaign(campaignId, dataSource); + if (!campaign) throw new Error("Campaign not found"); + const nextRaised = (BigInt(campaign.raisedAmount || "0") + parseAmount(amount)).toString(); + const updated: CampaignRecord = { + ...campaign, + raisedAmount: nextRaised, + updatedAt: now, + }; + const previous = campaign.stretchGoals ?? []; + const evaluated = previous.map((g) => evaluateStretchGoalStatus(g, updated, now)); + const unlocked = evaluated.filter( + (g, i) => g.status === "unlocked" && previous[i].status !== "unlocked", + ); + const saved = await dataSource.saveCampaign({ + ...updated, + stretchGoals: evaluated, + }); + return { campaign: saved, unlocked }; +} diff --git a/apps/web/src/services/campaign.service.ts b/apps/web/src/services/campaign.service.ts index 41a0dd5..91b2532 100644 --- a/apps/web/src/services/campaign.service.ts +++ b/apps/web/src/services/campaign.service.ts @@ -165,6 +165,9 @@ export interface CampaignRecord { /** Timestamp when the campaign was featured as a success story. */ featuredAt?: number; insuranceClaim?: CampaignInsuranceClaim; + /** Time-limited stretch goals that unlock special backer rewards. */ + stretchGoals?: import("./campaign-stretch-goals.service").StretchGoal[]; + /** Funding-percentage milestones already emailed to the creator. */ /** Funding milestones (e.g. 25, 50, 75, 100) that have already triggered a * creator notification for this campaign (issue #793). */ milestonesNotified?: number[];