{viewer.isSignedIn &&
- viewer.accountId === campaign?.owner &&
- (!campaign?.end_ms || Temporal.Now.instant().epochMilliseconds < campaign.end_ms) && (
+ viewer.accountId === campaign?.owner?.id &&
+ (!campaign?.end_at ||
+ Temporal.Now.instant().epochMilliseconds < toTimestamp(campaign.end_at)) && (
setOpenEditCampaign(!openEditCampaign)}
@@ -161,7 +173,18 @@ export const CampaignSettings: React.FC = ({ campaignId }
{campaign?.name}
-
{campaign?.description}
+
{
+ // Prevent navigation when clicking on links
+ if (event.target instanceof HTMLAnchorElement) {
+ event.stopPropagation();
+ }
+ }}
+ />
@@ -175,8 +198,8 @@ export const CampaignSettings: React.FC = ({ campaignId }
) : (
@@ -215,6 +238,10 @@ export const CampaignSettings: React.FC = ({ campaignId }
: "N/A"
}`}
/>
+
) : (
diff --git a/src/entities/campaign/components/CampaignsList.tsx b/src/entities/campaign/components/CampaignsList.tsx
index 034d1b228..f8925145a 100644
--- a/src/entities/campaign/components/CampaignsList.tsx
+++ b/src/entities/campaign/components/CampaignsList.tsx
@@ -1,16 +1,28 @@
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
-import { Campaign } from "@/common/contracts/core/campaigns";
-import { SearchBar, SortSelect, Spinner } from "@/common/ui/layout/components";
+import { Campaign } from "@/common/api/indexer";
+import {
+ Filter,
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+ SearchBar,
+ SortSelect,
+} from "@/common/ui/layout/components";
import { CampaignCard } from "./CampaignCard";
+import { CampaignCardSkeleton } from "./CampaignCardSkeleton";
import { useAllCampaignLists } from "../hooks/useCampaigns";
export const CampaignsList = () => {
const [search, setSearch] = useState("");
const [filteredCampaigns, setFilteredCampaigns] = useState
([]);
- const { buttons, campaigns, loading, currentTab } = useAllCampaignLists();
+ const { buttons, campaigns, loading, currentTab, tagsList, pagination } = useAllCampaignLists();
const SORT_LIST_PROJECTS = [
{ label: "Newest", value: "recent" },
@@ -22,12 +34,12 @@ export const CampaignsList = () => {
switch (sortType) {
case "recent":
- projects.sort((a, b) => new Date(b.start_ms).getTime() - new Date(a.start_ms).getTime());
+ projects.sort((a, b) => new Date(b.start_at).getTime() - new Date(a.start_at).getTime());
setFilteredCampaigns(projects);
break;
case "older":
- projects.sort((a, b) => new Date(a.start_ms).getTime() - new Date(b.start_ms).getTime());
+ projects.sort((a, b) => new Date(a.start_at).getTime() - new Date(b.start_at).getTime());
setFilteredCampaigns(projects);
break;
@@ -45,11 +57,40 @@ export const CampaignsList = () => {
setFilteredCampaigns(filtered);
}, [search, campaigns]);
- return loading ? (
-
-
-
- ) : (
+ const content = useMemo(() => {
+ if (loading) {
+ return (
+
+ {Array.from({ length: 6 }, (_, index) => (
+
+ ))}
+
+ );
+ }
+
+ if (!filteredCampaigns || filteredCampaigns.length === 0) {
+ return (
+
+

+
+
+ );
+ }
+
+ return (
+
+ {filteredCampaigns
+ .filter((campaign) => campaign?.on_chain_id !== 14)
+ .map((campaign) => (
+
+ ))}
+
+ );
+ }, [loading, filteredCampaigns]);
+
+ return (
{buttons.map(
@@ -70,24 +111,94 @@ export const CampaignsList = () => {
placeholder="Search Campaigns"
onChange={(e) => setSearch(e.target.value.toLowerCase())}
/>
+
-
- {filteredCampaigns.length ? (
-
- {filteredCampaigns
- ?.filter((campaign) => campaign?.id !== 14)
- .map((campaign) => )}
-
- ) : (
-
-

-
+
{content}
+
+ {/* Pagination - Only show for ALL_CAMPAIGNS */}
+ {currentTab === "ALL_CAMPAIGNS" &&
+ pagination.totalPages > 1 &&
+ !loading &&
+ filteredCampaigns.length > 0 && (
+
+
+
+
+ {
+ e.preventDefault();
+
+ if (pagination.hasPreviousPage) {
+ pagination.setCurrentPage(pagination.currentPage - 1);
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ }
+ }}
+ className={
+ !pagination.hasPreviousPage
+ ? "pointer-events-none opacity-50"
+ : "cursor-pointer"
+ }
+ />
+
+
+ {/* Page numbers */}
+ {Array.from({ length: Math.min(5, pagination.totalPages) }, (_, i) => {
+ let pageNum: number;
+
+ if (pagination.totalPages <= 5) {
+ pageNum = i + 1;
+ } else if (pagination.currentPage <= 3) {
+ pageNum = i + 1;
+ } else if (pagination.currentPage >= pagination.totalPages - 2) {
+ pageNum = pagination.totalPages - 4 + i;
+ } else {
+ pageNum = pagination.currentPage - 2 + i;
+ }
+
+ return (
+
+ {
+ e.preventDefault();
+ pagination.setCurrentPage(pageNum);
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ }}
+ isActive={pagination.currentPage === pageNum}
+ className="cursor-pointer"
+ >
+ {pageNum}
+
+
+ );
+ })}
+
+ {pagination.totalPages > 5 &&
+ pagination.currentPage < pagination.totalPages - 2 && (
+
+
+
+ )}
+
+
+ {
+ e.preventDefault();
+
+ if (pagination.hasNextPage) {
+ pagination.setCurrentPage(pagination.currentPage + 1);
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ }
+ }}
+ className={
+ !pagination.hasNextPage ? "pointer-events-none opacity-50" : "cursor-pointer"
+ }
+ />
+
+
+
)}
-
);
};
diff --git a/src/entities/campaign/components/editor.tsx b/src/entities/campaign/components/editor.tsx
index 927a56c62..dd9a979c4 100644
--- a/src/entities/campaign/components/editor.tsx
+++ b/src/entities/campaign/components/editor.tsx
@@ -5,15 +5,21 @@ import { useRouter } from "next/router";
import { isNonNullish } from "remeda";
import { Temporal } from "temporal-polyfill";
+import { Campaign } from "@/common/api/indexer";
import { NATIVE_TOKEN_ID } from "@/common/constants";
-import { Campaign } from "@/common/contracts/core/campaigns";
import { indivisibleUnitsToFloat, parseNumber } from "@/common/lib";
+import { toTimestamp } from "@/common/lib/datetime";
import { pinataHooks } from "@/common/services/pinata";
import { CampaignId } from "@/common/types";
import { TextAreaField, TextField } from "@/common/ui/form/components";
+import { RichTextEditor } from "@/common/ui/form/components/richtext";
import { Button, Form, FormField, Switch } from "@/common/ui/layout/components";
import { cn } from "@/common/ui/layout/utils";
import { useWalletUserSession } from "@/common/wallet";
+import {
+ ACCOUNT_PROFILE_DESCRIPTION_MAX_LENGTH,
+ useAccountSocialProfile,
+} from "@/entities/_shared/account";
import { TokenSelector, useFungibleToken } from "@/entities/_shared/token";
import { useCampaignForm } from "../hooks/forms";
@@ -29,28 +35,156 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit
const walletUser = useWalletUserSession();
const { back } = useRouter();
const [avoidFee, setAvoidFee] = useState
(false);
+ const [recipientType, setRecipientType] = useState<"yourself" | "someone_else">("yourself");
const isUpdate = campaignId !== undefined;
- const { form, handleCoverImageUploadResult, onSubmit, watch, isDisabled } = useCampaignForm({
- campaignId,
- ftId: existingData?.ft_id ?? NATIVE_TOKEN_ID,
- onUpdateSuccess: close,
+ // Minimum datetime for start date (current time + 1 minute buffer)
+ // Initialize with empty string to avoid SSR/SSG issues with Temporal.Now.timeZoneId()
+ const [minStartDateTime, setMinStartDateTime] = useState("");
+
+ // Track when project fields should be shown (prevent disappearing after being shown)
+ const [showProjectFields, setShowProjectFields] = useState(false);
+
+ const { form, handleCoverImageUploadResult, onSubmit, watch, isDisabled, handleDeleteCampaign } =
+ useCampaignForm({
+ campaignId,
+ ftId: existingData?.token?.account ?? NATIVE_TOKEN_ID,
+ onUpdateSuccess: close,
+ });
+
+ const { profile, isLoading: isProfileLoading } = useAccountSocialProfile({
+ accountId: walletUser?.accountId ?? "",
});
+ const [ftId, targetAmount, minAmount, maxAmount, coverImageUrl, description, recipient] =
+ form.watch([
+ "ft_id",
+ "target_amount",
+ "min_amount",
+ "max_amount",
+ "cover_image_url",
+ "description",
+ "recipient",
+ ]);
+
+ // Set initial recipient when component mounts (only for create mode)
+ useEffect(() => {
+ if (!isUpdate && recipientType === "yourself" && walletUser?.accountId) {
+ form.setValue("recipient", walletUser.accountId);
+ }
+ }, [recipientType, walletUser?.accountId, form, isUpdate]);
+
+ // Validate and auto-correct start date if it's in the past
+ const handleStartDateChange = (value: string) => {
+ if (!value) {
+ form.setValue("start_ms", undefined, { shouldValidate: false });
+ return;
+ }
+
+ const selectedTime = Temporal.PlainDateTime.from(value)
+ .toZonedDateTime(Temporal.Now.timeZoneId())
+ .toInstant().epochMilliseconds;
+
+ const minTime = Temporal.Now.instant().add({ minutes: 1 }).epochMilliseconds;
+
+ // Only validate if end_ms has been touched or has a value
+ // This prevents showing validation errors on untouched end_ms field
+ const endMsValue = form.getValues("end_ms");
+ const endMsTouched = form.formState.touchedFields.end_ms;
+ const shouldValidate = endMsTouched === true || endMsValue !== undefined;
+
+ if (selectedTime < minTime) {
+ // Auto-correct to minimum valid time (silently)
+ const correctedTime = Temporal.Now.instant().add({ minutes: 1 }).epochMilliseconds;
+ form.setValue("start_ms", correctedTime, { shouldValidate, shouldDirty: true });
+ } else {
+ form.setValue("start_ms", selectedTime, { shouldValidate, shouldDirty: true });
+ }
+ };
+
+ // Validate and auto-correct end date if it's before start date
+ const handleEndDateChange = (value: string) => {
+ if (!value) {
+ form.setValue("end_ms", undefined, { shouldValidate: false });
+ return;
+ }
+
+ const selectedTime = Temporal.PlainDateTime.from(value)
+ .toZonedDateTime(Temporal.Now.timeZoneId())
+ .toInstant().epochMilliseconds;
+
+ const startMs = form.getValues("start_ms");
+
+ const minTime = startMs
+ ? (startMs as number) + 60000 // At least 1 minute after start
+ : Temporal.Now.instant().add({ minutes: 1 }).epochMilliseconds;
+
+ // Only validate if start_ms has been touched or has a value
+ // This prevents showing validation errors on untouched start_ms field
+ const startMsTouched = form.formState.touchedFields.start_ms;
+ const shouldValidate = startMsTouched === true || startMs !== undefined;
+
+ if (selectedTime < minTime) {
+ // Auto-correct to minimum valid time (silently)
+ form.setValue("end_ms", minTime, { shouldValidate, shouldDirty: true });
+ } else {
+ form.setValue("end_ms", selectedTime, { shouldValidate, shouldDirty: true });
+ }
+ };
+
+ // Keep the min attribute on datetime inputs up to date (client-side only).
+ useEffect(() => {
+ const updateMinDateTime = () => {
+ const newMin = Temporal.Now.instant()
+ .add({ minutes: 1 })
+ .toZonedDateTimeISO(Temporal.Now.timeZoneId())
+ .toPlainDateTime()
+ .toString({ smallestUnit: "minute" });
+
+ setMinStartDateTime(newMin);
+ };
+
+ // Set initial value immediately on mount (client-side only)
+ updateMinDateTime();
+
+ // Then update every 60 seconds
+ const interval = setInterval(updateMinDateTime, 60000);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ // "Set to current" — sets start date to right now
+ const handleStartNow = () => {
+ const startEpoch = Temporal.Now.instant().add({ minutes: 1 }).epochMilliseconds;
+ form.setValue("start_ms", startEpoch, { shouldDirty: true, shouldValidate: true });
+ };
+
+ // Track project fields visibility to prevent them from disappearing
+ useEffect(() => {
+ const shouldShow =
+ !isUpdate &&
+ !isProfileLoading &&
+ !profile &&
+ process.env.NEXT_PUBLIC_ENV !== "test" &&
+ walletUser?.accountId &&
+ walletUser?.accountId === recipient;
+
+ // Hide fields if profile exists (user already has NEAR Social account)
+ const shouldHide = !isProfileLoading && profile;
+
+ if (shouldHide && showProjectFields) {
+ setShowProjectFields(false);
+ } else if (shouldShow && !showProjectFields) {
+ setShowProjectFields(true);
+ }
+ }, [isUpdate, isProfileLoading, profile, walletUser?.accountId, recipient, showProjectFields]);
+
const { handleFileInputChange, isPending: isBannerUploadPending } = pinataHooks.useFileUpload({
onSuccess: handleCoverImageUploadResult,
});
- const [ftId, targetAmount, minAmount, maxAmount, coverImageUrl] = form.watch([
- "ft_id",
- "target_amount",
- "min_amount",
- "max_amount",
- "cover_image_url",
- ]);
-
const { data: token } = useFungibleToken({
- tokenId: existingData?.ft_id ?? ftId ?? NATIVE_TOKEN_ID,
+ tokenId: existingData?.token?.account ?? ftId ?? NATIVE_TOKEN_ID,
balanceCheckAccountId: walletUser?.accountId,
});
@@ -72,12 +206,56 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit
} else return null;
}, [existingData, token]);
+ const fieldErrorMessages = useMemo(() => {
+ const errors = form.formState.errors;
+
+ const fields: [string, string][] = [
+ ["name", "Campaign Name"],
+ ["description", "Description"],
+ ["target_amount", "Target Amount"],
+ ["min_amount", "Minimum Target Amount"],
+ ["max_amount", "Maximum Target Amount"],
+ ["start_ms", "Start Date"],
+ ["end_ms", "End Date"],
+ ["recipient", "Recipient"],
+ ["cover_image_url", "Cover Image URL"],
+ ["referral_fee_basis_points", "Referral Fee"],
+ ["creator_fee_basis_points", "Creator Fee"],
+ ["ft_id", "Token"],
+ ];
+
+ const messages: string[] = [];
+
+ for (const [key, label] of fields) {
+ const error = errors[key as keyof typeof errors];
+
+ if (error?.message) {
+ messages.push(`${label}: ${String(error.message)}`);
+ }
+ }
+
+ return messages;
+ }, [
+ form.formState.errors.name,
+ form.formState.errors.description,
+ form.formState.errors.target_amount,
+ form.formState.errors.min_amount,
+ form.formState.errors.max_amount,
+ form.formState.errors.start_ms,
+ form.formState.errors.end_ms,
+ form.formState.errors.recipient,
+ form.formState.errors.cover_image_url,
+ form.formState.errors.referral_fee_basis_points,
+ form.formState.errors.creator_fee_basis_points,
+ form.formState.errors.ft_id,
+ ]);
+
// TODO: Use `useEnhancedForm` for form setup instead, this effect is called upon EVERY RENDER,
// TODO: which impacts UX and performance SUBSTANTIALLY!
useEffect(() => {
if (isUpdate && existingData && !form.formState.isDirty) {
- if (isNonNullish(existingData.ft_id) && ftId !== existingData.ft_id) {
- form.setValue("ft_id", existingData.ft_id);
+ if (isNonNullish(existingData.token?.account) && ftId !== existingData.token?.account) {
+ form.setValue("ft_id", existingData.token?.account);
}
if (token !== undefined) {
@@ -98,19 +276,19 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit
form.setValue("cover_image_url", existingData.cover_image_url);
}
- form.setValue("recipient", existingData?.recipient);
- form.setValue("name", existingData?.name);
- form.setValue("description", existingData.description);
+ form.setValue("recipient", existingData?.recipient?.id ?? "");
+ form.setValue("name", existingData?.name ?? "");
+ form.setValue("description", existingData?.description ?? "");
if (
- existingData?.start_ms &&
- existingData?.start_ms > Temporal.Now.instant().epochMilliseconds
+ existingData?.start_at &&
+ toTimestamp(existingData?.start_at) > Temporal.Now.instant().epochMilliseconds
) {
- form.setValue("start_ms", existingData?.start_ms);
+ form.setValue("start_ms", toTimestamp(existingData?.start_at));
}
- if (existingData?.end_ms) {
- form.setValue("end_ms", existingData?.end_ms);
+ if (existingData?.end_at) {
+ form.setValue("end_ms", toTimestamp(existingData?.end_at));
}
if (existingData.allow_fee_avoidance) {
@@ -193,22 +371,83 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit
Time-limited Campaign: Has a specified end date—concludes on the set
date.
+
+
+ Campaign Deletion: Campaigns can only be deleted before they start.
+ Once a campaign has started, it cannot be deleted.
+