Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/common/lib/datetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export const timestamp = preprocess(
);

export const futureTimestamp = timestamp.refine(
(value) => value > Temporal.Now.instant().epochMilliseconds,
(value) => value >= Temporal.Now.instant().epochMilliseconds - 60_000,
{ message: "Cannot be in the past" },
);

Expand Down
152 changes: 104 additions & 48 deletions src/entities/campaign/components/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,9 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit
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 });
form.setValue("start_ms", correctedTime, { shouldValidate, shouldDirty: true });
} else {
form.setValue("start_ms", selectedTime, { shouldValidate });
form.setValue("start_ms", selectedTime, { shouldValidate, shouldDirty: true });
}
};

Expand Down Expand Up @@ -127,19 +126,17 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit

if (selectedTime < minTime) {
// Auto-correct to minimum valid time (silently)
form.setValue("end_ms", minTime, { shouldValidate });
form.setValue("end_ms", minTime, { shouldValidate, shouldDirty: true });
} else {
form.setValue("end_ms", selectedTime, { shouldValidate });
form.setValue("end_ms", selectedTime, { shouldValidate, shouldDirty: true });
}
};

// Update minimum datetime every minute to prevent past date selection
// This runs only on client-side to avoid SSG/SSR issues
// Keep the min attribute on datetime inputs up to date (client-side only).
useEffect(() => {
const updateMinDateTime = () => {
const now = Temporal.Now.instant().add({ minutes: 1 });

const newMin = now
const newMin = Temporal.Now.instant()
.add({ minutes: 1 })
.toZonedDateTimeISO(Temporal.Now.timeZoneId())
.toPlainDateTime()
.toString({ smallestUnit: "minute" });
Expand All @@ -156,6 +153,12 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit
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 });
};
Comment on lines +156 to +160
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

handleStartNow doesn't trigger re-validation of end_ms.

If the user has already picked an end date and then clicks "Set to current", the new start_ms could land after end_ms. Since only start_ms is set here, end_ms won't be re-validated, potentially leaving the form in an inconsistent state with no visible error.

Consider triggering end_ms validation as well:

Suggested addition
  const handleStartNow = () => {
    const startEpoch = Temporal.Now.instant().add({ minutes: 1 }).epochMilliseconds;
    form.setValue("start_ms", startEpoch, { shouldDirty: true, shouldValidate: true });
+   // Re-validate end date in case it's now before the new start
+   const endMs = form.getValues("end_ms");
+   if (endMs !== undefined) {
+     form.trigger("end_ms");
+   }
  };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// "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 });
};
// "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 });
// Re-validate end date in case it's now before the new start
const endMs = form.getValues("end_ms");
if (endMs !== undefined) {
form.trigger("end_ms");
}
};
🤖 Prompt for AI Agents
In `@src/entities/campaign/components/editor.tsx` around lines 156 - 160,
handleStartNow currently updates only "start_ms" which can leave "end_ms" stale
and un-validated; after calling form.setValue("start_ms", ...), also trigger
re-validation of the end date (for example call form.trigger("end_ms") or
otherwise validate the "end_ms" field) so any end-before-start errors surface
immediately; update the handleStartNow function to set start_ms as it does and
then call form.trigger("end_ms") (or trigger full form validation) to ensure the
end date is re-checked.


// Track project fields visibility to prevent them from disappearing
useEffect(() => {
const shouldShow =
Expand Down Expand Up @@ -203,10 +206,49 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit
} else return null;
}, [existingData, token]);

// Check if datetime fields have validation errors
const hasDateTimeErrors = useMemo(() => {
return form.formState.errors.start_ms || form.formState.errors.end_ms;
}, [form.formState.errors]);
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!
Expand Down Expand Up @@ -340,14 +382,12 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit

<Form {...form}>
<form
onSubmit={(e) => {
e.preventDefault();

onSubmit={form.handleSubmit((values) => {
onSubmit({
...form.getValues(),
...values,
allow_fee_avoidance: avoidFee,
});
}}
})}
>
<div className="mb-8 mt-8">
{showProjectFields && (
Expand Down Expand Up @@ -673,29 +713,39 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit
)}
>
{!campaignId ? (
<FormField
control={form.control}
name="start_ms"
render={({ field: { value, onChange: _onChange, ...field } }) => (
<TextField
{...field}
required={true}
label="Start Date"
min={minStartDateTime}
value={
typeof value === "number"
? Temporal.Instant.fromEpochMilliseconds(value)
.toZonedDateTimeISO(Temporal.Now.timeZoneId())
.toPlainDateTime()
.toString({ smallestUnit: "minute" })
: undefined
}
onChange={(e) => handleStartDateChange(e.target.value)}
classNames={{ root: "lg:w-90 md:w-90 mb-8 md:mb-0" }}
type="datetime-local"
/>
)}
/>
<div className="lg:w-90 md:w-90 mb-8 flex flex-col md:mb-0">
<FormField
control={form.control}
name="start_ms"
render={({ field: { value, onChange: _onChange, ...field } }) => (
<TextField
{...field}
required={true}
label="Start Date"
min={minStartDateTime}
value={
typeof value === "number"
? Temporal.Instant.fromEpochMilliseconds(value)
.toZonedDateTimeISO(Temporal.Now.timeZoneId())
.toPlainDateTime()
.toString({ smallestUnit: "minute" })
: undefined
}
onChange={(e) => handleStartDateChange(e.target.value)}
type="datetime-local"
/>
)}
/>

<Button
type="button"
variant="standard-outline"
onClick={handleStartNow}
className="mt-2 w-fit"
>
Set to current
</Button>
</div>
) : (
existingData?.start_at &&
toTimestamp(existingData?.start_at) > Temporal.Now.instant().epochMilliseconds && (
Expand Down Expand Up @@ -823,12 +873,18 @@ export const CampaignEditor = ({ existingData, campaignId, close }: CampaignEdit

<div className="my-10 flex flex-row-reverse justify-between">
<div className="flex flex-col items-end gap-2">
{isDisabled && !form.formState.isSubmitting && (
<p className="text-sm text-orange-600">
{hasDateTimeErrors
? "Please check the start and end dates above"
: "Please fill in all required fields correctly"}
</p>
{!form.formState.isSubmitting && isDisabled && (
<div className="flex flex-col items-end gap-1">
{fieldErrorMessages.length > 0 ? (
fieldErrorMessages.map((msg, i) => (
<p key={i} className="text-sm text-orange-600">
{msg}
</p>
))
) : (
<p className="text-sm text-orange-600">Please fill in all required fields</p>
)}
</div>
)}
<Button variant="brand-filled" disabled={isDisabled} type="submit">
{isUpdate ? "Update" : "Create"} Campaign
Expand Down
3 changes: 2 additions & 1 deletion src/entities/campaign/hooks/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,13 @@ export const useCampaignForm = ({ campaignId, ftId, onUpdateSuccess }: CampaignF

const isDisabled = useMemo(
() =>
!self.formState.isDirty ||
(!isNewCampaign && !self.formState.isDirty) ||
!self.formState.isValid ||
self.formState.isSubmitting ||
(values.ft_id !== NATIVE_TOKEN_ID && !isTokenDataLoading && token === undefined),

[
isNewCampaign,
isTokenDataLoading,
self.formState.isDirty,
self.formState.isSubmitting,
Expand Down
Loading