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
43 changes: 42 additions & 1 deletion app/frontend/components/forms/CreateEventForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import FormInput from './FormInput';
import ErrorSummary from './ErrorSummary';
import { TagSelector } from '@/components/ui/atoms';

const TAG_SUGGESTIONS = [
'Web3',
'Blockchain',
'Stellar',
'Soroban',
'Hackathon',
'Workshop',
'Networking',
'Startup',
'AI',
'DeFi',
'Community',
'Technology',
];

// ─── Schema ───────────────────────────────────────────────────────────────────
const createEventSchema = z.object({
Expand All @@ -29,6 +45,12 @@ const createEventSchema = z.object({
category: z.enum(['conference', 'workshop', 'concert', 'hackathon', 'meetup', ''], {
errorMap: () => ({ message: 'Please select a category' }),
}).refine(v => v !== '', { message: 'Please select a category' }),
tags: z
.array(z.string().trim().min(1).max(20, 'Tag cannot exceed 20 characters'))
.max(10, 'You can add up to 10 tags')
.refine((tags) => new Set(tags.map((tag) => tag.toLowerCase())).size === tags.length, {
message: 'Duplicate tags are not allowed',
}),
isPublic: z.boolean(),
websiteUrl: z
.string()
Expand All @@ -46,6 +68,7 @@ const FIELD_LABELS: Record<string, string> = {
ticketPrice: 'Ticket Price',
maxAttendees: 'Max Attendees',
category: 'Category',
tags: 'Tags',
isPublic: 'Visibility',
websiteUrl: 'Website URL',
};
Expand Down Expand Up @@ -101,6 +124,7 @@ export default function CreateEventForm() {
ticketPrice: '0',
maxAttendees: '100',
category: '',
tags: [],
isPublic: true,
websiteUrl: '',
},
Expand Down Expand Up @@ -193,6 +217,23 @@ export default function CreateEventForm() {
<option value="meetup">Meetup</option>
</FormInput>

{/* Tags */}
<Controller
name="tags"
control={control}
render={({ field }) => (
<TagSelector
label="Tags"
name={field.name}
value={field.value}
onChange={field.onChange}
suggestions={TAG_SUGGESTIONS}
hint="Press Enter or comma to add tags. Backspace removes the last tag."
error={errors.tags}
/>
)}
/>

{/* Price + Attendees side by side */}
<div className="grid grid-cols-2 gap-4">
<FormInput
Expand Down Expand Up @@ -268,7 +309,7 @@ export default function CreateEventForm() {
role="switch"
aria-checked={field.value}
onClick={() => field.onChange(!field.value)}
className={`relative w-11 h-6 rounded-full transition-all duration-200 flex-shrink-0 ${
className={`relative w-11 h-6 rounded-full transition-all duration-200 shrink-0 ${
field.value ? 'bg-[#3d5afe]' : 'bg-[#1e2333]'
}`}
>
Expand Down
9 changes: 9 additions & 0 deletions app/frontend/components/forms/schema/eventSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ export const createEventSchema = z.object({

websiteUrl: z.string().optional().or(z.literal('')),

tags: z
.array(z.string().trim().min(1, 'Tags cannot be empty').max(20, 'Tag cannot exceed 20 characters'))
.max(10, 'You can add up to 10 tags')
.refine((tags) => new Set(tags.map((tag) => tag.toLowerCase())).size === tags.length, {
message: 'Tags must be unique',
}),

isPublic: z.boolean().default(true),
});

Expand All @@ -61,6 +68,7 @@ export const EVENT_FIELD_LABELS: Record<string, string> = {
maxAttendees: 'Max Attendees',
contractAddress: 'Contract Address',
websiteUrl: 'Website URL',
tags: 'Tags',
isPublic: 'Visibility',
};

Expand All @@ -73,5 +81,6 @@ export const createEventDefaults: CreateEventFormValues = {
maxAttendees: '100',
contractAddress: '',
websiteUrl: '',
tags: [],
isPublic: true,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { TagSelector } from './TagSelector';

const meta: Meta<typeof TagSelector> = {
title: 'UI/Atoms/TagSelector',
component: TagSelector,
tags: ['autodocs'],
};

export default meta;
type Story = StoryObj<typeof meta>;

const DEFAULT_SUGGESTIONS = [
'Web3',
'Stellar',
'Hackathon',
'Networking',
'Workshop',
'Community',
'Technology',
'Startup',
];

export const Default: Story = {
render: (args) => {
const [tags, setTags] = useState<string[]>(['Stellar']);

return (
<div className="w-[520px]">
<TagSelector
{...args}
value={tags}
onChange={setTags}
/>
</div>
);
},
args: {
label: 'Event Tags',
suggestions: DEFAULT_SUGGESTIONS,
hint: 'Press Enter to add. Use Backspace to remove the last tag.',
},
};

export const WithError: Story = {
render: (args) => {
const [tags, setTags] = useState<string[]>([]);

return (
<div className="w-[520px]">
<TagSelector
{...args}
value={tags}
onChange={setTags}
error={{ message: 'At least one tag is required' }}
/>
</div>
);
},
args: {
label: 'Event Tags',
suggestions: DEFAULT_SUGGESTIONS,
},
};
59 changes: 59 additions & 0 deletions app/frontend/components/ui/atoms/TagSelector/TagSelector.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { TagSelector } from './TagSelector';

describe('TagSelector', () => {
it('adds tags with Enter key', () => {
const onChange = vi.fn();
render(<TagSelector label="Tags" value={[]} onChange={onChange} />);

const input = screen.getByLabelText('Tags');
fireEvent.change(input, { target: { value: 'Stellar' } });
fireEvent.keyDown(input, { key: 'Enter' });

expect(onChange).toHaveBeenCalledWith(['Stellar']);
});

it('prevents duplicate tags', () => {
const onChange = vi.fn();
render(<TagSelector label="Tags" value={['Stellar']} onChange={onChange} />);

const input = screen.getByLabelText('Tags');
fireEvent.change(input, { target: { value: 'stellar' } });
fireEvent.keyDown(input, { key: 'Enter' });

expect(onChange).not.toHaveBeenCalled();
expect(screen.getByText('Tag already added')).toBeInTheDocument();
});

it('removes last tag on Backspace when input is empty', () => {
const onChange = vi.fn();
render(<TagSelector label="Tags" value={['Web3', 'Stellar']} onChange={onChange} />);

const input = screen.getByLabelText('Tags');
fireEvent.keyDown(input, { key: 'Backspace' });

expect(onChange).toHaveBeenCalledWith(['Web3']);
});

it('shows suggestions and allows selecting one', () => {
const onChange = vi.fn();
render(
<TagSelector
label="Tags"
value={[]}
onChange={onChange}
suggestions={['Hackathon', 'Workshop']}
/>
);

const input = screen.getByLabelText('Tags');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: 'work' } });

const suggestion = screen.getByRole('option', { name: 'Workshop' });
fireEvent.mouseDown(suggestion);

expect(onChange).toHaveBeenCalledWith(['Workshop']);
});
});
Loading
Loading