diff --git a/frontend/src/CreateCampaign.jsx b/frontend/src/CreateCampaign.jsx
index f33078e9..6d29b9ee 100644
--- a/frontend/src/CreateCampaign.jsx
+++ b/frontend/src/CreateCampaign.jsx
@@ -4,6 +4,7 @@ import { apiUrl, getStellarNetwork } from './config';
import { logSafeEvent } from './lib/safeAnalytics';
import { initializeCampaignContract, getWalletAddress, isWalletConnected } from './stellar';
import TransactionStatus from './components/TransactionStatus';
+import { validateCampaignForm } from './lib/validateCampaignForm';
const MAX_IMAGE_SIZE_BYTES = 2 * 1024 * 1024;
const ALLOWED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jpg'];
@@ -40,6 +41,8 @@ export default function CreateCampaign({ onCampaignCreated, campaigns = [], stan
const [success, setSuccess] = useState('');
const [deploymentStatus, setDeploymentStatus] = useState('');
const [txHash, setTxHash] = useState('');
+ const [touched, setTouched] = useState({});
+ const [submitAttempted, setSubmitAttempted] = useState(false);
const headingId = useId();
const nameId = useId();
const descId = useId();
@@ -61,7 +64,32 @@ export default function CreateCampaign({ onCampaignCreated, campaigns = [], stan
: '';
const effectiveApiKey = apiKeyInput || storedApiKey;
- const isValid = name.trim().length >= 3 && name.trim().length <= 80;
+ const fieldErrors = validateCampaignForm(
+ {
+ name,
+ rewardPerAction,
+ maxParticipants,
+ rewardToken,
+ contractId: contractIdInput,
+ startDate,
+ endDate,
+ },
+ { isEditMode },
+ );
+ const isValid = Object.keys(fieldErrors).length === 0;
+ const markTouched = (field) => setTouched((prev) => ({ ...prev, [field]: true }));
+ const visibleError = (field) =>
+ (touched[field] || submitAttempted) && fieldErrors[field] ? fieldErrors[field] : '';
+ const errorProps = (field, inputId) =>
+ visibleError(field)
+ ? { 'aria-invalid': true, 'aria-describedby': `${inputId}-error` }
+ : {};
+ const renderFieldError = (field, inputId) =>
+ visibleError(field) ? (
+
+ {visibleError(field)}
+
+ ) : null;
const loadCampaignForEdit = (campaignId) => {
setSelectedId(campaignId);
@@ -131,7 +159,11 @@ export default function CreateCampaign({ onCampaignCreated, campaigns = [], stan
const handleSubmit = async (event) => {
event.preventDefault();
- if (!isValid) return;
+ setSubmitAttempted(true);
+ if (!isValid) {
+ setError('Please fix the highlighted fields before submitting.');
+ return;
+ }
if (!effectiveApiKey) {
setError('Admin API key is required. It is stored in session only.');
return;
@@ -252,6 +284,8 @@ export default function CreateCampaign({ onCampaignCreated, campaigns = [], stan
setDeployOnChain(false);
setImageFile(null);
setImagePreview('');
+ setTouched({});
+ setSubmitAttempted(false);
if (onCampaignCreated) {
onCampaignCreated(campaign);
@@ -332,7 +366,10 @@ export default function CreateCampaign({ onCampaignCreated, campaigns = [], stan
minLength={3}
maxLength={80}
onChange={(e) => setName(e.target.value)}
+ onBlur={() => markTouched('name')}
+ {...errorProps('name', nameId)}
/>
+ {renderFieldError('name', nameId)}
3–80 characters
@@ -363,7 +400,10 @@ export default function CreateCampaign({ onCampaignCreated, campaigns = [], stan
value={rewardToken}
disabled={isSubmitting}
onChange={(e) => setRewardToken(e.target.value)}
+ onBlur={() => markTouched('rewardToken')}
+ {...errorProps('rewardToken', rewardTokenId)}
/>
+ {renderFieldError('rewardToken', rewardTokenId)}
Stellar asset contract address. Leave empty to default to native XLM.
@@ -383,7 +423,10 @@ export default function CreateCampaign({ onCampaignCreated, campaigns = [], stan
value={rewardPerAction}
disabled={isSubmitting}
onChange={(e) => setRewardPerAction(e.target.value)}
+ onBlur={() => markTouched('rewardPerAction')}
+ {...errorProps('rewardPerAction', rewardId)}
/>
+ {renderFieldError('rewardPerAction', rewardId)}
Points or token units awarded per qualifying action.
@@ -403,7 +446,10 @@ export default function CreateCampaign({ onCampaignCreated, campaigns = [], stan
value={maxParticipants}
disabled={isSubmitting}
onChange={(e) => setMaxParticipants(e.target.value)}
+ onBlur={() => markTouched('maxParticipants')}
+ {...errorProps('maxParticipants', maxParticipantsId)}
/>
+ {renderFieldError('maxParticipants', maxParticipantsId)}
Set to 0 for unlimited participants.
@@ -419,7 +465,10 @@ export default function CreateCampaign({ onCampaignCreated, campaigns = [], stan
value={startDate}
disabled={isSubmitting}
onChange={(e) => setStartDate(e.target.value)}
+ onBlur={() => markTouched('startDate')}
+ {...errorProps('startDate', startDateId)}
/>
+ {renderFieldError('startDate', startDateId)}
@@ -433,7 +482,10 @@ export default function CreateCampaign({ onCampaignCreated, campaigns = [], stan
value={endDate}
disabled={isSubmitting}
onChange={(e) => setEndDate(e.target.value)}
+ onBlur={() => markTouched('endDate')}
+ {...errorProps('endDate', endDateId)}
/>
+ {renderFieldError('endDate', endDateId)}
@@ -487,7 +539,10 @@ export default function CreateCampaign({ onCampaignCreated, campaigns = [], stan
value={contractIdInput}
disabled={isSubmitting}
onChange={(e) => setContractIdInput(e.target.value)}
+ onBlur={() => markTouched('contractId')}
+ {...errorProps('contractId', contractIdInputId)}
/>
+ {renderFieldError('contractId', contractIdInputId)}
Enter a deployed campaign contract ID to link this campaign to on-chain state.
@@ -515,7 +570,7 @@ export default function CreateCampaign({ onCampaignCreated, campaigns = [], stan
{isSubmitting
? isEditMode
diff --git a/frontend/src/Landing.css b/frontend/src/Landing.css
index 5e5882ee..59f02459 100644
--- a/frontend/src/Landing.css
+++ b/frontend/src/Landing.css
@@ -1659,3 +1659,15 @@
color: var(--text-muted);
font-style: italic;
}
+
+/* Issue #1230: inline client-side validation feedback on the campaign form */
+.create-campaign-field-error {
+ display: block;
+ margin-top: 0.25rem;
+ color: var(--danger);
+ font-size: 0.8125rem;
+}
+
+.create-campaign-input[aria-invalid='true'] {
+ border-color: var(--danger);
+}
diff --git a/frontend/src/lib/validateCampaignForm.js b/frontend/src/lib/validateCampaignForm.js
new file mode 100644
index 00000000..2faaf973
--- /dev/null
+++ b/frontend/src/lib/validateCampaignForm.js
@@ -0,0 +1,63 @@
+// Client-side validation for the CreateCampaign form (issue #1230).
+// Pure function so it can be unit tested without rendering the form.
+
+const STELLAR_CONTRACT_RE = /^C[A-Z2-7]{55}$/;
+
+/**
+ * @param {object} values - raw form values (strings as held in state)
+ * @param {object} [options]
+ * @param {boolean} [options.isEditMode] - skip "end date in the past" for existing campaigns
+ * @param {Date} [options.now]
+ * @returns {Record} map of field -> error message (empty when valid)
+ */
+export function validateCampaignForm(values, { isEditMode = false, now = new Date() } = {}) {
+ const errors = {};
+ const name = (values.name || '').trim();
+
+ if (!name) {
+ errors.name = 'Campaign name is required.';
+ } else if (name.length < 3 || name.length > 80) {
+ errors.name = 'Campaign name must be 3–80 characters.';
+ }
+
+ if (values.rewardPerAction !== '' && values.rewardPerAction != null) {
+ const reward = Number(values.rewardPerAction);
+ if (!Number.isFinite(reward) || reward <= 0) {
+ errors.rewardPerAction = 'Reward amount must be a positive number.';
+ } else if (!Number.isInteger(reward)) {
+ errors.rewardPerAction = 'Reward amount must be a whole number of token units.';
+ }
+ }
+
+ if (values.maxParticipants !== '' && values.maxParticipants != null) {
+ const max = Number(values.maxParticipants);
+ if (!Number.isInteger(max) || max < 0) {
+ errors.maxParticipants = 'Max participants must be a whole number (0 = unlimited).';
+ }
+ }
+
+ const token = (values.rewardToken || '').trim();
+ if (token && !STELLAR_CONTRACT_RE.test(token)) {
+ errors.rewardToken = 'Reward token must be a Stellar contract address (C…, 56 characters).';
+ }
+
+ const contractId = (values.contractId || '').trim();
+ if (contractId && !STELLAR_CONTRACT_RE.test(contractId)) {
+ errors.contractId = 'Contract ID must be a Stellar contract address (C…, 56 characters).';
+ }
+
+ const start = values.startDate ? new Date(values.startDate) : null;
+ const end = values.endDate ? new Date(values.endDate) : null;
+ if (start && Number.isNaN(start.getTime())) errors.startDate = 'Start date is invalid.';
+ if (end && Number.isNaN(end.getTime())) {
+ errors.endDate = 'End date is invalid.';
+ } else if (end) {
+ if (!isEditMode && end.getTime() <= now.getTime()) {
+ errors.endDate = 'End date must be in the future.';
+ } else if (start && !errors.startDate && end.getTime() <= start.getTime()) {
+ errors.endDate = 'End date must be after the start date.';
+ }
+ }
+
+ return errors;
+}
diff --git a/frontend/src/lib/validateCampaignForm.test.js b/frontend/src/lib/validateCampaignForm.test.js
new file mode 100644
index 00000000..95403eaa
--- /dev/null
+++ b/frontend/src/lib/validateCampaignForm.test.js
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest';
+import { validateCampaignForm } from './validateCampaignForm';
+
+const NOW = new Date('2026-01-01T00:00:00Z');
+const CONTRACT = `C${'A'.repeat(55)}`;
+const valid = {
+ name: 'Onboarding Rewards',
+ rewardPerAction: '10',
+ maxParticipants: '0',
+ rewardToken: '',
+ contractId: '',
+ startDate: '2026-02-01T00:00',
+ endDate: '2026-03-01T00:00',
+};
+const run = (overrides, opts = {}) => validateCampaignForm({ ...valid, ...overrides }, { now: NOW, ...opts });
+
+describe('validateCampaignForm', () => {
+ it('accepts a valid form', () => {
+ expect(run({})).toEqual({});
+ });
+
+ it('accepts empty optional fields', () => {
+ expect(run({ rewardPerAction: '', maxParticipants: '', startDate: '', endDate: '' })).toEqual({});
+ });
+
+ it('requires a 3–80 character name', () => {
+ expect(run({ name: ' ' }).name).toMatch(/required/);
+ expect(run({ name: 'ab' }).name).toMatch(/3–80/);
+ expect(run({ name: 'x'.repeat(81) }).name).toMatch(/3–80/);
+ });
+
+ it('rejects zero, negative, fractional and non-numeric token amounts', () => {
+ expect(run({ rewardPerAction: '0' }).rewardPerAction).toMatch(/positive/);
+ expect(run({ rewardPerAction: '-5' }).rewardPerAction).toMatch(/positive/);
+ expect(run({ rewardPerAction: 'abc' }).rewardPerAction).toMatch(/positive/);
+ expect(run({ rewardPerAction: '1.5' }).rewardPerAction).toMatch(/whole/);
+ });
+
+ it('rejects negative or fractional max participants', () => {
+ expect(run({ maxParticipants: '-1' }).maxParticipants).toBeDefined();
+ expect(run({ maxParticipants: '2.5' }).maxParticipants).toBeDefined();
+ });
+
+ it('validates Stellar contract addresses', () => {
+ expect(run({ rewardToken: 'GABC' }).rewardToken).toBeDefined();
+ expect(run({ contractId: 'nope' }).contractId).toBeDefined();
+ expect(run({ rewardToken: CONTRACT, contractId: CONTRACT })).toEqual({});
+ });
+
+ it('rejects end dates in the past when creating', () => {
+ expect(run({ startDate: '', endDate: '2025-06-01T00:00' }).endDate).toMatch(/future/);
+ });
+
+ it('allows past end dates when editing an existing campaign', () => {
+ expect(run({ startDate: '', endDate: '2025-06-01T00:00' }, { isEditMode: true })).toEqual({});
+ });
+
+ it('requires end date after start date', () => {
+ expect(run({ startDate: '2026-03-01T00:00', endDate: '2026-02-01T00:00' }).endDate).toMatch(
+ /after the start/,
+ );
+ });
+});