diff --git a/src/app/(app)/me/project/new/components/back-button.tsx b/src/app/(app)/me/project/new/components/back-button.tsx new file mode 100644 index 0000000..64508b2 --- /dev/null +++ b/src/app/(app)/me/project/new/components/back-button.tsx @@ -0,0 +1,37 @@ +'use client' + +import { useBoundStore } from '@/store' + +import { Button } from '@/components/ui/button' +import { useRouter } from 'next/navigation' +import { HTMLAttributes } from 'react' + +type BackButtonProps = HTMLAttributes + +export function BackButton({ ...props }: BackButtonProps) { + const { newProjectFormSteps } = useBoundStore(({ newProjectFormSteps }) => ({ + newProjectFormSteps, + })) + + const router = useRouter() + + function handleBack() { + router.back() + } + + return ( + + ) +} diff --git a/src/app/(app)/me/project/new/components/input-tracker.tsx b/src/app/(app)/me/project/new/components/input-tracker.tsx new file mode 100644 index 0000000..5b2bd30 --- /dev/null +++ b/src/app/(app)/me/project/new/components/input-tracker.tsx @@ -0,0 +1,116 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +'use client' + +import { HTMLAttributes, MouseEvent, useEffect, useRef } from 'react' +import { useTrackSelectedStep } from '../contexts/track-selected-step-context' +import { twMerge } from 'tailwind-merge' + +type InputTrackerProps = HTMLAttributes + +export function InputTracker({ ...props }: InputTrackerProps) { + const { handleSetCurrentTarget } = useTrackSelectedStep() + const containerRef = useRef(null) + + function handleMouseEnter(event: MouseEvent) { + const currentTarget = event.currentTarget + + const firstInputChild = currentTarget.getElementsByTagName('input')[0] + const hasInputChildren = !!firstInputChild + + if (!hasInputChildren) { + const hasEditorContainer = + currentTarget.getElementsByClassName('prose')[0] + + if (hasEditorContainer) { + return handleSetCurrentTarget(hasEditorContainer.id) + } + + const divContainer = currentTarget.getElementsByTagName('div') + + return Array.from({ length: divContainer.length }).forEach((_, index) => { + const hasIdIdentifierOnDiv = divContainer.item(index)?.id + + if (hasIdIdentifierOnDiv) { + return handleSetCurrentTarget(hasIdIdentifierOnDiv) + } + }) + } + + const isInputFile = firstInputChild.type === 'file' + + if (isInputFile) { + return handleSetCurrentTarget(`${firstInputChild.id}-container`) + } + + handleSetCurrentTarget(firstInputChild.id) + } + + useEffect(() => { + const container = containerRef.current + + if (!container) { + return + } + + const selectedInput = container.getElementsByTagName('button')[0] + + const hasSelectChildren = !!selectedInput + + if (hasSelectChildren) { + selectedInput.addEventListener('focus', () => { + handleSetCurrentTarget(selectedInput.id) + }) + + return () => + selectedInput.removeEventListener('focus', () => { + handleSetCurrentTarget(selectedInput.id) + }) + } + + const firstInputChild = container.getElementsByTagName('input')[0] + const hasInputChildren = !!firstInputChild + + if (!hasInputChildren) { + return + } + + const isInputFile = firstInputChild.type === 'file' + + if (isInputFile) { + const dropzoneContainer = container + .getElementsByTagName('div') + .namedItem(`${firstInputChild.id}-container`) + + if (!dropzoneContainer) { + return + } + + dropzoneContainer.addEventListener('focus', () => { + handleSetCurrentTarget(`${firstInputChild.id}-container`) + }) + + return () => + dropzoneContainer.removeEventListener('focus', () => { + handleSetCurrentTarget(`${firstInputChild.id}-container`) + }) + } + + firstInputChild.addEventListener('focus', () => { + handleSetCurrentTarget(firstInputChild.id) + }) + + return () => + firstInputChild.removeEventListener('focus', () => { + handleSetCurrentTarget(firstInputChild.id) + }) + }, []) + + return ( +
handleMouseEnter(event)} + {...props} + /> + ) +} diff --git a/src/app/(app)/me/project/new/components/sidebar-navigation.tsx b/src/app/(app)/me/project/new/components/sidebar-navigation.tsx new file mode 100644 index 0000000..3e0a7d1 --- /dev/null +++ b/src/app/(app)/me/project/new/components/sidebar-navigation.tsx @@ -0,0 +1,186 @@ +'use client' + +import { MouseEvent } from 'react' +import { motion } from 'framer-motion' +import { useTrackSelectedStep } from '../contexts/track-selected-step-context' +import { usePathname } from 'next/navigation' +import { parseCookies } from 'nookies' + +import Link from 'next/link' +import { Card, CardContent } from '@/components/ui/card' +import { NEW_PROJECT_COOKIES_ID } from '../layout' + +const FORM_STEPS = [ + { + formHref: 'cover', + formStep: { + title: 'Capa do projeto', + }, + steps: [ + { + href: 'banner-container', + name: 'Banner', + }, + { + href: 'avatar-container', + name: 'Avatar', + }, + { + href: 'name', + name: 'Nome do projeto', + }, + { + href: 'availability', + name: 'Disponibilidade', + }, + ], + }, + { + formHref: 'description', + formStep: { + title: 'Informações do projeto', + }, + steps: [ + { + href: 'description', + name: 'Descrição do projeto', + }, + { + href: 'skills', + name: 'Habilidades', + }, + ], + }, + { + formHref: 'job', + formStep: { + title: 'Informações das vagas', + }, + steps: [ + { + href: 'roles', + name: 'Vagas disponíveis', + }, + { + href: 'role-description', + name: 'Descrição da função', + }, + ], + }, +] + +export function SidebarNavigation() { + const { currentTarget, handleSetCurrentTarget } = useTrackSelectedStep() + const pathname = usePathname() + const cookiesStore = parseCookies() + + const pathnameSlip = pathname.split('/') + const currentForm = pathnameSlip[pathnameSlip.length - 1] + + const formIdCookiesStore = cookiesStore[NEW_PROJECT_COOKIES_ID] + + let formIdCookies: { + [key: string]: string + cover: string + description: string + } | null = null + + if (formIdCookiesStore) { + formIdCookies = JSON.parse(formIdCookiesStore) + } + + function handleFocusElement( + event: MouseEvent, + elementHref: string, + ) { + event.preventDefault() + + const inputOrDivElement = window.document.getElementById(elementHref) + + if (!inputOrDivElement) { + return + } + + if (inputOrDivElement?.tagName.toLowerCase() === 'div') { + const tiptapEditorContainer = + inputOrDivElement?.getElementsByClassName('tiptap') + + if (tiptapEditorContainer.length) { + const divContainer = tiptapEditorContainer?.item(0) as HTMLDivElement + + divContainer.focus() + } + + const divDropzoneContainer = inputOrDivElement + + divDropzoneContainer.focus() + return handleSetCurrentTarget(elementHref) + } + + handleSetCurrentTarget(elementHref) + const inputElement = inputOrDivElement as HTMLInputElement + + inputElement.focus() + } + + return ( + + ) +} diff --git a/src/app/(app)/me/project/new/contexts/track-selected-step-context.tsx b/src/app/(app)/me/project/new/contexts/track-selected-step-context.tsx new file mode 100644 index 0000000..ab115ae --- /dev/null +++ b/src/app/(app)/me/project/new/contexts/track-selected-step-context.tsx @@ -0,0 +1,40 @@ +'use client' + +import { createContext, useContext, useState } from 'react' + +type TrackSelectedStepContextType = { + currentTarget: string + handleSetCurrentTarget: (target: string) => void +} + +const TrackSelectedStepContext = createContext( + {} as TrackSelectedStepContextType, +) + +export function TrackSelectedStepContextProvider({ + children, +}: { + children: React.ReactNode +}) { + const [currentTarget, setCurrentTarget] = useState('') + + function handleSetCurrentTarget(target: string) { + setCurrentTarget((state) => { + if (state === target) { + return state + } + + return target + }) + } + + return ( + + {children} + + ) +} + +export const useTrackSelectedStep = () => useContext(TrackSelectedStepContext) diff --git a/src/app/(app)/me/project/new/cover/components/cover-form.tsx b/src/app/(app)/me/project/new/cover/components/cover-form.tsx new file mode 100644 index 0000000..d0e1870 --- /dev/null +++ b/src/app/(app)/me/project/new/cover/components/cover-form.tsx @@ -0,0 +1,614 @@ +'use client' + +import { useEffect, useId } from 'react' +import { useRouter } from 'next/navigation' +import { z } from 'zod' +import { FormProvider, useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { useBoundStore } from '@/store' +import { useDropzone } from 'react-dropzone' +import { setCookie } from 'nookies' + +import { + InputControl, + InputMessageError, + InputRoot, +} from '@/components/ui/input' +import { InputTracker } from '../../components/input-tracker' +import { Label } from '@/components/ui/label' +import { Separator } from '@/components/ui/separator' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import Image from 'next/image' +import { MultiSelectInput } from './multi-select-input' + +import { Loader2, Plus, X } from 'lucide-react' +import { NEW_PROJECT_COOKIES_ID } from '../../layout' +import { api } from '@/libs/fetch-api' +import { DoubtBox } from '@/components/doubt-box' + +const coverFormInput = z.object({ + name: z + .string({ required_error: 'O nome do projeto é obrigatório.' }) + .min(2, { message: 'O nome do projeto deve ter no mínimo 2 caracteres.' }), + avatarUrl: z.string().optional(), + bannerUrl: z.string().optional(), + availableToParticipate: z.object({ + availableDays: z + .array(z.object({ value: z.string(), label: z.string() }), { + required_error: 'Selecione um dia da semana.', + }) + .min(1, { + message: 'Você deve selecionar pelo menos um dia da semana.', + }), + availableTime: z.object({ + value: z.coerce + .number({ + invalid_type_error: 'Você deve informar um número.', + required_error: 'Informe quantas horas são necessário.', + }) + .min(1, 'Você deve informar no mínimo 1 hora') + .max(9, 'Você deve informar no máximo 8 horas por dia'), + unit: z.enum(['hours']).default('hours'), + }), + }), +}) + +export type CoverFormInput = z.infer + +const WEEKDAYS = [ + { + label: 'Todos os dias', + value: 'all', + }, + { + label: 'Domingo', + value: '0', + }, + { + label: 'Segunda - feira', + value: '1', + }, + { + label: 'Terça - feira', + value: '2', + }, + { + label: 'Quarta - feira', + value: '3', + }, + { + label: 'Quinta - feira', + value: '4', + }, + { + label: 'Sexta - feira', + value: '5', + }, + { + label: 'Sábado', + value: '6', + }, +] + +export function CoverForm() { + const router = useRouter() + const formId = useId() + const { deleteWeekDatFromCover, newProjectFormSteps } = useBoundStore( + ({ deleteWeekDatFromCover, newProjectFormSteps }) => ({ + deleteWeekDatFromCover, + newProjectFormSteps, + }), + ) + + const form = useForm({ + resolver: zodResolver(coverFormInput), + defaultValues: { + name: useBoundStore.getState().newProjectFormSteps.cover.name, + bannerUrl: + useBoundStore.getState().newProjectFormSteps.cover.bannerUrl + ?.previewUrl, + avatarUrl: + useBoundStore.getState().newProjectFormSteps.cover.avatarUrl + ?.previewUrl, + availableToParticipate: { + availableDays: + useBoundStore.getState().newProjectFormSteps.cover + .availableToParticipate.availableDays, + availableTime: { + value: + useBoundStore.getState().newProjectFormSteps.cover + .availableToParticipate.availableTime.value, + unit: 'hours', + }, + }, + }, + }) + + const { + register, + handleSubmit, + setValue, + setError, + formState: { errors, isValid }, + } = form + + const { + getInputProps: getInputPropsBanner, + isDragActive: isDragActiveBanner, + getRootProps: getRootPropsBanner, + } = useDropzone({ + multiple: false, + accept: { + 'image/*': ['.png', '.jpg', '.jpeg', '.svg+xml'], + }, + maxSize: 10485760, + disabled: + newProjectFormSteps.cover.avatarUrlIsLoading || + newProjectFormSteps.cover.submitIsLoading, + onDrop: async (acceptedFiles, rejectedFiles) => { + const fileErrors = rejectedFiles[0]?.errors[0] + + if (fileErrors) { + switch (fileErrors.code) { + case 'file-too-large': + return setError('bannerUrl', { + message: 'Você deve enviar um arquivo menor que 10MB.', + }) + + case 'file-invalid-type': + return setError('bannerUrl', { + message: 'Este tipo de arquivo não é suportado.', + }) + default: + return + } + } + + useBoundStore.setState(({ newProjectFormSteps }) => ({ + newProjectFormSteps: { + ...newProjectFormSteps, + cover: { + ...newProjectFormSteps.cover, + bannerUrlIsLoading: true, + }, + }, + })) + + const previewUrl = URL.createObjectURL(acceptedFiles[0]) + + const response = await api('/uploads', { + method: 'POST', + body: JSON.stringify({ + fileContentType: 'jpeg', + uploadPrefix: 'projects', + }), + }) + + const { signedUrl, publicUrl } = await response.json() + + setValue('bannerUrl', previewUrl) + + useBoundStore.setState(({ newProjectFormSteps }) => ({ + newProjectFormSteps: { + ...newProjectFormSteps, + cover: { + ...newProjectFormSteps.cover, + bannerUrl: { + previewUrl, + file: acceptedFiles[0], + publicUrl, + signedUrl, + }, + bannerUrlIsLoading: false, + }, + }, + })) + }, + }) + + const { + getInputProps: getInputPropsAvatar, + isDragActive: isDragActiveAvatar, + getRootProps: getRootPropsAvatar, + } = useDropzone({ + multiple: false, + accept: { + 'image/*': ['.png', '.jpg', '.jpeg', '.svg+xml'], + }, + maxSize: 8388608, + disabled: + newProjectFormSteps.cover.avatarUrlIsLoading || + newProjectFormSteps.cover.submitIsLoading, + onDrop: async (acceptedFiles, rejectedFiles) => { + const fileErrors = rejectedFiles[0]?.errors[0] + + if (fileErrors) { + switch (fileErrors.code) { + case 'file-too-large': + return setError('avatarUrl', { + message: 'Você deve enviar um arquivo menor que 8MB.', + }) + + case 'file-invalid-type': + return setError('avatarUrl', { + message: 'Este tipo de arquivo não é suportado.', + }) + default: + return + } + } + + useBoundStore.setState(({ newProjectFormSteps }) => ({ + newProjectFormSteps: { + ...newProjectFormSteps, + cover: { + ...newProjectFormSteps.cover, + avatarUrlIsLoading: true, + }, + }, + })) + + const previewUrl = URL.createObjectURL(acceptedFiles[0]) + + const response = await api('/uploads', { + method: 'POST', + body: JSON.stringify({ + fileContentType: 'jpeg', + uploadPrefix: 'projects', + }), + }) + + const { signedUrl, publicUrl } = await response.json() + + setValue('avatarUrl', previewUrl) + + useBoundStore.setState(({ newProjectFormSteps }) => ({ + newProjectFormSteps: { + ...newProjectFormSteps, + cover: { + ...newProjectFormSteps.cover, + avatarUrl: { + previewUrl, + file: acceptedFiles[0], + publicUrl, + signedUrl, + }, + avatarUrlIsLoading: false, + }, + }, + })) + }, + }) + + function handleNextStep(data: CoverFormInput) { + useBoundStore.setState(({ newProjectFormSteps }) => ({ + newProjectFormSteps: { + ...newProjectFormSteps, + cover: { + ...newProjectFormSteps.cover, + name: data.name, + availableToParticipate: { + availableDays: data.availableToParticipate.availableDays, + availableTime: { + value: data.availableToParticipate.availableTime.value, + unit: 'hour', + }, + }, + submitIsLoading: true, + }, + }, + })) + + setCookie(null, NEW_PROJECT_COOKIES_ID, JSON.stringify({ cover: formId }), { + maxAge: 60 * 30, // 30 minutes + path: '/me/project/new', + }) + + router.push('/me/project/new/description') + } + + const allDaysOfWeekIsChecked = useBoundStore + .getState() + .newProjectFormSteps.cover.availableToParticipate.availableDays.some( + (day) => day.value === 'all', + ) + + const weekDaysSelected = allDaysOfWeekIsChecked + ? useBoundStore + .getState() + .newProjectFormSteps.cover.availableToParticipate.availableDays.filter( + (day) => day.value === 'all', + ) + : useBoundStore + .getState() + .newProjectFormSteps.cover.availableToParticipate.availableDays.sort( + (a, b) => Number(a.value) - Number(b.value), + ) + + useEffect(() => { + useBoundStore.setState(({ newProjectFormSteps }) => ({ + newProjectFormSteps: { + ...newProjectFormSteps, + cover: { + ...newProjectFormSteps.cover, + isValidToSubmit: isValid, + }, + }, + })) + }, [isValid]) + + useEffect(() => { + useBoundStore.setState(({ newProjectFormSteps }) => ({ + newProjectFormSteps: { + ...newProjectFormSteps, + cover: { + ...newProjectFormSteps.cover, + submitIsLoading: false, + }, + }, + })) + + const unloadCallback = (event: BeforeUnloadEvent) => { + event.preventDefault() + event.returnValue = '' + return '' + } + + window.addEventListener('beforeunload', unloadCallback) + return () => window.removeEventListener('beforeunload', unloadCallback) + }, []) + + return ( + +
+
+ + + + {newProjectFormSteps.cover.bannerUrl ? ( + + ) : ( + + )} + + + {errors.bannerUrl && ( + {errors.bannerUrl.message} + )} + + {newProjectFormSteps.cover.bannerUrl && ( + + )} + + + Resolução recomendada 739 x 136 pixels (tamanho máximo de 10MB). + +
+ + + + +
+ + +
+ {newProjectFormSteps.cover.avatarUrl ? ( +
+ avatar preview +
+ ) : ( +
+ {newProjectFormSteps.cover.avatarUrlIsLoading ? ( + + ) : ( + + )} + + +
+ )} + + {newProjectFormSteps.cover.avatarUrl && ( + + )} +
+ + {errors.avatarUrl && ( + {errors.avatarUrl.message} + )} + + + Resolução recomendada 112 x 112 pixels (tamanho máximo de 8MB). + +
+
+ + + + + + +
+ + + + + {errors.name && ( + {errors.name.message} + )} +
+
+ + + +
+ +
+ + + + Você deve informar até 8 horas por dia. + + +
+ +
+
+ + + {errors.availableToParticipate?.availableDays && ( + + {errors.availableToParticipate?.availableDays.message} + + )} +
+ +
+ + + + + {errors.availableToParticipate?.availableTime?.value && ( + + {errors.availableToParticipate?.availableTime.value.message} + + )} +
+
+ +
+ {weekDaysSelected.map((param) => { + return ( + + {param.label} + + + ) + })} +
+
+
+ +
+ ) +} diff --git a/src/app/(app)/me/project/new/cover/components/multi-select-input.tsx b/src/app/(app)/me/project/new/cover/components/multi-select-input.tsx new file mode 100644 index 0000000..f3acd15 --- /dev/null +++ b/src/app/(app)/me/project/new/cover/components/multi-select-input.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useBoundStore } from '@/store' + +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' + +import { ChevronDown } from 'lucide-react' + +import { useFormContext } from 'react-hook-form' +import { CoverFormInput } from './cover-form' + +import { Command as CommandPrimitive } from 'cmdk' + +interface SelectInputFilterProps { + name: 'week-days' + placeholder: string + emptyMessage: string + params: Array<{ + value: string + label: string + }> +} + +export function MultiSelectInput({ + emptyMessage, + params, + placeholder, +}: SelectInputFilterProps) { + const { setValue, clearErrors } = useFormContext() + + const { newProjectFormSteps, toggleWeekDaysFromCover } = useBoundStore( + ({ newProjectFormSteps, toggleWeekDaysFromCover }) => ({ + newProjectFormSteps, + toggleWeekDaysFromCover, + }), + ) + + function toggleParam(value: string, label: string) { + clearErrors('availableToParticipate.availableDays') + + const availableDayGrantThanZero = + newProjectFormSteps.cover.availableToParticipate.availableDays.length > 0 + const availableDayLessThanOrEqualSeven = + newProjectFormSteps.cover.availableToParticipate.availableDays.length <= 7 + + const isAllDaysOption = value === 'all' + + const allDaysIsChecked = + newProjectFormSteps.cover.availableToParticipate.availableDays.some( + (day) => day.value === 'all', + ) + + if (isAllDaysOption) { + if (availableDayGrantThanZero && availableDayLessThanOrEqualSeven) { + useBoundStore.setState(({ newProjectFormSteps }) => ({ + newProjectFormSteps: { + ...newProjectFormSteps, + cover: { + ...newProjectFormSteps.cover, + availableToParticipate: { + ...newProjectFormSteps.cover.availableToParticipate, + availableDays: [], + }, + }, + }, + })) + } + + params.forEach((day) => { + toggleWeekDaysFromCover({ + label: day.label, + value: day.value, + }) + }) + + return setValue( + 'availableToParticipate.availableDays', + useBoundStore.getState().newProjectFormSteps.cover + .availableToParticipate.availableDays, + ) + } + + if (allDaysIsChecked && !isAllDaysOption) { + useBoundStore.setState(({ newProjectFormSteps }) => ({ + newProjectFormSteps: { + ...newProjectFormSteps, + cover: { + ...newProjectFormSteps.cover, + availableToParticipate: { + ...newProjectFormSteps.cover.availableToParticipate, + availableDays: + newProjectFormSteps.cover.availableToParticipate.availableDays.filter( + (day) => day.value !== 'all', + ), + }, + }, + }, + })) + } + + toggleWeekDaysFromCover({ label, value }) + setValue( + 'availableToParticipate.availableDays', + useBoundStore.getState().newProjectFormSteps.cover.availableToParticipate + .availableDays, + ) + } + + return ( + + + + + + + + + + + {emptyMessage} + + {params.map((param) => { + const isSelected = + newProjectFormSteps.cover.availableToParticipate.availableDays.some( + (day) => day.value === param.value, + ) + + return ( + toggleParam(value, param.label)} + > +
+ + {param.label} + + ) + })} + + + + + ) +} diff --git a/src/app/(app)/me/project/new/cover/components/submit-button.tsx b/src/app/(app)/me/project/new/cover/components/submit-button.tsx new file mode 100644 index 0000000..52f2bfd --- /dev/null +++ b/src/app/(app)/me/project/new/cover/components/submit-button.tsx @@ -0,0 +1,33 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { useBoundStore } from '@/store' +import { Loader2 } from 'lucide-react' +import { ButtonHTMLAttributes } from 'react' + +type SubmitButtonProps = ButtonHTMLAttributes + +export function SubmitButton(props: SubmitButtonProps) { + const { newProjectFormSteps } = useBoundStore(({ newProjectFormSteps }) => ({ + newProjectFormSteps, + })) + + return ( + + ) +} diff --git a/src/app/(app)/me/project/new/cover/page.tsx b/src/app/(app)/me/project/new/cover/page.tsx new file mode 100644 index 0000000..cd89e44 --- /dev/null +++ b/src/app/(app)/me/project/new/cover/page.tsx @@ -0,0 +1,17 @@ +import { Card, CardContent } from '@/components/ui/card' +import { CoverForm } from './components/cover-form' +import { SubmitButton } from './components/submit-button' + +export default function CoverProject() { + return ( +
+ + + + + + + Avançar +
+ ) +} diff --git a/src/app/(app)/me/project/new/description/components/description-form.tsx b/src/app/(app)/me/project/new/description/components/description-form.tsx new file mode 100644 index 0000000..1fb64b1 --- /dev/null +++ b/src/app/(app)/me/project/new/description/components/description-form.tsx @@ -0,0 +1,165 @@ +'use client' + +import { useEffect, useId } from 'react' +import { z } from 'zod' +import { FormProvider, useForm } from 'react-hook-form' +import { useBoundStore } from '@/store' +import { zodResolver } from '@hookform/resolvers/zod' + +import { InputTracker } from '../../components/input-tracker' +import { Label } from '@/components/ui/label' +import { Separator } from '@/components/ui/separator' +import { DescriptionTextArea } from './description-text-area' +import { SkillsInput } from './skills-input' +import { useRouter } from 'next/navigation' +import { destroyCookie, parseCookies, setCookie } from 'nookies' +import { NEW_PROJECT_COOKIES_ID } from '../../layout' +import { DoubtBox } from '@/components/doubt-box' + +const descriptionFormInput = z.object({ + description: z + .string({ + required_error: 'Você deve inserir uma descrição para o projeto.', + }) + .max(1200, { message: 'Limite máximo de 1200 caracteres.' }) + .min(10, { message: 'Limite mínimo de 10 caracteres.' }), + skills: z + .array(z.string(), { required_error: 'Selecione uma habilidade.' }) + .min(1, { message: 'Você deve selecionar pelo menos uma habilidade.' }), +}) + +export type DescriptionFormInput = z.infer + +export function DescriptionForm() { + const formId = useId() + const router = useRouter() + const form = useForm({ + resolver: zodResolver(descriptionFormInput), + defaultValues: { + description: + useBoundStore.getState().newProjectFormSteps.description + .projectDescription, + skills: + useBoundStore.getState().newProjectFormSteps.description.skills ?? [], + }, + }) + const { newProjectFormSteps } = useBoundStore(({ newProjectFormSteps }) => ({ + newProjectFormSteps, + })) + + const { + handleSubmit, + formState: { isValid }, + } = form + + function handleNextStep(data: DescriptionFormInput) { + useBoundStore.setState(({ newProjectFormSteps }) => ({ + newProjectFormSteps: { + ...newProjectFormSteps, + description: { + ...newProjectFormSteps.description, + projectDescription: data.description, + skills: data.skills, + submitIsLoading: true, + }, + }, + })) + + const cookiesStore = parseCookies() + const newProjectFormId = cookiesStore[NEW_PROJECT_COOKIES_ID] + + const newProjectFormIdParsed = JSON.parse(newProjectFormId) + + setCookie( + null, + NEW_PROJECT_COOKIES_ID, + JSON.stringify({ ...newProjectFormIdParsed, description: formId }), + { + maxAge: 60 * 30, // 30 minutes + path: '/me/project/new', + }, + ) + + router.push('/me/project/new/job') + } + + useEffect(() => { + useBoundStore.setState(({ newProjectFormSteps }) => ({ + newProjectFormSteps: { + ...newProjectFormSteps, + description: { + ...newProjectFormSteps.description, + isValidToSubmit: isValid, + }, + }, + })) + }, [isValid]) + + useEffect(() => { + useBoundStore.setState(({ newProjectFormSteps }) => ({ + newProjectFormSteps: { + ...newProjectFormSteps, + description: { + ...newProjectFormSteps.description, + submitIsLoading: false, + }, + }, + })) + + if (!newProjectFormSteps.cover.isValidToSubmit) { + destroyCookie(null, NEW_PROJECT_COOKIES_ID) + + return router.push('/me/project/new/cover') + } + + const unloadCallback = (event: BeforeUnloadEvent) => { + event.preventDefault() + event.returnValue = '' + + return '' + } + + window.addEventListener('beforeunload', unloadCallback) + return () => window.removeEventListener('beforeunload', unloadCallback) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + +
+ + + + + + + + + +
+ + + + O que são as habilidades? + + + + Aqui você pode adicionar as habilidades gerais que a pessoa deve + possuir independente da função que ira exercer, desde hard a + softskills. + + +
+ + +
+ +
+ ) +} diff --git a/src/app/(app)/me/project/new/description/components/description-text-area.tsx b/src/app/(app)/me/project/new/description/components/description-text-area.tsx new file mode 100644 index 0000000..e89a678 --- /dev/null +++ b/src/app/(app)/me/project/new/description/components/description-text-area.tsx @@ -0,0 +1,62 @@ +import { Editor } from '@/components/editor' +import { InputMessageError } from '@/components/ui/input' +import { useController, useFormContext } from 'react-hook-form' +import { DescriptionFormInput } from './description-form' +import { useTrackSelectedStep } from '../../contexts/track-selected-step-context' + +interface RequirementsTextAreaProps { + editable?: boolean +} + +export function DescriptionTextArea({ editable }: RequirementsTextAreaProps) { + const { control } = useFormContext() + const { handleSetCurrentTarget } = useTrackSelectedStep() + + const { + formState: { errors }, + field: { onChange, value }, + } = useController({ + name: 'description', + control, + }) + + const MAX_LENGTH = 1200 + + return ( +
+
+ { + onChange(value) + }} + config={{ + editable, + maxLength: MAX_LENGTH, + className: '', + }} + onFocusMarkdown={() => { + handleSetCurrentTarget('description') + }} + placeholderValue="Conte-nos sobre você" + content={value} + /> + + {value && ( + MAX_LENGTH} + className="absolute bottom-3 right-3 text-xs font-light data-[in-limit=true]:text-red-500" + > + {value.length}/{MAX_LENGTH} + + )} +
+ + {errors.description && ( + + {errors.description.message?.toString()} + + )} +
+ ) +} diff --git a/src/app/(app)/me/project/new/description/components/skills-input.tsx b/src/app/(app)/me/project/new/description/components/skills-input.tsx new file mode 100644 index 0000000..739cd61 --- /dev/null +++ b/src/app/(app)/me/project/new/description/components/skills-input.tsx @@ -0,0 +1,241 @@ +'use client' + +import { Loader2, X } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { Command as CommandPrimitive } from 'cmdk' +import { Badge } from '@/components/ui/badge' +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { useController, useFormContext } from 'react-hook-form' +import { useQuery } from '@tanstack/react-query' +import axios from 'axios' +import { useDebounceValue } from '@/hooks/use-debounce-value' +import { InputMessageError } from '@/components/ui/input' +import { DescriptionFormInput } from './description-form' + +type DataItem = string + +interface SkillsInputProps { + disabled?: boolean +} + +export function SkillsInput({ disabled }: SkillsInputProps) { + const { control } = useFormContext() + + const { + field, + formState: { errors }, + } = useController({ + name: 'skills', + control, + disabled, + }) + + const { value: selectedItems, onChange: setSelectedItems } = field + + const inputRef = useRef(null) + const buttonRef = useRef(null) + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + + const searchTerm = useDebounceValue(search, 400) + + const { data: skillOptions, isLoading: isLoadingSkillOptions } = useQuery({ + queryKey: ['skills', searchTerm], + queryFn: async () => { + const response = await axios.get('/api/skills/search', { + params: { + q: searchTerm, + pageSize: 8, + }, + }) + + return response.data.skills + }, + enabled: open, + }) + + const handleUnselect = useCallback( + (item: DataItem) => { + const newValue = selectedItems.filter((skill) => skill !== item) + + setSelectedItems(newValue) + }, + [selectedItems, setSelectedItems], + ) + + const handleAddNewSkill = useCallback( + (skill: string) => { + if (search.length <= 1) { + return + } + + setSearch('') + const isSelected = selectedItems.includes(search) + + if (isSelected) { + return + } + + setSelectedItems([...selectedItems, skill]) + }, + [search, setSelectedItems, selectedItems], + ) + + const handleKeyDownOnCommandContainer = useCallback( + (event: React.KeyboardEvent) => { + const input = inputRef.current + const button = buttonRef.current + if (input) { + if (event.key === 'Delete') { + if (input.value === '') { + const newValue = selectedItems + newValue.pop() + + setSelectedItems(newValue) + } + } + + if (event.key === 'Escape') { + return input.blur() + } + } + + if (button) { + if (event.key === 'Enter') { + button.click() + } + } + }, + [selectedItems, setSelectedItems], + ) + + let unselectedItems = [] + + if (skillOptions) { + unselectedItems = skillOptions.filter( + (item: string) => !selectedItems?.includes(item), + ) + } + + const hasSelectedItems = selectedItems && selectedItems.length > 0 + const hasSearchValue = !!search + + useEffect(() => { + if (inputRef.current) { + inputRef.current.setAttribute('id', 'skills') + } + }, []) + + return ( + +
+
+ {selectedItems && + selectedItems.map((skill) => { + return ( + + {skill} + + + ) + })} + + setOpen(false)} + onFocus={() => setOpen(true)} + placeholder={ + hasSelectedItems ? 'Selecionar mais' : 'Selecione uma habilidade' + } + className="ml-2 flex-1 bg-transparent py-1 outline-none placeholder:text-muted-foreground" + /> +
+
+ +
+ {errors.skills && ( + {errors.skills.message} + )} + + {open && hasSearchValue ? ( + + + {selectedItems && selectedItems.includes(search) ? ( +
+ + Você já possui está habilidade + +
+ ) : ( + + )} +
+ + {isLoadingSkillOptions ? ( +
+ +
+ ) : ( + unselectedItems.length > 0 && ( + + {unselectedItems.map((skill: string) => { + return ( + { + event.preventDefault() + event.stopPropagation() + }} + onSelect={() => { + setSearch('') + setSelectedItems([...selectedItems, skill]) + }} + > + {skill} + + ) + })} + + ) + )} +
+ ) : null} +
+
+ ) +} diff --git a/src/app/(app)/me/project/new/description/components/submit-button.tsx b/src/app/(app)/me/project/new/description/components/submit-button.tsx new file mode 100644 index 0000000..52b7416 --- /dev/null +++ b/src/app/(app)/me/project/new/description/components/submit-button.tsx @@ -0,0 +1,33 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { useBoundStore } from '@/store' +import { Loader2 } from 'lucide-react' +import { ButtonHTMLAttributes } from 'react' + +type SubmitButtonProps = ButtonHTMLAttributes + +export function SubmitButton(props: SubmitButtonProps) { + const { newProjectFormSteps } = useBoundStore(({ newProjectFormSteps }) => ({ + newProjectFormSteps, + })) + + return ( + + ) +} diff --git a/src/app/(app)/me/project/new/description/page.tsx b/src/app/(app)/me/project/new/description/page.tsx new file mode 100644 index 0000000..db86cb8 --- /dev/null +++ b/src/app/(app)/me/project/new/description/page.tsx @@ -0,0 +1,33 @@ +import { Card, CardContent } from '@/components/ui/card' +import { DescriptionForm } from './components/description-form' +import { BackButton } from '../components/back-button' +import { SubmitButton } from './components/submit-button' +import { cookies } from 'next/headers' +import { NEW_PROJECT_COOKIES_ID } from '../layout' +import { redirect } from 'next/navigation' + +export default function DescriptionProject() { + const cookiesStore = cookies() + + const newProjectFormId = cookiesStore.get(NEW_PROJECT_COOKIES_ID) + + if (!newProjectFormId) { + return redirect('/me/project/new/cover') + } + + return ( +
+ + + + + + +
+ + + Avançar +
+
+ ) +} diff --git a/src/app/(app)/me/project/new/job/components/description-role-text-area.tsx b/src/app/(app)/me/project/new/job/components/description-role-text-area.tsx new file mode 100644 index 0000000..aa40f18 --- /dev/null +++ b/src/app/(app)/me/project/new/job/components/description-role-text-area.tsx @@ -0,0 +1,64 @@ +import { Editor } from '@/components/editor' +import { InputMessageError } from '@/components/ui/input' +import { useController, useFormContext } from 'react-hook-form' +import { JobFormInput } from './job-form' +import { useTrackSelectedStep } from '../../contexts/track-selected-step-context' + +interface DescriptionRoleTextAreaProps { + editable?: boolean +} + +export function DescriptionRoleTextArea({ + editable, +}: DescriptionRoleTextAreaProps) { + const { control } = useFormContext() + const { handleSetCurrentTarget } = useTrackSelectedStep() + + const { + formState: { errors }, + field: { onChange, value }, + } = useController({ + name: 'description', + control, + }) + + const MAX_LENGTH = 1200 + + return ( +
+
+ { + onChange(value) + }} + onFocusMarkdown={() => { + handleSetCurrentTarget('role-description') + }} + config={{ + editable, + maxLength: MAX_LENGTH, + className: '', + }} + placeholderValue="Conte-nos sobre você" + content={value} + /> + + {value && ( + MAX_LENGTH} + className="absolute bottom-3 right-3 text-xs font-light data-[in-limit=true]:text-red-500" + > + {value.length}/{MAX_LENGTH} + + )} +
+ + {errors.description && ( + + {errors.description.message?.toString()} + + )} +
+ ) +} diff --git a/src/app/(app)/me/project/new/job/components/initializer-new-project-store.tsx b/src/app/(app)/me/project/new/job/components/initializer-new-project-store.tsx new file mode 100644 index 0000000..577c3dc --- /dev/null +++ b/src/app/(app)/me/project/new/job/components/initializer-new-project-store.tsx @@ -0,0 +1,29 @@ +'use client' + +import { useBoundStore } from '@/store' +import { useRef } from 'react' + +type InitializerOnboardingStore = { + roleItens: Array<{ label: string; value: string }> +} + +export function InitializerNewProjectStore({ + roleItens, +}: InitializerOnboardingStore) { + const initializer = useRef(false) + + if (!initializer.current) { + useBoundStore.setState((state) => ({ + newProjectFormSteps: { + ...state.newProjectFormSteps, + job: { + ...state.newProjectFormSteps.job, + roleItens, + }, + }, + })) + initializer.current = true + } + + return null +} diff --git a/src/app/(app)/me/project/new/job/components/job-form.tsx b/src/app/(app)/me/project/new/job/components/job-form.tsx new file mode 100644 index 0000000..411606c --- /dev/null +++ b/src/app/(app)/me/project/new/job/components/job-form.tsx @@ -0,0 +1,288 @@ +'use client' + +import { ReactNode, useEffect, useId } from 'react' +import { z } from 'zod' +import { Controller, FormProvider, useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { useBoundStore } from '@/store' + +import { + InputControl, + InputMessageError, + InputRoot, +} from '@/components/ui/input' +import { InputTracker } from '../../components/input-tracker' +import { Label } from '@/components/ui/label' +import { Separator } from '@/components/ui/separator' +import { DescriptionRoleTextArea } from './description-role-text-area' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { objectsHaveEqualProperty } from '@/utils/objects-have-equal-property' +import { toast } from '@/components/ui/use-toast' +import { DoubtBox } from '@/components/doubt-box' + +const jobFormInput = z.object({ + roleId: z.string(), + name: z + .string({ required_error: 'Você deve selecionar uma função.' }) + .min(1, { message: 'Você selecionar pelo menos uma função.' }), + membersAmount: z + .number({ + required_error: 'Você deve inserir a quantidade de vagas.', + invalid_type_error: 'Você deve inserir um número.', + }) + .min(1, { message: 'Você deve inserir pelo menos uma vaga.' }) + .max(10, { message: 'Você pode disponibilizar até 10 vagas por função.' }), + description: z + .string({ + required_error: 'Você deve inserir uma descrição para a função.', + }) + .max(1200, { message: 'Limite máximo de 1200 caracteres.' }) + .min(10, { message: 'Limite mínimo de 10 caracteres.' }), +}) + +export type JobFormInput = z.infer + +type JobFormProps = { + children: ReactNode + defaultValues: { + roleId?: string + name?: string + membersAmount?: number + description?: string + } +} + +export function JobForm({ defaultValues, children }: JobFormProps) { + const { newProjectFormSteps } = useBoundStore(({ newProjectFormSteps }) => ({ + newProjectFormSteps, + })) + + const roleId = useId() + + const form = useForm({ + resolver: zodResolver(jobFormInput), + defaultValues: { + ...defaultValues, + roleId: defaultValues.roleId ?? roleId, + }, + }) + + const { + handleSubmit, + control, + register, + reset, + formState: { isValid, errors }, + } = form + + function handleNextStep(data: JobFormInput) { + const projectJobs = useBoundStore.getState().newProjectFormSteps.job.roles + const { description, roleId, membersAmount, name: roleName } = data + + if (projectJobs) { + const jobIsRegistered = projectJobs.find((job) => job.roleId === roleId) + + if (jobIsRegistered) { + const jobRegisteredHasNotChanged = objectsHaveEqualProperty( + data, + jobIsRegistered, + ) + + if (jobRegisteredHasNotChanged) { + return + } + + const jobChangedIndex = projectJobs.findIndex( + (job) => job.roleId === data.roleId, + ) + + projectJobs.splice(jobChangedIndex, 1, { + roleId: data.roleId, + description: data.description, + membersAmount: data.membersAmount, + name: data.name, + }) + + useBoundStore.setState((state) => ({ + newProjectFormSteps: { + ...state.newProjectFormSteps, + job: { + ...state.newProjectFormSteps.job, + roles: projectJobs, + currentProjectJobTab: data.name, + }, + }, + })) + + return toast({ + title: 'Vaga Atualizada', + description: ( + + A vaga{' '} + + { + newProjectFormSteps.job.roleItens.find( + (role) => role.value === data.name, + )?.label + } + {' '} + foi atualizada com sucesso. + + ), + variant: 'default', + }) + } + } + + useBoundStore.setState((state) => ({ + newProjectFormSteps: { + ...state.newProjectFormSteps, + job: { + ...state.newProjectFormSteps.job, + roles: state.newProjectFormSteps.job.roles && [ + ...state.newProjectFormSteps.job.roles, + { + roleId, + description, + membersAmount, + name: roleName, + }, + ], + currentProjectJobTab: roleName, + }, + }, + })) + + reset() + } + + const roleItems = newProjectFormSteps.job.roleItens.filter((role) => { + const currentProjectJobTabIsSelected = + newProjectFormSteps.job.currentProjectJobTab + + if (currentProjectJobTabIsSelected === role.value) { + return true + } + + if (newProjectFormSteps.job.roles) { + const roleisSelected = newProjectFormSteps.job.roles.find( + (selectedRole) => selectedRole.name === role.value, + ) + + return !roleisSelected + } + + return true + }) + + useEffect(() => { + useBoundStore.setState((state) => ({ + newProjectFormSteps: { + ...state.newProjectFormSteps, + job: { + ...state.newProjectFormSteps.job, + newProjectJobIsValid: isValid, + }, + }, + })) + }, [isValid]) + + return ( + +
+ +
+ + + + + Você pode disponibilizar até 10 vagas por função. + + +
+ +
+
+ { + return ( + + ) + }} + /> + + {errors.name && ( + {errors.name.message} + )} +
+ +
+ + + + + {errors.membersAmount && ( + + {errors.membersAmount.message} + + )} +
+
+
+ + + + + + + + + + + +
{children}
+ +
+ ) +} diff --git a/src/app/(app)/me/project/new/job/components/job-tabs.tsx b/src/app/(app)/me/project/new/job/components/job-tabs.tsx new file mode 100644 index 0000000..01a0b77 --- /dev/null +++ b/src/app/(app)/me/project/new/job/components/job-tabs.tsx @@ -0,0 +1,193 @@ +'use client' + +import { useBoundStore } from '@/store' +import { useEffect } from 'react' + +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { JobForm } from './job-form' +import { Button } from '@/components/ui/button' + +import { X } from 'lucide-react' +import { destroyCookie } from 'nookies' +import { NEW_PROJECT_COOKIES_ID } from '../../layout' +import { useRouter } from 'next/navigation' + +export function JobTabs() { + const router = useRouter() + const { newProjectFormSteps, deleteRoleFromJob } = useBoundStore( + ({ newProjectFormSteps, deleteRoleFromJob }) => ({ + newProjectFormSteps, + deleteRoleFromJob, + }), + ) + + let hasSomeJobCreated = false + + if (newProjectFormSteps.job.roles) { + hasSomeJobCreated = newProjectFormSteps.job.roles.length > 1 + } + + useEffect(() => { + useBoundStore.setState((state) => ({ + newProjectFormSteps: { + ...state.newProjectFormSteps, + job: { + ...state.newProjectFormSteps.job, + isValidToSubmit: hasSomeJobCreated, + }, + }, + })) + }, [hasSomeJobCreated]) + + useEffect(() => { + useBoundStore.setState(({ newProjectFormSteps }) => ({ + newProjectFormSteps: { + ...newProjectFormSteps, + description: { + ...newProjectFormSteps.description, + submitIsLoading: false, + }, + job: { + ...newProjectFormSteps.job, + submitIsLoading: false, + }, + }, + })) + + if ( + !newProjectFormSteps.cover.isValidToSubmit && + !newProjectFormSteps.description.isValidToSubmit + ) { + destroyCookie(null, NEW_PROJECT_COOKIES_ID) + return router.push('/me/project/new/cover') + } + + const unloadCallback = (event: BeforeUnloadEvent) => { + event.preventDefault() + event.returnValue = '' + return '' + } + + window.addEventListener('beforeunload', unloadCallback) + return () => window.removeEventListener('beforeunload', unloadCallback) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + + useBoundStore.setState((state) => ({ + newProjectFormSteps: { + ...state.newProjectFormSteps, + job: { + ...state.newProjectFormSteps.job, + currentProjectJobTab: value, + }, + }, + })) + } + value={newProjectFormSteps.job.currentProjectJobTab} + > + {hasSomeJobCreated && ( +
+ Vagas cadastradas + + + {newProjectFormSteps.job.roles && + newProjectFormSteps.job.roles.map((role) => ( + <> + {role.name !== 'default' && ( + + + {role.membersAmount} -{' '} + { + newProjectFormSteps.job.roleItens.find( + (item) => item.value === role.name, + )?.label + } + + + + + )} + + ))} + +
+ )} + + {newProjectFormSteps.job.roles && + newProjectFormSteps.job.roles.map((role, index) => { + const isDefaultRole = role.name === 'default' + + return ( + + + {isDefaultRole ? ( + + ) : ( + <> + + + + )} + + + ) + })} +
+ ) +} diff --git a/src/app/(app)/me/project/new/job/components/submit-button.tsx b/src/app/(app)/me/project/new/job/components/submit-button.tsx new file mode 100644 index 0000000..37b0cc5 --- /dev/null +++ b/src/app/(app)/me/project/new/job/components/submit-button.tsx @@ -0,0 +1,230 @@ +/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ +/* eslint-disable no-new */ +'use client' + +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/use-toast' +import { externalApi } from '@/libs/fetch-api' +import { useBoundStore } from '@/store' +import axios from 'axios' +import Compressor from 'compressorjs' +import { destroyCookie } from 'nookies' +import { ButtonHTMLAttributes } from 'react' +import { NEW_PROJECT_COOKIES_ID } from '../../layout' +import { Loader2 } from 'lucide-react' +import { useRouter } from 'next/navigation' + +type SubmitButtonProps = ButtonHTMLAttributes + +export function SubmitButton(props: SubmitButtonProps) { + const { newProjectFormSteps, resetNewProjectForm } = useBoundStore( + ({ newProjectFormSteps, resetNewProjectForm }) => ({ + newProjectFormSteps, + resetNewProjectForm, + }), + ) + const router = useRouter() + + async function handleSubmitNewProject() { + try { + useBoundStore.setState(({ newProjectFormSteps }) => ({ + newProjectFormSteps: { + ...newProjectFormSteps, + job: { + ...newProjectFormSteps.job, + submitIsLoading: true, + }, + }, + })) + + await Promise.all([ + new Promise((resolve) => { + if (newProjectFormSteps.cover.avatarUrl) { + new Compressor(newProjectFormSteps.cover.avatarUrl.file, { + quality: 0.6, + convertSize: 10000, + success(file) { + resolve( + useBoundStore.setState(({ newProjectFormSteps }) => ({ + newProjectFormSteps: { + ...newProjectFormSteps, + cover: { + ...newProjectFormSteps.cover, + avatarUrl: { + ...newProjectFormSteps.cover.avatarUrl, + previewUrl: + newProjectFormSteps.cover.avatarUrl?.previewUrl ?? + '', + publicUrl: + newProjectFormSteps.cover.avatarUrl?.publicUrl ?? + '', + signedUrl: + newProjectFormSteps.cover.avatarUrl?.signedUrl ?? + '', + file: file as File, + }, + }, + }, + })), + ) + }, + }) + } + + return resolve(true) + }), + new Promise((resolve) => { + if (newProjectFormSteps.cover.bannerUrl) { + new Compressor(newProjectFormSteps.cover.bannerUrl.file, { + quality: 0.6, + convertSize: 10000, + success(file) { + resolve( + useBoundStore.setState(({ newProjectFormSteps }) => ({ + newProjectFormSteps: { + ...newProjectFormSteps, + cover: { + ...newProjectFormSteps.cover, + bannerUrl: { + ...newProjectFormSteps.cover.bannerUrl, + previewUrl: + newProjectFormSteps.cover.bannerUrl?.previewUrl ?? + '', + publicUrl: + newProjectFormSteps.cover.bannerUrl?.publicUrl ?? + '', + signedUrl: + newProjectFormSteps.cover.bannerUrl?.signedUrl ?? + '', + file: file as File, + }, + }, + }, + })), + ) + }, + }) + } + + resolve(true) + }), + ]) + + const response = await externalApi('/projects', { + method: 'POST', + body: JSON.stringify({ + bannerUrl: newProjectFormSteps.cover.bannerUrl?.publicUrl, + imageUrl: newProjectFormSteps.cover.avatarUrl?.publicUrl, + name: newProjectFormSteps.cover.name, + availableToParticipate: { + availableDays: + newProjectFormSteps.cover.availableToParticipate.availableDays + .filter((day) => day.value !== 'all') + .map((day) => Number(day.value)), + availableTime: { + value: + newProjectFormSteps.cover.availableToParticipate.availableTime + .value, + unit: newProjectFormSteps.cover.availableToParticipate + .availableTime.unit, + }, + }, + description: newProjectFormSteps.description.projectDescription, + generalSkills: newProjectFormSteps.description.skills?.map( + (skill) => ({ + slug: skill, + }), + ), + roles: newProjectFormSteps.job.roles + ?.filter((role) => role.name !== 'default') + .map((role) => ({ + membersAmount: role.membersAmount, + name: role.name, + description: role.description, + })), + }), + }) + + if (!response.ok) { + const error = await response.json() + + console.error(error) + + useBoundStore.setState(({ newProjectFormSteps }) => ({ + newProjectFormSteps: { + ...newProjectFormSteps, + job: { + ...newProjectFormSteps.job, + submitIsLoading: false, + }, + }, + })) + + return toast({ + title: 'Ocorreu um error ao cria o seu projeto.', + description: `Tente novamente mais tarde.`, + variant: 'destructive', + }) + } + + await Promise.all([ + axios.put( + useBoundStore.getState().newProjectFormSteps.cover.avatarUrl + ?.signedUrl!, + useBoundStore.getState().newProjectFormSteps.cover.avatarUrl?.file!, + { + headers: { + 'Content-Type': + useBoundStore.getState().newProjectFormSteps.cover.avatarUrl + ?.file.type, + }, + }, + ), + axios.put( + useBoundStore.getState().newProjectFormSteps.cover.bannerUrl + ?.signedUrl!, + useBoundStore.getState().newProjectFormSteps.cover.bannerUrl?.file!, + { + headers: { + 'Content-Type': + useBoundStore.getState().newProjectFormSteps.cover.bannerUrl + ?.file.type, + }, + }, + ), + ]) + + router.push('/me/projects') + destroyCookie(null, NEW_PROJECT_COOKIES_ID) + resetNewProjectForm() + } catch (error) { + console.error(error) + + return toast({ + title: 'Ocorreu um error ao cria o seu projeto.', + description: `Tente novamente mais tarde.`, + variant: 'destructive', + }) + } + } + + return ( + + ) +} diff --git a/src/app/(app)/me/project/new/job/page.tsx b/src/app/(app)/me/project/new/job/page.tsx new file mode 100644 index 0000000..f3ed85d --- /dev/null +++ b/src/app/(app)/me/project/new/job/page.tsx @@ -0,0 +1,50 @@ +import { getRolesItensFromCms } from '@/actions/get-roles-itens-from-cms' +import { cookies } from 'next/headers' +import { NEW_PROJECT_COOKIES_ID } from '../layout' +import { redirect } from 'next/navigation' + +import { Card, CardContent } from '@/components/ui/card' +import { BackButton } from '../components/back-button' +import { JobTabs } from './components/job-tabs' +import { InitializerNewProjectStore } from './components/initializer-new-project-store' +import { SubmitButton } from './components/submit-button' + +export default async function JobProject() { + const { roles } = await getRolesItensFromCms() + const cookiesStore = cookies() + + const newProjectFormId = cookiesStore.get(NEW_PROJECT_COOKIES_ID) + + if (!newProjectFormId) { + return redirect('/me/project/new/cover') + } + + const newProjectFormIdParsed: { + [key: string]: string + cover: string + description: string + } = JSON.parse(newProjectFormId.value) + + if (!newProjectFormIdParsed.description) { + return redirect('/me/project/new/description') + } + + return ( + <> + +
+ + + + + + +
+ + + Publicar projeto +
+
+ + ) +} diff --git a/src/app/(app)/me/project/new/layout.tsx b/src/app/(app)/me/project/new/layout.tsx new file mode 100644 index 0000000..bbb0fd7 --- /dev/null +++ b/src/app/(app)/me/project/new/layout.tsx @@ -0,0 +1,21 @@ +import { ReactNode } from 'react' +import { SidebarNavigation } from './components/sidebar-navigation' +import { TrackSelectedStepContextProvider } from './contexts/track-selected-step-context' + +export const NEW_PROJECT_COOKIES_ID = '@devxperience:new-project-id' + +export default async function NewProjectLayout({ + children, +}: { + children: ReactNode +}) { + return ( +
+ + + + {children} + +
+ ) +}