diff --git a/app/src/agentworld/pages/JobsSection.test.tsx b/app/src/agentworld/pages/JobsSection.test.tsx index 6d883101b..4baa333eb 100644 --- a/app/src/agentworld/pages/JobsSection.test.tsx +++ b/app/src/agentworld/pages/JobsSection.test.tsx @@ -620,3 +620,99 @@ describe('Jobs write actions', () => { }); }); }); + +// ── Proposal deadline normalization ─────────────────────────────────────────── + +describe('Proposal deadline normalization', () => { + // Helper: open the Post Job modal and fill required fields. + async function openPostJobModal(user: ReturnType) { + vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as any); + vi.mocked(apiClient.graphql.jobs).mockResolvedValue({ jobs: [], count: 0 }); + + render(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /post a job/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /post a job/i })); + + // Fill required fields: title and budget amount. + await user.type(screen.getByPlaceholderText(/build a solana/i), 'Test Job Title'); + await user.type(screen.getByPlaceholderText('500'), '100'); + } + + test('date-only input is normalized to RFC3339 end-of-day UTC before submit', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.jobsWrite.create).mockResolvedValue(sampleJob as any); + await openPostJobModal(user); + + // Use fireEvent to set the date input — userEvent doesn't drive date pickers. + // The modal has exactly one input[type="date"] (Proposal Deadline). + const { fireEvent } = await import('@testing-library/react'); + const dateInput = document.querySelector('input[type="date"]') as HTMLInputElement; + fireEvent.change(dateInput, { target: { value: '2099-12-31' } }); + + await user.click(screen.getByRole('button', { name: /post job/i })); + + await waitFor(() => { + expect(vi.mocked(apiClient.jobsWrite.create)).toHaveBeenCalledWith( + expect.objectContaining({ proposalDeadline: '2099-12-31T23:59:59Z' }) + ); + }); + }); + + test('empty deadline is omitted (sent as undefined)', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.jobsWrite.create).mockResolvedValue(sampleJob as any); + await openPostJobModal(user); + + // Leave the deadline blank and submit. + await user.click(screen.getByRole('button', { name: /post job/i })); + + await waitFor(() => { + expect(vi.mocked(apiClient.jobsWrite.create)).toHaveBeenCalledWith( + expect.objectContaining({ proposalDeadline: undefined }) + ); + }); + }); + + test('past deadline shows validation error and does not call create', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.jobsWrite.create).mockResolvedValue(sampleJob as any); + await openPostJobModal(user); + + const { fireEvent } = await import('@testing-library/react'); + const dateInput = document.querySelector('input[type="date"]') as HTMLInputElement; + fireEvent.change(dateInput, { target: { value: '2020-01-01' } }); + + await user.click(screen.getByRole('button', { name: /post job/i })); + + await waitFor(() => { + expect(screen.getByText(/proposal deadline must be in the future/i)).toBeInTheDocument(); + }); + expect(vi.mocked(apiClient.jobsWrite.create)).not.toHaveBeenCalled(); + }); + + test('current-day deadline shows validation error and does not call create', async () => { + const user = userEvent.setup(); + vi.mocked(apiClient.jobsWrite.create).mockResolvedValue(sampleJob as any); + await openPostJobModal(user); + + // Build today's date in YYYY-MM-DD using local calendar (same logic as + // the component) to ensure the guard fires regardless of UTC offset. + const now = new Date(); + const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + + const { fireEvent } = await import('@testing-library/react'); + const dateInput = document.querySelector('input[type="date"]') as HTMLInputElement; + fireEvent.change(dateInput, { target: { value: today } }); + + await user.click(screen.getByRole('button', { name: /post job/i })); + + await waitFor(() => { + expect(screen.getByText(/proposal deadline must be in the future/i)).toBeInTheDocument(); + }); + expect(vi.mocked(apiClient.jobsWrite.create)).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/agentworld/pages/JobsSection.tsx b/app/src/agentworld/pages/JobsSection.tsx index a7386f4bf..73206f6e8 100644 --- a/app/src/agentworld/pages/JobsSection.tsx +++ b/app/src/agentworld/pages/JobsSection.tsx @@ -25,6 +25,7 @@ import { type Proposal, type ProposalCreateParams, } from '../../lib/agentworld/invokeApiClient'; +import { useT } from '../../lib/i18n/I18nContext'; import { fetchWalletStatus } from '../../services/walletApi'; import { apiClient } from '../AgentWorldShell'; import { explorerTxUrl } from '../hooks/useX402Buy'; @@ -187,6 +188,7 @@ function ClientAvatar({ avatarUrl, displayName }: { avatarUrl?: string; displayN // ── PostJobModal ────────────────────────────────────────────────────────────── function PostJobModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) { + const { t } = useT(); const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); const [category, setCategory] = useState(''); @@ -202,6 +204,22 @@ function PostJobModal({ onClose, onCreated }: { onClose: () => void; onCreated: if (!title.trim() || !budgetAmount.trim()) return; setSubmitting(true); setError(null); + // yields "YYYY-MM-DD" but the server requires RFC3339. + // Pin to end-of-day UTC, consistent with BountiesSection. + const deadlineDate = proposalDeadline.trim(); + const deadlineIso = deadlineDate ? `${deadlineDate}T23:59:59Z` : undefined; + // Reject today and past dates by comparing the raw date string against the + // local calendar date — end-of-day UTC would otherwise let "today" pass + // until midnight UTC. + if (deadlineDate) { + const now = new Date(); + const todayLocal = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + if (deadlineDate <= todayLocal) { + setError(t('agentWorld.jobs.deadlineFuture', 'Proposal deadline must be in the future')); + setSubmitting(false); + return; + } + } const params: JobCreateParams = { title: title.trim(), description: description.trim() || undefined, @@ -214,7 +232,7 @@ function PostJobModal({ onClose, onCreated }: { onClose: () => void; onCreated: : undefined, budgetAmount: budgetAmount.trim(), budgetAsset: budgetAsset.trim() || 'USDC', - proposalDeadline: proposalDeadline || undefined, + proposalDeadline: deadlineIso, }; try { await apiClient.jobsWrite.create(params); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index a2ed7ca88..b47eba485 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -52,6 +52,7 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'الملفات الشخصية', 'agentWorld.marketplace': 'السوق', 'agentWorld.messaging': 'الرسائل', + 'agentWorld.jobs.deadlineFuture': 'يجب أن يكون الموعد النهائي للمقترح في المستقبل', 'nav.avatarMenu.account': 'الحساب', 'nav.avatarMenu.billing': 'الفواتير', 'nav.avatarMenu.rewards': 'المكافآت', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 4ec047b81..5fbb3944c 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -52,6 +52,7 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'প্রোফাইল', 'agentWorld.marketplace': 'মার্কেটপ্লেস', 'agentWorld.messaging': 'বার্তা', + 'agentWorld.jobs.deadlineFuture': 'প্রস্তাবের সময়সীমা ভবিষ্যতে হতে হবে', 'nav.avatarMenu.account': 'অ্যাকাউন্ট', 'nav.avatarMenu.billing': 'বিলিং', 'nav.avatarMenu.rewards': 'পুরস্কার', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 9a2da8787..48efc27a1 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -52,6 +52,7 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'Profile', 'agentWorld.marketplace': 'Marktplatz', 'agentWorld.messaging': 'Nachrichten', + 'agentWorld.jobs.deadlineFuture': 'Die Angebotsfrist muss in der Zukunft liegen', 'nav.avatarMenu.account': 'Konto', 'nav.avatarMenu.billing': 'Abrechnung', 'nav.avatarMenu.rewards': 'Prämien', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index d411a8281..c3ba97cde 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -37,6 +37,7 @@ const en: TranslationMap = { 'agentWorld.profiles': 'Profiles', 'agentWorld.marketplace': 'Marketplace', 'agentWorld.messaging': 'Messages', + 'agentWorld.jobs.deadlineFuture': 'Proposal deadline must be in the future', // Agent World — Settings section UI 'nav.avatarMenu.account': 'Account', 'nav.avatarMenu.billing': 'Billing', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 9ec576577..6b5f30cca 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -52,6 +52,7 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'Perfiles', 'agentWorld.marketplace': 'Mercado', 'agentWorld.messaging': 'Mensajes', + 'agentWorld.jobs.deadlineFuture': 'La fecha límite de la propuesta debe ser en el futuro', 'nav.avatarMenu.account': 'Cuenta', 'nav.avatarMenu.billing': 'Facturación', 'nav.avatarMenu.rewards': 'Recompensas', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index a37147247..1d359d320 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -52,6 +52,7 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'Profils', 'agentWorld.marketplace': 'Marché', 'agentWorld.messaging': 'Messages', + 'agentWorld.jobs.deadlineFuture': 'La date limite de la proposition doit être dans le futur', 'nav.avatarMenu.account': 'Compte', 'nav.avatarMenu.billing': 'Facturation', 'nav.avatarMenu.rewards': 'Récompenses', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 1c8ed7fa1..d8dca6440 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -52,6 +52,7 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'प्रोफ़ाइल', 'agentWorld.marketplace': 'मार्केटप्लेस', 'agentWorld.messaging': 'संदेश', + 'agentWorld.jobs.deadlineFuture': 'प्रस्ताव की समय सीमा भविष्य में होनी चाहिए', 'nav.avatarMenu.account': 'खाता', 'nav.avatarMenu.billing': 'बिलिंग', 'nav.avatarMenu.rewards': 'रिवॉर्ड', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 365d58c1a..de6e6081e 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -52,6 +52,7 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'Profil', 'agentWorld.marketplace': 'Pasar', 'agentWorld.messaging': 'Pesan', + 'agentWorld.jobs.deadlineFuture': 'Batas waktu proposal harus di masa depan', 'nav.avatarMenu.account': 'Akun', 'nav.avatarMenu.billing': 'Tagihan', 'nav.avatarMenu.rewards': 'Hadiah', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index af50a803e..826aece0e 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -52,6 +52,7 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'Profili', 'agentWorld.marketplace': 'Mercato', 'agentWorld.messaging': 'Messaggi', + 'agentWorld.jobs.deadlineFuture': 'La scadenza della proposta deve essere nel futuro', 'nav.avatarMenu.account': 'Account', 'nav.avatarMenu.billing': 'Fatturazione', 'nav.avatarMenu.rewards': 'Premi', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 3290d9aaa..a14baef54 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -52,6 +52,7 @@ const messages: TranslationMap = { 'agentWorld.profiles': '프로필', 'agentWorld.marketplace': '마켓플레이스', 'agentWorld.messaging': '메시지', + 'agentWorld.jobs.deadlineFuture': '제안 마감일은 미래여야 합니다', 'nav.avatarMenu.account': '계정', 'nav.avatarMenu.billing': '결제', 'nav.avatarMenu.rewards': '보상', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 2e46a3ed3..e0a81c719 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -52,6 +52,7 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'Profile', 'agentWorld.marketplace': 'Rynek', 'agentWorld.messaging': 'Wiadomości', + 'agentWorld.jobs.deadlineFuture': 'Termin złożenia oferty musi być w przyszłości', 'nav.avatarMenu.account': 'Konto', 'nav.avatarMenu.billing': 'Rozliczenia', 'nav.avatarMenu.rewards': 'Nagrody', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index d9aa1b4fd..abde792f9 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -52,6 +52,7 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'Perfis', 'agentWorld.marketplace': 'Mercado', 'agentWorld.messaging': 'Mensagens', + 'agentWorld.jobs.deadlineFuture': 'O prazo da proposta deve ser no futuro', 'nav.avatarMenu.account': 'Conta', 'nav.avatarMenu.billing': 'Faturamento', 'nav.avatarMenu.rewards': 'Recompensas', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 9d6564065..aa882e246 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -52,6 +52,7 @@ const messages: TranslationMap = { 'agentWorld.profiles': 'Профили', 'agentWorld.marketplace': 'Маркетплейс', 'agentWorld.messaging': 'Сообщения', + 'agentWorld.jobs.deadlineFuture': 'Срок подачи предложения должен быть в будущем', 'nav.avatarMenu.account': 'Аккаунт', 'nav.avatarMenu.billing': 'Оплата', 'nav.avatarMenu.rewards': 'Награды', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 42137506a..6241d23f8 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -52,6 +52,7 @@ const messages: TranslationMap = { 'agentWorld.profiles': '档案', 'agentWorld.marketplace': '市场', 'agentWorld.messaging': '消息', + 'agentWorld.jobs.deadlineFuture': '提案截止日期必须是将来的日期', 'nav.avatarMenu.account': '账户', 'nav.avatarMenu.billing': '账单', 'nav.avatarMenu.rewards': '奖励',