diff --git a/AGENTS.md b/AGENTS.md index f5168427af..a333fc0fd2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,6 +146,8 @@ pnpm --filter webapp build # Build webapp pnpm --filter extension build:chrome # Build Chrome extension ``` +**IMPORTANT**: Do NOT run `build` commands while the dev server is running - it will break hot reload. Only run builds at the end to verify your work compiles successfully. During development, rely on the dev server's hot reload and TypeScript/ESLint checks instead. + ## Where Should I Put This Code? ``` diff --git a/packages/shared/src/components/RecruiterLayout.tsx b/packages/shared/src/components/RecruiterLayout.tsx index e7196037dc..5d609dbaf1 100644 --- a/packages/shared/src/components/RecruiterLayout.tsx +++ b/packages/shared/src/components/RecruiterLayout.tsx @@ -82,7 +82,7 @@ export const RecruiterLayout = ({ {children} diff --git a/packages/shared/src/components/accordion/index.tsx b/packages/shared/src/components/accordion/index.tsx index 6be6230752..0b943f9a69 100644 --- a/packages/shared/src/components/accordion/index.tsx +++ b/packages/shared/src/components/accordion/index.tsx @@ -16,6 +16,8 @@ interface AccordionProps { title: ReactNode; children: ReactNode; initiallyOpen?: boolean; + isOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; onClick?: MouseEventHandler; className?: { button?: string; @@ -69,13 +71,18 @@ export function Accordion({ children, onClick, initiallyOpen = false, + isOpen: controlledIsOpen, + onOpenChange, className, disabled = false, }: AccordionProps): ReactElement { - const [isOpen, setIsOpen] = useState(initiallyOpen); + const [internalIsOpen, setInternalIsOpen] = useState(initiallyOpen); const id = useId(); const contentId = `accordion-content-${id}`; + // Use controlled state if provided, otherwise use internal state + const isControlled = controlledIsOpen !== undefined; + const isOpen = isControlled ? controlledIsOpen : internalIsOpen; const isOpenAndEnabled = isOpen && !disabled; const handleClick: MouseEventHandler = (e) => { @@ -85,7 +92,11 @@ export function Accordion({ onClick?.(e); - setIsOpen((prev) => !prev); + const newIsOpen = !isOpen; + if (!isControlled) { + setInternalIsOpen(newIsOpen); + } + onOpenChange?.(newIsOpen); }; return ( diff --git a/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx b/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx index 18987eebdd..d1be7f9a6e 100644 --- a/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx +++ b/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx @@ -13,6 +13,8 @@ import { ItalicIcon, BulletListIcon, NumberedListIcon, + UndoIcon, + RedoIcon, } from '../../icons'; import { LinkIcon } from '../../icons/Link'; import { Tooltip } from '../../tooltip/Tooltip'; @@ -32,6 +34,7 @@ interface ToolbarButtonProps { icon: ReactElement; isActive: boolean; onClick: () => void; + disabled?: boolean; } const ToolbarButton = ({ @@ -39,18 +42,25 @@ const ToolbarButton = ({ icon, isActive, onClick, -}: ToolbarButtonProps): ReactElement => ( - - + ); + })} + + ); +} + +export default EditPreviewTabs; diff --git a/packages/shared/src/components/opportunity/SideBySideEdit/OpportunityEditPanel.tsx b/packages/shared/src/components/opportunity/SideBySideEdit/OpportunityEditPanel.tsx new file mode 100644 index 0000000000..6db897e15a --- /dev/null +++ b/packages/shared/src/components/opportunity/SideBySideEdit/OpportunityEditPanel.tsx @@ -0,0 +1,204 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../../typography/Typography'; +import { ArrowIcon } from '../../icons'; +import { IconSize } from '../../Icon'; +import type { Opportunity } from '../../../features/opportunity/types'; +import { RoleInfoSection } from './sections/RoleInfoSection'; +import { JobDetailsSection } from './sections/JobDetailsSection'; +import { ContentSection } from './sections/ContentSection'; +import { LinkedProfileSection } from './sections/LinkedProfileSection'; + +export interface OpportunityEditPanelProps { + opportunity: Opportunity; + onSectionFocus?: (sectionId: string) => void; + className?: string; +} + +interface CollapsibleSectionProps { + id: string; + title: string; + required?: boolean; + children: ReactNode; + defaultExpanded?: boolean; + onFocus?: () => void; +} + +function CollapsibleSection({ + id, + title, + required = false, + children, + defaultExpanded = true, + onFocus, +}: CollapsibleSectionProps): ReactElement { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + const handleClick = () => { + const willExpand = !isExpanded; + setIsExpanded(willExpand); + if (willExpand && onFocus) { + onFocus(); + } + }; + + return ( +
+ + {isExpanded && ( +
+ {children} +
+ )} +
+ ); +} + +// Content section configuration for config-driven rendering +type ContentSectionConfig = { + id: + | 'overview' + | 'responsibilities' + | 'requirements' + | 'whatYoullDo' + | 'interviewProcess'; + title: string; + required: boolean; + getDefaultExpanded?: (opportunity: Opportunity) => boolean; +}; + +const contentSections: ContentSectionConfig[] = [ + { id: 'overview', title: 'Overview', required: true }, + { id: 'responsibilities', title: 'Responsibilities', required: true }, + { id: 'requirements', title: 'Requirements', required: true }, + { + id: 'whatYoullDo', + title: "What You'll Do", + required: false, + getDefaultExpanded: (opp) => !!opp?.content?.whatYoullDo?.html, + }, + { + id: 'interviewProcess', + title: 'Interview Process', + required: false, + getDefaultExpanded: (opp) => !!opp?.content?.interviewProcess?.html, + }, +]; + +export function OpportunityEditPanel({ + opportunity, + onSectionFocus, + className, +}: OpportunityEditPanelProps): ReactElement { + const company = opportunity?.organization; + const recruiter = opportunity?.recruiters?.[0]; + + return ( +
+
+ + Edit Job Posting + + + Changes are auto-saved locally. Click Save to publish. + +
+ +
+ {/* Role Info section with form inputs */} + onSectionFocus?.('roleInfo')} + > +
+ + +
+
+ + {/* Content sections - config-driven */} + {contentSections.map((section) => ( + onSectionFocus?.(section.id)} + > + onSectionFocus?.(section.id)} + /> + + ))} + + {/* Linked profiles - flat list without collapsible wrapper */} +
+ + Linked profiles + + + +
+
+
+ ); +} + +export default OpportunityEditPanel; diff --git a/packages/shared/src/components/opportunity/SideBySideEdit/hooks/index.ts b/packages/shared/src/components/opportunity/SideBySideEdit/hooks/index.ts new file mode 100644 index 0000000000..f176117680 --- /dev/null +++ b/packages/shared/src/components/opportunity/SideBySideEdit/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useOpportunityEditForm'; +export * from './useScrollSync'; diff --git a/packages/shared/src/components/opportunity/SideBySideEdit/hooks/useOpportunityEditForm.ts b/packages/shared/src/components/opportunity/SideBySideEdit/hooks/useOpportunityEditForm.ts new file mode 100644 index 0000000000..1c1f07c8ab --- /dev/null +++ b/packages/shared/src/components/opportunity/SideBySideEdit/hooks/useOpportunityEditForm.ts @@ -0,0 +1,245 @@ +import { useForm } from 'react-hook-form'; +import type { UseFormReturn } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useCallback, useEffect } from 'react'; +import type { Opportunity } from '../../../../features/opportunity/types'; +import { OpportunityState } from '../../../../features/opportunity/protobuf/opportunity'; +import { + opportunityEditInfoSchema, + createOpportunityEditContentSchema, +} from '../../../../lib/schema/opportunity'; + +export function getOpportunityStateLabel(state: OpportunityState): string { + switch (state) { + case OpportunityState.DRAFT: + return 'DRAFT'; + case OpportunityState.LIVE: + return 'LIVE'; + case OpportunityState.CLOSED: + return 'CLOSED'; + case OpportunityState.IN_REVIEW: + return 'IN REVIEW'; + default: + return 'DRAFT'; + } +} + +export function getOpportunityStateBadgeClass(state: OpportunityState): string { + switch (state) { + case OpportunityState.DRAFT: + return 'bg-status-warning text-white'; + case OpportunityState.LIVE: + return 'bg-status-success text-white'; + case OpportunityState.CLOSED: + return 'bg-text-disabled text-white'; + case OpportunityState.IN_REVIEW: + return 'bg-status-info text-white'; + default: + return 'bg-status-warning text-white'; + } +} + +export const opportunitySideBySideEditSchema = opportunityEditInfoSchema.extend( + { + content: z.object({ + overview: createOpportunityEditContentSchema({ + errorLabel: 'Overview is required', + }), + responsibilities: createOpportunityEditContentSchema({ + errorLabel: 'Responsibilities are required', + }), + requirements: createOpportunityEditContentSchema({ + errorLabel: 'Requirements are required', + }), + whatYoullDo: createOpportunityEditContentSchema({ optional: true }), + interviewProcess: createOpportunityEditContentSchema({ optional: true }), + }), + }, +); + +export type OpportunitySideBySideEditFormData = z.infer< + typeof opportunitySideBySideEditSchema +>; + +export function opportunityToFormData( + opportunity: Opportunity | undefined, +): OpportunitySideBySideEditFormData | undefined { + if (!opportunity) { + return undefined; + } + + return { + title: opportunity.title || '', + tldr: opportunity.tldr || '', + keywords: opportunity.keywords?.map((k) => ({ keyword: k.keyword })) || [], + externalLocationId: opportunity.locations?.[0]?.location?.city || undefined, + locationType: opportunity.locations?.[0]?.type, + meta: { + employmentType: opportunity.meta?.employmentType ?? 0, + teamSize: opportunity.meta?.teamSize ?? 1, + salary: { + min: opportunity.meta?.salary?.min, + max: opportunity.meta?.salary?.max, + period: opportunity.meta?.salary?.period ?? 0, + }, + seniorityLevel: opportunity.meta?.seniorityLevel ?? 0, + roleType: opportunity.meta?.roleType ?? 0.5, + }, + content: { + overview: { + content: opportunity.content?.overview?.content || '', + }, + responsibilities: { + content: opportunity.content?.responsibilities?.content || '', + }, + requirements: { + content: opportunity.content?.requirements?.content || '', + }, + whatYoullDo: { + content: opportunity.content?.whatYoullDo?.content || '', + }, + interviewProcess: { + content: opportunity.content?.interviewProcess?.content || '', + }, + }, + }; +} + +export function formDataToPreviewOpportunity( + formData: Partial, +): Partial { + return { + title: formData.title, + tldr: formData.tldr, + keywords: formData.keywords, + locations: formData.locationType + ? [{ type: formData.locationType, location: null }] + : undefined, + meta: formData.meta + ? { + employmentType: formData.meta.employmentType, + teamSize: formData.meta.teamSize, + salary: formData.meta.salary + ? { + min: formData.meta.salary.min, + max: formData.meta.salary.max, + period: formData.meta.salary.period, + } + : undefined, + seniorityLevel: formData.meta.seniorityLevel, + roleType: formData.meta.roleType, + } + : undefined, + content: formData.content + ? { + overview: { + content: formData.content.overview?.content || '', + html: formData.content.overview?.content || '', + }, + responsibilities: { + content: formData.content.responsibilities?.content || '', + html: formData.content.responsibilities?.content || '', + }, + requirements: { + content: formData.content.requirements?.content || '', + html: formData.content.requirements?.content || '', + }, + whatYoullDo: { + content: formData.content.whatYoullDo?.content || '', + html: formData.content.whatYoullDo?.content || '', + }, + interviewProcess: { + content: formData.content.interviewProcess?.content || '', + html: formData.content.interviewProcess?.content || '', + }, + } + : undefined, + }; +} + +export function formDataToMutationPayload( + formData: OpportunitySideBySideEditFormData, +) { + return { + title: formData.title, + tldr: formData.tldr, + keywords: formData.keywords, + meta: { + employmentType: formData.meta.employmentType, + teamSize: formData.meta.teamSize, + salary: formData.meta.salary + ? { + min: formData.meta.salary.min, + max: formData.meta.salary.max, + period: formData.meta.salary.period, + } + : undefined, + seniorityLevel: formData.meta.seniorityLevel, + roleType: formData.meta.roleType, + }, + content: { + overview: { content: formData.content.overview?.content || '' }, + responsibilities: { + content: formData.content.responsibilities?.content || '', + }, + requirements: { content: formData.content.requirements?.content || '' }, + whatYoullDo: formData.content.whatYoullDo?.content + ? { content: formData.content.whatYoullDo.content } + : undefined, + interviewProcess: formData.content.interviewProcess?.content + ? { content: formData.content.interviewProcess.content } + : undefined, + }, + }; +} + +export interface UseOpportunityEditFormOptions { + opportunity?: Opportunity; + draftData?: OpportunitySideBySideEditFormData; +} + +export interface UseOpportunityEditFormReturn { + form: UseFormReturn; + resetToOpportunity: () => void; + isDirty: boolean; +} + +export function useOpportunityEditForm({ + opportunity, + draftData, +}: UseOpportunityEditFormOptions): UseOpportunityEditFormReturn { + const opportunityFormData = opportunityToFormData(opportunity); + const defaultValues = draftData || opportunityFormData; + + const form = useForm({ + resolver: zodResolver(opportunitySideBySideEditSchema), + defaultValues, + mode: 'onChange', + }); + + // Reset form when opportunity data changes (e.g., after reimport) + useEffect(() => { + if (opportunity && !draftData) { + // Compute form data inside effect to avoid stale closure + const freshFormData = opportunityToFormData(opportunity); + if (freshFormData) { + form.reset(freshFormData); + } + } + }, [opportunity, draftData, form]); + + const resetToOpportunity = useCallback(() => { + if (opportunityFormData) { + form.reset(opportunityFormData); + } + }, [form, opportunityFormData]); + + return { + form, + resetToOpportunity, + isDirty: form.formState.isDirty, + }; +} + +export default useOpportunityEditForm; diff --git a/packages/shared/src/components/opportunity/SideBySideEdit/hooks/useScrollSync.ts b/packages/shared/src/components/opportunity/SideBySideEdit/hooks/useScrollSync.ts new file mode 100644 index 0000000000..f4b783c9cb --- /dev/null +++ b/packages/shared/src/components/opportunity/SideBySideEdit/hooks/useScrollSync.ts @@ -0,0 +1,87 @@ +import { useCallback, useRef } from 'react'; + +export type ScrollSyncSection = + | 'roleInfo' + | 'overview' + | 'responsibilities' + | 'requirements' + | 'whatYoullDo' + | 'interviewProcess' + | 'company' + | 'recruiter'; + +export interface UseScrollSyncOptions { + containerSelector?: string; + offset?: number; + behavior?: ScrollBehavior; +} + +export interface UseScrollSyncReturn { + scrollToSection: (section: ScrollSyncSection) => void; + handleSectionFocus: (section: ScrollSyncSection) => () => void; +} + +export function useScrollSync({ + containerSelector, + offset = 20, + behavior = 'smooth', +}: UseScrollSyncOptions = {}): UseScrollSyncReturn { + const lastScrolledRef = useRef(null); + + const scrollToSection = useCallback( + (section: ScrollSyncSection) => { + if (lastScrolledRef.current === section) { + return; + } + + const targetId = `job-preview-${section}`; + const targetElement = document.getElementById(targetId); + + if (!targetElement) { + return; + } + + let container: Element | null = null; + if (containerSelector) { + container = document.querySelector(containerSelector); + } + + if (container) { + const containerRect = container.getBoundingClientRect(); + const targetRect = targetElement.getBoundingClientRect(); + const scrollTop = + container.scrollTop + (targetRect.top - containerRect.top) - offset; + + container.scrollTo({ + top: scrollTop, + behavior, + }); + } else { + targetElement.scrollIntoView({ + behavior, + block: 'start', + }); + } + + lastScrolledRef.current = section; + setTimeout(() => { + lastScrolledRef.current = null; + }, 500); + }, + [containerSelector, offset, behavior], + ); + + const handleSectionFocus = useCallback( + (section: ScrollSyncSection) => () => { + scrollToSection(section); + }, + [scrollToSection], + ); + + return { + scrollToSection, + handleSectionFocus, + }; +} + +export default useScrollSync; diff --git a/packages/shared/src/components/opportunity/SideBySideEdit/index.ts b/packages/shared/src/components/opportunity/SideBySideEdit/index.ts new file mode 100644 index 0000000000..a5f0e32d88 --- /dev/null +++ b/packages/shared/src/components/opportunity/SideBySideEdit/index.ts @@ -0,0 +1,4 @@ +export * from './EditPreviewTabs'; +export * from './OpportunityEditPanel'; +export * from './BrowserPreviewFrame'; +export * from './hooks'; diff --git a/packages/shared/src/components/opportunity/SideBySideEdit/sections/ContentSection.tsx b/packages/shared/src/components/opportunity/SideBySideEdit/sections/ContentSection.tsx new file mode 100644 index 0000000000..3e3716e84f --- /dev/null +++ b/packages/shared/src/components/opportunity/SideBySideEdit/sections/ContentSection.tsx @@ -0,0 +1,99 @@ +import type { ReactElement } from 'react'; +import React, { useRef, useEffect } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import dynamic from 'next/dynamic'; +import classNames from 'classnames'; +import type { RichTextRef } from '../../../fields/RichTextEditor'; +import { labels } from '../../../../lib'; +import type { ContentSection as ContentSectionType } from '../../../../features/opportunity/types'; +import { Loader } from '../../../Loader'; +import type { OpportunitySideBySideEditFormData } from '../hooks/useOpportunityEditForm'; + +const RichTextEditor = dynamic( + () => + import( + /* webpackChunkName: "richTextEditor" */ '../../../fields/RichTextEditor' + ), + { + ssr: false, + loading: () => , + }, +); + +export interface ContentSectionProps { + section: ContentSectionType; + placeholder?: string; + onFocus?: () => void; +} + +export function ContentSection({ + section, + placeholder, + onFocus, +}: ContentSectionProps): ReactElement { + const richTextRef = useRef(null); + const { + control, + formState: { errors }, + watch, + } = useFormContext(); + + const currentValue = watch(`content.${section}.content`); + + useEffect(() => { + if ( + richTextRef.current && + typeof richTextRef.current.setContent === 'function' + ) { + const editorContent = richTextRef.current.getHTML?.() || ''; + if (currentValue !== editorContent) { + richTextRef.current.setContent(currentValue || ''); + } + } + }, [currentValue]); + + const fieldError = errors.content?.[section]?.content; + const hint = fieldError?.message as string | undefined; + const valid = !fieldError; + + return ( +
+ ( + { + field.onChange(value); + }} + onFocus={onFocus} + className={{ + container: 'flex-1 !rounded-none !border-0', + }} + /> + )} + /> + {!!hint && ( +
+ {hint} +
+ )} +
+ ); +} + +export default ContentSection; diff --git a/packages/shared/src/components/opportunity/SideBySideEdit/sections/JobDetailsSection.tsx b/packages/shared/src/components/opportunity/SideBySideEdit/sections/JobDetailsSection.tsx new file mode 100644 index 0000000000..db5399afdd --- /dev/null +++ b/packages/shared/src/components/opportunity/SideBySideEdit/sections/JobDetailsSection.tsx @@ -0,0 +1,202 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import classNames from 'classnames'; +import { TextField } from '../../../fields/TextField'; +import { Radio } from '../../../fields/Radio'; +import { Dropdown } from '../../../fields/Dropdown'; +import { Typography, TypographyType } from '../../../typography/Typography'; +import type { OpportunitySideBySideEditFormData } from '../hooks/useOpportunityEditForm'; + +const salaryPeriodOptions = ['Annually', 'Monthly', 'Hourly']; + +const seniorityOptions = [ + 'Intern', + 'Junior', + 'Mid', + 'Senior', + 'Lead', + 'Manager', + 'Director', + 'VP', + 'C-Level', +]; + +const roleTypeOptions = [ + { value: 0, title: 'IC' }, + { value: 0.5, title: 'Auto' }, + { value: 1, title: 'Management' }, +]; + +export function JobDetailsSection(): ReactElement { + const { + register, + control, + formState: { errors }, + } = useFormContext(); + + return ( +
+
+ + Employment type* + + ( + field.onChange(Number(value))} + valid={!errors.meta?.employmentType} + /> + )} + /> + {errors.meta?.employmentType && ( + + {errors.meta.employmentType.message as string} + + )} +
+ +
+ + Seniority level* + + ( + { + const valueIndex = seniorityOptions.indexOf(value); + field.onChange(valueIndex + 1); + }} + valid={!errors.meta?.seniorityLevel} + hint={errors.meta?.seniorityLevel?.message as string} + /> + )} + /> +
+ +
+ + Job type* + + ( + option.value === field.value, + )} + options={roleTypeOptions.map((option) => option.title)} + onChange={(value) => { + const item = roleTypeOptions.find( + (option) => option.title === value, + ); + field.onChange(item?.value ?? 0); + }} + valid={!errors.meta?.roleType} + hint={errors.meta?.roleType?.message as string} + /> + )} + /> +
+ + + +
+ + Salary range (USD) + + {!!errors.meta?.salary && ( +
+ {errors.meta.salary.message as string} +
+ )} +
+ + + ( + { + const valueIndex = salaryPeriodOptions.indexOf(value); + field.onChange(valueIndex + 1); + }} + valid={!errors.meta?.salary?.period} + hint={errors.meta?.salary?.period?.message as string} + /> + )} + /> +
+
+
+ ); +} + +export default JobDetailsSection; diff --git a/packages/shared/src/components/opportunity/SideBySideEdit/sections/LinkedProfileSection.tsx b/packages/shared/src/components/opportunity/SideBySideEdit/sections/LinkedProfileSection.tsx new file mode 100644 index 0000000000..25e1f9cd39 --- /dev/null +++ b/packages/shared/src/components/opportunity/SideBySideEdit/sections/LinkedProfileSection.tsx @@ -0,0 +1,113 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../typography/Typography'; +import { EditIcon } from '../../../icons'; +import { IconSize } from '../../../Icon'; +import Link from '../../../utilities/Link'; + +export interface LinkedProfileSectionProps { + type: 'company' | 'recruiter'; + name?: string; + image?: string; + subtitle?: string; + editUrl: string; + emptyMessage: string; +} + +export function LinkedProfileSection({ + type, + name, + image, + subtitle, + editUrl, + emptyMessage, +}: LinkedProfileSectionProps): ReactElement { + const hasData = !!name; + + if (!hasData) { + return ( + + {emptyMessage} + + ); + } + + const avatarBorderRadius = type === 'company' ? 'rounded-8' : 'rounded-full'; + + const renderSubtitle = (): ReactNode => { + if (!subtitle) { + return null; + } + + return ( + <> + · + + {subtitle} + + + ); + }; + + return ( + + + {image ? ( + {name} + ) : ( +
+ + {name?.charAt(0)?.toUpperCase()} + +
+ )} + +
+ + {name} + + {renderSubtitle()} +
+ + +
+ + ); +} + +export default LinkedProfileSection; diff --git a/packages/shared/src/components/opportunity/SideBySideEdit/sections/RoleInfoSection.tsx b/packages/shared/src/components/opportunity/SideBySideEdit/sections/RoleInfoSection.tsx new file mode 100644 index 0000000000..aed172ca82 --- /dev/null +++ b/packages/shared/src/components/opportunity/SideBySideEdit/sections/RoleInfoSection.tsx @@ -0,0 +1,109 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { TextField } from '../../../fields/TextField'; +import Textarea from '../../../fields/Textarea'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../typography/Typography'; +import { KeywordSelection } from '../../../../features/opportunity/components/KeywordSelection'; +import ProfileLocation from '../../../profile/ProfileLocation'; +import { LocationDataset } from '../../../../graphql/autocomplete'; +import type { Opportunity } from '../../../../features/opportunity/types'; +import type { OpportunitySideBySideEditFormData } from '../hooks/useOpportunityEditForm'; + +export interface RoleInfoSectionProps { + opportunity: Opportunity; +} + +export function RoleInfoSection({ + opportunity, +}: RoleInfoSectionProps): ReactElement { + const { + register, + control, + formState: { errors }, + } = useFormContext(); + + return ( +
+ + +