-
+ {/* Header */}
+
+
@@ -151,7 +183,11 @@ function RecruiterPage(): ReactElement {
);
}
-RecruiterPage.getLayout = getLayout;
+const GetPageLayout = (page: ReactNode): ReactNode => {
+ return getLayout(page);
+};
+
+RecruiterPage.getLayout = GetPageLayout;
export async function getServerSideProps() {
return { props: {} };
diff --git a/packages/webapp/pages/recruiter/[opportunityId]/edit.tsx b/packages/webapp/pages/recruiter/[opportunityId]/edit.tsx
new file mode 100644
index 0000000000..2274c214bb
--- /dev/null
+++ b/packages/webapp/pages/recruiter/[opportunityId]/edit.tsx
@@ -0,0 +1,499 @@
+import type { ReactElement, ReactNode } from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
+import { useRouter } from 'next/router';
+import { FormProvider, useWatch } from 'react-hook-form';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import classNames from 'classnames';
+import type {
+ ApiErrorResult,
+ ApiResponseError,
+ ApiZodErrorExtension,
+} from '@dailydotdev/shared/src/graphql/common';
+import { gqlClient, ApiError } from '@dailydotdev/shared/src/graphql/common';
+import { EDIT_OPPORTUNITY_MUTATION } from '@dailydotdev/shared/src/features/opportunity/graphql';
+import { updateOpportunityStateOptions } from '@dailydotdev/shared/src/features/opportunity/mutations';
+import { OpportunityState } from '@dailydotdev/shared/src/features/opportunity/protobuf/opportunity';
+import { usePrompt } from '@dailydotdev/shared/src/hooks/usePrompt';
+import { labels } from '@dailydotdev/shared/src/lib/labels';
+import { webappUrl } from '@dailydotdev/shared/src/lib/constants';
+import type {
+ Opportunity,
+ ContentSection as ContentSectionType,
+} from '@dailydotdev/shared/src/features/opportunity/types';
+import {
+ generateQueryKey,
+ RequestKey,
+} from '@dailydotdev/shared/src/lib/query';
+import {
+ OpportunityEditProvider,
+ useOpportunityEditContext,
+} from '@dailydotdev/shared/src/components/opportunity/OpportunityEditContext';
+import { opportunityByIdOptions } from '@dailydotdev/shared/src/features/opportunity/queries';
+import {
+ useOpportunityEditForm,
+ formDataToPreviewOpportunity,
+ formDataToMutationPayload,
+ useScrollSync,
+ getOpportunityStateLabel,
+ getOpportunityStateBadgeClass,
+} from '@dailydotdev/shared/src/components/opportunity/SideBySideEdit';
+import type {
+ OpportunitySideBySideEditFormData,
+ ScrollSyncSection,
+} from '@dailydotdev/shared/src/components/opportunity/SideBySideEdit';
+import { useLazyModal } from '@dailydotdev/shared/src/hooks/useLazyModal';
+import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/types';
+import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNotification';
+import { useExitConfirmation } from '@dailydotdev/shared/src/hooks/useExitConfirmation';
+import { Loader } from '@dailydotdev/shared/src/components/Loader';
+import {
+ useViewSize,
+ ViewSize,
+} from '@dailydotdev/shared/src/hooks/useViewSize';
+import {
+ Typography,
+ TypographyType,
+ TypographyColor,
+} from '@dailydotdev/shared/src/components/typography/Typography';
+import {
+ Button,
+ ButtonVariant,
+ ButtonSize,
+ ButtonColor,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import { RefreshIcon } from '@dailydotdev/shared/src/components/icons';
+import { IconSize } from '@dailydotdev/shared/src/components/Icon';
+import { OpportunityCompletenessBar } from '@dailydotdev/shared/src/components/opportunity/OpportunityCompletenessBar';
+import { OpportunityEditPanel } from '@dailydotdev/shared/src/components/opportunity/SideBySideEdit/OpportunityEditPanel';
+import {
+ EditPreviewTabs,
+ EditPreviewTab,
+} from '@dailydotdev/shared/src/components/opportunity/SideBySideEdit/EditPreviewTabs';
+import { BrowserPreviewFrame } from '@dailydotdev/shared/src/components/opportunity/SideBySideEdit/BrowserPreviewFrame';
+import HeaderLogo from '@dailydotdev/shared/src/components/layout/HeaderLogo';
+import { getLayout } from '../../../components/layouts/RecruiterFullscreenLayout';
+import JobPage from '../../jobs/[id]';
+
+function EditPageContent(): ReactElement {
+ const { opportunityId } = useOpportunityEditContext();
+ const { openModal } = useLazyModal();
+ const { displayToast } = useToastNotification();
+ const { showPrompt } = usePrompt();
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const isLaptop = useViewSize(ViewSize.Laptop);
+ const [activeTab, setActiveTab] = useState
(
+ EditPreviewTab.Edit,
+ );
+ const [expandedSections, setExpandedSections] = useState<
+ Set
+ >(new Set());
+
+ const { data: opportunity, isLoading } = useQuery(
+ opportunityByIdOptions({ id: opportunityId }),
+ );
+
+ // Save mutation for unified form data
+ const { mutateAsync: saveOpportunity, isPending: isSaving } = useMutation({
+ mutationFn: async (payload: ReturnType) =>
+ gqlClient.request<{ editOpportunity: Opportunity }>(
+ EDIT_OPPORTUNITY_MUTATION,
+ {
+ id: opportunityId,
+ payload,
+ },
+ ),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: generateQueryKey(RequestKey.Opportunity, null, opportunityId),
+ });
+ },
+ });
+
+ const onSuccess = useCallback(async () => {
+ await router.push(`${webappUrl}recruiter/${opportunityId}/matches`);
+ }, [router, opportunityId]);
+
+ const onValidationError = useCallback(
+ async ({
+ issues,
+ }: {
+ issues: Array<{ path: PropertyKey[]; message: string }>;
+ }) => {
+ await showPrompt({
+ title: labels.opportunity.requiredMissingNotice.title,
+ description: (
+
+
{labels.opportunity.requiredMissingNotice.description}
+
+ {issues.map((issue) => {
+ const path = issue.path.join('.');
+ return • {issue.message} ;
+ })}
+
+
+ ),
+ okButton: {
+ className: '!w-full',
+ title: labels.opportunity.requiredMissingNotice.okButton,
+ },
+ cancelButton: null,
+ });
+ },
+ [showPrompt],
+ );
+
+ const {
+ mutateAsync: updateOpportunityState,
+ isPending: isPendingOpportunityState,
+ } = useMutation({
+ ...updateOpportunityStateOptions(),
+ onSuccess,
+ onError: async (error: ApiErrorResult) => {
+ if (
+ error.response?.errors?.[0]?.extensions?.code ===
+ ApiError.PaymentRequired
+ ) {
+ if (opportunity?.organization?.recruiterTotalSeats > 0) {
+ displayToast('You need more seats to publish this job.');
+
+ openModal({
+ type: LazyModal.RecruiterSeats,
+ props: {
+ opportunityId,
+ onNext: async () => {
+ await showPrompt({
+ title: labels.opportunity.assignSeat.title,
+ description: labels.opportunity.assignSeat.description,
+ okButton: {
+ title: labels.opportunity.assignSeat.okButton,
+ },
+ cancelButton: {
+ title: labels.opportunity.assignSeat.cancelButton,
+ },
+ });
+
+ await updateOpportunityState({
+ id: opportunityId,
+ state: OpportunityState.IN_REVIEW,
+ });
+
+ await onSuccess();
+ },
+ },
+ });
+ } else {
+ await router.push(`${webappUrl}recruiter/${opportunityId}/plans`);
+ }
+
+ return;
+ }
+
+ if (
+ error.response?.errors?.[0]?.extensions?.code ===
+ ApiError.ZodValidationError
+ ) {
+ const apiError = error.response
+ .errors[0] as ApiResponseError;
+
+ await onValidationError({
+ issues: apiError.extensions.issues,
+ });
+
+ return;
+ }
+
+ displayToast(
+ error.response?.errors?.[0]?.message || labels.error.generic,
+ );
+ },
+ });
+
+ // Initialize form with opportunity data
+ const { form, isDirty } = useOpportunityEditForm({
+ opportunity,
+ });
+
+ // Watch form values for real-time preview
+ const formValues = useWatch({
+ control: form.control,
+ }) as OpportunitySideBySideEditFormData;
+
+ // Convert form data to preview opportunity for real-time updates
+ const previewData = useMemo(() => {
+ if (!formValues) {
+ return undefined;
+ }
+ return formDataToPreviewOpportunity(formValues);
+ }, [formValues]);
+
+ // Scroll sync between edit panel and preview
+ const { scrollToSection } = useScrollSync({
+ offset: 20,
+ behavior: 'smooth',
+ });
+
+ const handleSectionFocus = useCallback(
+ (sectionId: string) => {
+ scrollToSection(sectionId as ScrollSyncSection);
+ // Also expand the section in the preview (without closing others)
+ setExpandedSections((prev) => {
+ const next = new Set(prev);
+ next.add(sectionId as ContentSectionType);
+ return next;
+ });
+ },
+ [scrollToSection],
+ );
+
+ // Exit confirmation when navigating away with unsaved changes
+ useExitConfirmation({
+ message: 'You have unsaved changes. Leave anyway?',
+ onValidateAction: useCallback(() => !isDirty, [isDirty]),
+ });
+
+ // Save handler with mutation
+ const handleSave = useCallback(async () => {
+ const isValid = await form.trigger();
+ if (!isValid) {
+ displayToast('Please complete all required fields');
+ return;
+ }
+
+ try {
+ const formData = form.getValues();
+ const payload = formDataToMutationPayload(formData);
+ await saveOpportunity(payload);
+
+ form.reset(formData); // Reset dirty state after successful save
+
+ // Update opportunity state to IN_REVIEW and redirect
+ await updateOpportunityState({
+ id: opportunityId,
+ state: OpportunityState.IN_REVIEW,
+ });
+ } catch (error) {
+ displayToast('Failed to save changes. Please try again.');
+ }
+ }, [
+ form,
+ displayToast,
+ saveOpportunity,
+ updateOpportunityState,
+ opportunityId,
+ ]);
+
+ const handleUpdateFromUrl = useCallback(() => {
+ openModal({
+ type: LazyModal.OpportunityReimport,
+ props: {
+ opportunityId,
+ },
+ });
+ }, [openModal, opportunityId]);
+
+ const getSaveButtonText = () => {
+ if (isSaving || isPendingOpportunityState) {
+ return 'Submitting...';
+ }
+ return 'Submit for review';
+ };
+
+ const isSaveDisabled = isSaving || isPendingOpportunityState;
+
+ if (isLoading || !opportunity) {
+ return (
+
+
+
+ );
+ }
+
+ // Desktop: side-by-side layout
+ if (isLaptop) {
+ return (
+
+
+ {/* Header */}
+
+
+ {/* Main content */}
+
+ {/* Edit Panel - 1/3 width */}
+
+
+
+
+
+ {/* Preview Panel - 2/3 width - uses existing JobPage */}
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // Mobile: tabbed layout
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+ {form.watch('title') || opportunity.title || 'Untitled'}
+
+
+
+ {getOpportunityStateLabel(opportunity.state)}
+
+
+
+
+ {getSaveButtonText()}
+
+
+
+ {/* Tab switcher */}
+
+
+ {/* Content */}
+
+ {activeTab === EditPreviewTab.Edit ? (
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ );
+}
+
+function EditPage(): ReactElement {
+ return ;
+}
+
+const GetPageLayout = (page: ReactNode): ReactNode => {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const router = useRouter();
+ const { opportunityId } = router.query;
+
+ return (
+
+ {getLayout(page)}
+
+ );
+};
+
+EditPage.getLayout = GetPageLayout;
+
+export default EditPage;
diff --git a/packages/webapp/pages/recruiter/[opportunityId]/payment.tsx b/packages/webapp/pages/recruiter/[opportunityId]/payment.tsx
index e93af2d165..5e0268250c 100644
--- a/packages/webapp/pages/recruiter/[opportunityId]/payment.tsx
+++ b/packages/webapp/pages/recruiter/[opportunityId]/payment.tsx
@@ -103,7 +103,7 @@ const RecruiterPaymentPage = (): ReactElement => {
/>
-
+
(
+ EditPreviewTab.Edit,
+ );
- const { data: opportunity } = useQuery(
+ const { data: opportunity, isLoading } = useQuery(
opportunityByIdOptions({ id: opportunityId }),
);
@@ -50,13 +97,89 @@ function PreparePage(): ReactElement {
}),
);
+ // Save mutation for unified form data
+ const { mutateAsync: saveOpportunity, isPending: isSaving } = useMutation({
+ mutationFn: async (payload: ReturnType) =>
+ gqlClient.request<{ editOpportunity: Opportunity }>(
+ EDIT_OPPORTUNITY_MUTATION,
+ {
+ id: opportunityId,
+ payload,
+ },
+ ),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: generateQueryKey(RequestKey.Opportunity, null, opportunityId),
+ });
+ },
+ });
+
+ // Initialize form with opportunity data
+ const { form, isDirty } = useOpportunityEditForm({
+ opportunity,
+ });
+
+ // Watch form values for real-time preview
+ const formValues = useWatch({
+ control: form.control,
+ }) as OpportunitySideBySideEditFormData;
+
+ // Convert form data to preview opportunity for real-time updates
+ const previewData = useMemo(() => {
+ if (!formValues) {
+ return undefined;
+ }
+ return formDataToPreviewOpportunity(formValues);
+ }, [formValues]);
+
+ // Scroll sync between edit panel and preview
+ const { scrollToSection } = useScrollSync({
+ offset: 20,
+ behavior: 'smooth',
+ });
+
+ const handleSectionFocus = useCallback(
+ (sectionId: string) => {
+ scrollToSection(sectionId as ScrollSyncSection);
+ },
+ [scrollToSection],
+ );
+
+ // Exit confirmation when navigating away with unsaved changes
+ useExitConfirmation({
+ message: 'You have unsaved changes. Leave anyway?',
+ onValidateAction: useCallback(() => !isDirty, [isDirty]),
+ });
+
+ // Save handler
+ const handleSave = useCallback(async () => {
+ const isValid = await form.trigger();
+ if (!isValid) {
+ displayToast('Please complete all required fields');
+ return false;
+ }
+
+ try {
+ const formData = form.getValues();
+ const payload = formDataToMutationPayload(formData);
+ await saveOpportunity(payload);
+
+ displayToast('Changes saved');
+ form.reset(formData);
+ return true;
+ } catch (error) {
+ displayToast('Failed to save changes. Please try again.');
+ return false;
+ }
+ }, [form, displayToast, saveOpportunity]);
+
const goToNextStep = async () => {
await router.push(`${webappUrl}recruiter/${opportunityId}/questions`);
};
const {
mutate: onSubmit,
- isPending,
+ isPending: isSubmitting,
isSuccess,
} = useMutation({
...recommendOpportunityScreeningQuestionsOptions(),
@@ -66,7 +189,7 @@ function PreparePage(): ReactElement {
}
displayToast(
- 'Just a momment, generating screening questions for your job....',
+ 'Just a moment, generating screening questions for your job....',
{
subject: ToastSubject.OpportunityScreeningQuestions,
timer: 10_000,
@@ -101,7 +224,56 @@ function PreparePage(): ReactElement {
},
});
- if (isCheckingPayment) {
+ const handleNextStep = useCallback(async () => {
+ // First save any pending changes
+ if (isDirty) {
+ const saved = await handleSave();
+ if (!saved) {
+ return;
+ }
+ }
+
+ // Validate the opportunity data
+ const result = onValidateOpportunity({
+ schema: opportunityEditStep1Schema,
+ });
+
+ if (result.error) {
+ await showPrompt({
+ title: labels.opportunity.requiredMissingNotice.title,
+ description: (
+
+
{labels.opportunity.requiredMissingNotice.description}
+
+ {result.error.issues.map((issue) => (
+ • {issue.message}
+ ))}
+
+
+ ),
+ okButton: {
+ className: '!w-full',
+ title: labels.opportunity.requiredMissingNotice.okButton,
+ },
+ cancelButton: null,
+ });
+
+ return;
+ }
+
+ onSubmit({
+ id: opportunity.id,
+ });
+ }, [
+ isDirty,
+ handleSave,
+ onValidateOpportunity,
+ showPrompt,
+ onSubmit,
+ opportunity?.id,
+ ]);
+
+ if (isCheckingPayment || isLoading || !opportunity) {
return (
@@ -109,72 +281,175 @@ function PreparePage(): ReactElement {
);
}
+ // Desktop: side-by-side layout
+ if (isLaptop) {
+ return (
+
+
+ {/* Header */}
+
+
+
+
+ {/* Main content */}
+
+ {/* Edit Panel - 1/3 width */}
+
+
+
+
+
+ {/* Preview Panel - 2/3 width */}
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // Mobile: tabbed layout
return (
-
-
{
- const result = onValidateOpportunity({
- schema: opportunityEditStep1Schema,
- });
-
- if (result.error) {
- await showPrompt({
- title: labels.opportunity.requiredMissingNotice.title,
- description: (
-
-
- {labels.opportunity.requiredMissingNotice.description}
-
-
- {result.error.issues.map((issue) => (
- • {issue.message}
- ))}
-
-
- ),
- okButton: {
- className: '!w-full',
- title: labels.opportunity.requiredMissingNotice.okButton,
- },
- cancelButton: null,
- });
-
- return;
- }
-
- onSubmit({
- id: opportunity.id,
- });
- },
- loading: isPending || isSuccess,
- }}
- />
-
-
-
+
+ {/* Header */}
+
+
+
+
+ {/* Tab switcher */}
+
+
+ {/* Content */}
+
+ {activeTab === EditPreviewTab.Edit ? (
+
+
+
+
+ ) : (
+
+
+
+ )}
+
-
+
);
}
-const GetPageLayout: typeof getLayout = (page, layoutProps) => {
+function PreparePage(): ReactElement {
+ return ;
+}
+
+const GetPageLayout = (page: ReactNode): ReactNode => {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
const router = useRouter();
const { opportunityId } = router.query;
return (
-
- {getLayout(page, layoutProps)}
+
+ {getLayout(page)}
);
};
+
PreparePage.getLayout = GetPageLayout;
export default PreparePage;
diff --git a/packages/webapp/pages/recruiter/[opportunityId]/questions.tsx b/packages/webapp/pages/recruiter/[opportunityId]/questions.tsx
index 4447d4b874..94521ee26d 100644
--- a/packages/webapp/pages/recruiter/[opportunityId]/questions.tsx
+++ b/packages/webapp/pages/recruiter/[opportunityId]/questions.tsx
@@ -1,7 +1,6 @@
import React from 'react';
-import type { ReactElement } from 'react';
+import type { ReactElement, ReactNode } from 'react';
-import type { NextSeoProps } from 'next-seo';
import { useMutation, useQuery } from '@tanstack/react-query';
import {
Typography,
@@ -10,7 +9,7 @@ import {
TypographyType,
} from '@dailydotdev/shared/src/components/typography/Typography';
-import { PlusIcon } from '@dailydotdev/shared/src/components/icons';
+import { MoveToIcon, PlusIcon } from '@dailydotdev/shared/src/components/icons';
import { useRouter } from 'next/router';
import { opportunityByIdOptions } from '@dailydotdev/shared/src/features/opportunity/queries';
@@ -28,8 +27,11 @@ import {
ButtonVariant,
} from '@dailydotdev/shared/src/components/buttons/common';
import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/types';
-import { Button } from '@dailydotdev/shared/src/components/buttons/Button';
-import { RecruiterHeader } from '@dailydotdev/shared/src/components/recruiter/Header';
+import {
+ Button,
+ ButtonColor,
+} from '@dailydotdev/shared/src/components/buttons/Button';
+import HeaderLogo from '@dailydotdev/shared/src/components/layout/HeaderLogo';
import { opportunityEditStep2Schema } from '@dailydotdev/shared/src/lib/schema/opportunity';
import { usePrompt } from '@dailydotdev/shared/src/hooks/usePrompt';
import { labels } from '@dailydotdev/shared/src/lib/labels';
@@ -49,20 +51,7 @@ import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNoti
import { OpportunityState } from '@dailydotdev/shared/src/features/opportunity/protobuf/opportunity';
import { useRequirePayment } from '@dailydotdev/shared/src/features/opportunity/hooks/useRequirePayment';
import { Loader } from '@dailydotdev/shared/src/components/Loader';
-import { getLayout } from '../../../components/layouts/RecruiterSelfServeLayout';
-import {
- defaultOpenGraph,
- defaultSeo,
- defaultSeoTitle,
-} from '../../../next-seo';
-
-const seo: NextSeoProps = {
- title: defaultSeoTitle,
- openGraph: { ...defaultOpenGraph },
- ...defaultSeo,
- nofollow: true,
- noindex: true,
-};
+import { getLayout } from '../../../components/layouts/RecruiterFullscreenLayout';
const QuestionsSetupPage = (): ReactElement => {
const { isLoggedIn, isAuthReady } = useAuthContext();
@@ -190,46 +179,56 @@ const QuestionsSetupPage = (): ReactElement => {
return ;
}
+ const handleNextStep = async () => {
+ const result = onValidateOpportunity({
+ schema: opportunityEditStep2Schema,
+ });
+
+ if (result.error) {
+ await onValidationError({ issues: result.error.issues });
+
+ return;
+ }
+
+ await updateOpportunityState({
+ id: opportunity.id,
+ state: OpportunityState.IN_REVIEW,
+ });
+ };
+
return (
-
{
- const result = onValidateOpportunity({
- schema: opportunityEditStep2Schema,
- });
-
- if (result.error) {
- await onValidationError({ issues: result.error.issues });
-
- return;
- }
-
- await updateOpportunityState({
- id: opportunity.id,
- state: OpportunityState.IN_REVIEW,
- });
- },
- disabled: isPendingOpportunityState,
- }}
- />
+ {/* Header */}
+
+
-
-
- Screening Questions
-
-
- AI generated questions to help you quickly assess candidate fit
- before moving forward. You can edit, replace, or remove them to
- match your hiring needs
-
-
{opportunity.questions.map((question, index) => {
@@ -314,19 +313,22 @@ const QuestionsSetupPage = (): ReactElement => {
);
};
-const GetPageLayout: typeof getLayout = (page, layoutProps) => {
+const GetPageLayout = (page: ReactNode): ReactNode => {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
const router = useRouter();
const { opportunityId } = router.query;
return (
- {getLayout(page, {
- ...layoutProps,
- seo,
- })}
+ {getLayout(page)}
);
};
+
QuestionsSetupPage.getLayout = GetPageLayout;
+export async function getServerSideProps() {
+ return { props: {} };
+}
+
export default QuestionsSetupPage;
diff --git a/packages/webapp/pages/recruiter/review/index.tsx b/packages/webapp/pages/recruiter/review/index.tsx
index 2a0a7a5ab2..529532b6d1 100644
--- a/packages/webapp/pages/recruiter/review/index.tsx
+++ b/packages/webapp/pages/recruiter/review/index.tsx
@@ -112,7 +112,7 @@ const ReviewListPage = (): ReactElement => {
Job Review Queue
- Review and approve job descriptions before they go live
+ Review and approve job postings before they go live