diff --git a/frontend/public/dashboard/star-q.svg b/frontend/public/dashboard/star-q.svg new file mode 100644 index 0000000..966b7b8 --- /dev/null +++ b/frontend/public/dashboard/star-q.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/app/creator/quests/new/page.tsx b/frontend/src/app/creator/quests/new/page.tsx new file mode 100644 index 0000000..a4908f3 --- /dev/null +++ b/frontend/src/app/creator/quests/new/page.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import QuestsHub from "@/components/creator/quests/QuestsHub"; +import QuestWizard from "@/components/creator/quests/wizard/QuestWizard"; +import PublishSuccess from "@/components/creator/quests/wizard/PublishSuccess"; + +export default function CreateQuestPage() { + const router = useRouter(); + const [publishedQuestId, setPublishedQuestId] = useState(null); + + if (publishedQuestId) { + return ; + } + + return ( + <> +
+ +
+ +
+
+ + ); +} diff --git a/frontend/src/components/creator/quests/EmptyActiveQuests.tsx b/frontend/src/components/creator/quests/EmptyActiveQuests.tsx index 3bf37de..074ecc5 100644 --- a/frontend/src/components/creator/quests/EmptyActiveQuests.tsx +++ b/frontend/src/components/creator/quests/EmptyActiveQuests.tsx @@ -10,7 +10,7 @@ export default function EmptyActiveQuests() {

+ Add new Quest diff --git a/frontend/src/components/creator/quests/QuestRow.tsx b/frontend/src/components/creator/quests/QuestRow.tsx index 791dfcf..c866f43 100644 --- a/frontend/src/components/creator/quests/QuestRow.tsx +++ b/frontend/src/components/creator/quests/QuestRow.tsx @@ -6,7 +6,7 @@ import QuestRowMenu from "./QuestRowMenu"; import type { QuestRowData } from "./types"; const TAG_STYLES: Record = { - active: "bg-[#9011FF]/20 text-[#B78CFF]", + active: "bg-[#8B5CF6]/20 text-[#B78CFF]", draft: "bg-white/10 text-white/60", completed: "bg-emerald-500/15 text-emerald-400", }; diff --git a/frontend/src/components/creator/quests/QuestsHub.tsx b/frontend/src/components/creator/quests/QuestsHub.tsx index b76057d..e9a0c81 100644 --- a/frontend/src/components/creator/quests/QuestsHub.tsx +++ b/frontend/src/components/creator/quests/QuestsHub.tsx @@ -86,7 +86,7 @@ export default function QuestsHub() { + Add new Quest @@ -115,14 +115,14 @@ export default function QuestsHub() { {counts[tab.key]} {isActive ? ( - + ) : null} ); diff --git a/frontend/src/components/creator/quests/wizard/ParticipantPreviewModal.tsx b/frontend/src/components/creator/quests/wizard/ParticipantPreviewModal.tsx new file mode 100644 index 0000000..7527c71 --- /dev/null +++ b/frontend/src/components/creator/quests/wizard/ParticipantPreviewModal.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect } from "react"; +import Image from "next/image"; +import { X } from "lucide-react"; +import { formatDateTime, rewardCalculations, type QuestWizardData } from "./types"; + +export default function ParticipantPreviewModal({ + data, + onClose, +}: { + data: QuestWizardData; + onClose: () => void; +}) { + const { rewardPerWinner } = rewardCalculations(data.rewards); + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + return ( +
+
+
+

+ Participant preview +

+ +
+ +

+ {data.basics.title || "Untitled quest"} +

+
+ + Ruze.Stellar +
+ +

+ {data.basics.description || "No description provided yet."} +

+ +
+
+

Reward

+

+ {rewardPerWinner.toLocaleString(undefined, { + maximumFractionDigits: 2, + })}{" "} + XLM +

+
+
+

Time

+

+ {data.basics.completionDuration} +

+
+
+

Closes

+

+ {formatDateTime(data.schedule.closingDateTime)} +

+
+
+ +
+

+ What you'll do +

+
    + {data.tasks.map((task, index) => ( +
  1. + + {index + 1} + +
    +

    + {task.title || "Untitled task"} +

    + {task.instruction ? ( +

    {task.instruction}

    + ) : null} +
    +
  2. + ))} +
+
+
+
+ ); +} diff --git a/frontend/src/components/creator/quests/wizard/PublishSuccess.tsx b/frontend/src/components/creator/quests/wizard/PublishSuccess.tsx new file mode 100644 index 0000000..d1909e9 --- /dev/null +++ b/frontend/src/components/creator/quests/wizard/PublishSuccess.tsx @@ -0,0 +1,38 @@ +import Image from "next/image"; +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; + +export default function PublishSuccess({ questId }: { questId: string }) { + return ( +
+ +

+ Quest Published Successfully +

+

+ Your quest has been successfully added to the campaign +

+
+ + Close + + + View quest + + +
+
+ ); +} diff --git a/frontend/src/components/creator/quests/wizard/QuestWizard.tsx b/frontend/src/components/creator/quests/wizard/QuestWizard.tsx new file mode 100644 index 0000000..7b03e40 --- /dev/null +++ b/frontend/src/components/creator/quests/wizard/QuestWizard.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useRef, useState } from "react"; +import Link from "next/link"; +import { Check, X } from "lucide-react"; +import WizardStepper from "./WizardStepper"; +import WizardFooter from "./WizardFooter"; +import ParticipantPreviewModal from "./ParticipantPreviewModal"; +import BasicsStep, { isBasicsStepValid } from "./steps/BasicsStep"; +import EligibilityStep, { isEligibilityStepValid } from "./steps/EligibilityStep"; +import TasksStep, { isTasksStepValid } from "./steps/TasksStep"; +import RewardsStep, { isRewardsStepValid } from "./steps/RewardsStep"; +import ScheduleStep, { isScheduleStepValid } from "./steps/ScheduleStep"; +import ReviewStep from "./steps/ReviewStep"; +import { WIZARD_STEPS, createDefaultWizardData, type QuestWizardData } from "./types"; + +function slugify(title: string): string { + const slug = title + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); + return slug || `quest-${Date.now()}`; +} + +export default function QuestWizard({ + onPublish, +}: { + onPublish: (questId: string) => void; +}) { + const [data, setDataState] = useState(() => createDefaultWizardData()); + const [currentIndex, setCurrentIndex] = useState(0); + const [maxReachedIndex, setMaxReachedIndex] = useState(0); + const [showPreview, setShowPreview] = useState(false); + const [saved, setSaved] = useState(true); + const saveTimeoutRef = useRef | null>(null); + + function setData(updater: (prev: QuestWizardData) => QuestWizardData) { + setDataState(updater); + setSaved(false); + if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = setTimeout(() => setSaved(true), 800); + } + + const stepValid = [ + isBasicsStepValid(data.basics), + isEligibilityStepValid(), + isTasksStepValid(data.tasks), + isRewardsStepValid(data.rewards), + isScheduleStepValid(data.schedule), + true, + ][currentIndex]; + + const isFirstStep = currentIndex === 0; + const isLastStep = currentIndex === WIZARD_STEPS.length - 1; + + function goToStep(index: number) { + if (index <= maxReachedIndex) setCurrentIndex(index); + } + + function handleBack() { + setCurrentIndex((index) => Math.max(0, index - 1)); + } + + function handleContinue() { + if (!stepValid) return; + + if (isLastStep) { + onPublish(slugify(data.basics.title)); + return; + } + + const nextIndex = currentIndex + 1; + setCurrentIndex(nextIndex); + setMaxReachedIndex((max) => Math.max(max, nextIndex)); + } + + function handleSaveDraft() { + // Stub until POST /missions/drafts is wired up. + window.location.href = "/creator/quests"; + } + + return ( +
+
+ + + Exit creator + + {saved ? ( + + + Saved + + ) : ( + Saving… + )} +
+ +
+

+ {data.basics.title || "Create a quest"} +

+ +
+ +
+ {currentIndex === 0 ? ( + + setData((prev) => ({ ...prev, basics: { ...prev.basics, ...patch } })) + } + /> + ) : null} + {currentIndex === 1 ? ( + + setData((prev) => ({ + ...prev, + eligibility: { ...prev.eligibility, ...patch }, + })) + } + /> + ) : null} + {currentIndex === 2 ? ( + setData((prev) => ({ ...prev, tasks }))} + /> + ) : null} + {currentIndex === 3 ? ( + + setData((prev) => ({ ...prev, rewards: { ...prev.rewards, ...patch } })) + } + /> + ) : null} + {currentIndex === 4 ? ( + + setData((prev) => ({ ...prev, schedule: { ...prev.schedule, ...patch } })) + } + /> + ) : null} + {currentIndex === 5 ? : null} +
+ + setShowPreview(true)} + onSaveDraft={handleSaveDraft} + onContinue={handleContinue} + /> + + {showPreview ? ( + setShowPreview(false)} /> + ) : null} +
+ ); +} diff --git a/frontend/src/components/creator/quests/wizard/WizardFooter.tsx b/frontend/src/components/creator/quests/wizard/WizardFooter.tsx new file mode 100644 index 0000000..d1575ba --- /dev/null +++ b/frontend/src/components/creator/quests/wizard/WizardFooter.tsx @@ -0,0 +1,63 @@ +import { ArrowRight, Eye, Save } from "lucide-react"; + +export default function WizardFooter({ + isFirstStep, + isLastStep, + canContinue, + onBack, + onPreview, + onSaveDraft, + onContinue, +}: { + isFirstStep: boolean; + isLastStep: boolean; + canContinue: boolean; + onBack: () => void; + onPreview: () => void; + onSaveDraft: () => void; + onContinue: () => void; +}) { + return ( +
+ {isFirstStep ? ( + + ) : ( + + )} + +
+ + + +
+
+ ); +} diff --git a/frontend/src/components/creator/quests/wizard/WizardStepper.tsx b/frontend/src/components/creator/quests/wizard/WizardStepper.tsx new file mode 100644 index 0000000..105bbb2 --- /dev/null +++ b/frontend/src/components/creator/quests/wizard/WizardStepper.tsx @@ -0,0 +1,59 @@ +import { Check } from "lucide-react"; +import { WIZARD_STEPS } from "./types"; + +export default function WizardStepper({ + currentIndex, + maxReachedIndex, + onStepClick, +}: { + currentIndex: number; + maxReachedIndex: number; + onStepClick: (index: number) => void; +}) { + return ( +
+ {WIZARD_STEPS.map((step, index) => { + const isCompleted = index < maxReachedIndex; + const isCurrent = index === currentIndex; + const isReachable = index <= maxReachedIndex; + + return ( +
+ {index > 0 ? ( + + ) : null} + +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/creator/quests/wizard/fields.tsx b/frontend/src/components/creator/quests/wizard/fields.tsx new file mode 100644 index 0000000..65ac93b --- /dev/null +++ b/frontend/src/components/creator/quests/wizard/fields.tsx @@ -0,0 +1,238 @@ +import { ChevronDown, ChevronUp, Check } from "lucide-react"; + +const INPUT_CLASSES = + "w-full rounded-lg border border-white/10 bg-[#100D1C] px-3 py-2.5 text-sm text-white placeholder:text-white/30 outline-none transition-colors focus:border-[#8B5CF6]/60"; + +export function FieldLabel({ + children, + htmlFor, + hint, +}: { + children: React.ReactNode; + htmlFor?: string; + hint?: string; +}) { + return ( +
+ + {hint ? {hint} : null} +
+ ); +} + +export function TextInput({ + id, + value, + onChange, + placeholder, + maxLength, +}: { + id: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + maxLength?: number; +}) { + return ( + onChange(event.target.value)} + placeholder={placeholder} + className={INPUT_CLASSES} + /> + ); +} + +export function TextArea({ + id, + value, + onChange, + placeholder, + rows = 4, +}: { + id: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + rows?: number; +}) { + return ( +