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
61 changes: 58 additions & 3 deletions frontend/src/CreateCampaign.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -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();
Expand All @@ -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) ? (
<small id={`${inputId}-error`} className="create-campaign-field-error" role="alert">
{visibleError(field)}
</small>
) : null;

const loadCampaignForEdit = (campaignId) => {
setSelectedId(campaignId);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -252,6 +284,8 @@ export default function CreateCampaign({ onCampaignCreated, campaigns = [], stan
setDeployOnChain(false);
setImageFile(null);
setImagePreview('');
setTouched({});
setSubmitAttempted(false);

if (onCampaignCreated) {
onCampaignCreated(campaign);
Expand Down Expand Up @@ -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)}
<small className="create-campaign-hint">3–80 characters</small>
</div>

Expand Down Expand Up @@ -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)}
<small className="create-campaign-hint">
Stellar asset contract address. Leave empty to default to native XLM.
</small>
Expand All @@ -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)}
<small className="create-campaign-hint">
Points or token units awarded per qualifying action.
</small>
Expand All @@ -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)}
<small className="create-campaign-hint">Set to 0 for unlimited participants.</small>
</div>

Expand All @@ -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)}
</div>

<div className="create-campaign-field">
Expand All @@ -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)}
</div>
</div>

Expand Down Expand Up @@ -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)}
<small className="create-campaign-hint">
Enter a deployed campaign contract ID to link this campaign to on-chain state.
</small>
Expand Down Expand Up @@ -515,7 +570,7 @@ export default function CreateCampaign({ onCampaignCreated, campaigns = [], stan
<button
type="submit"
className="btn btn-primary btn-button"
disabled={!isValid || isSubmitting || !effectiveApiKey}
disabled={isSubmitting || !effectiveApiKey}
>
{isSubmitting
? isEditMode
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/Landing.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
63 changes: 63 additions & 0 deletions frontend/src/lib/validateCampaignForm.js
Original file line number Diff line number Diff line change
@@ -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<string, string>} 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;
}
63 changes: 63 additions & 0 deletions frontend/src/lib/validateCampaignForm.test.js
Original file line number Diff line number Diff line change
@@ -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/,
);
});
});
Loading