Skip to content

Commit

Permalink
feat: implemented company_logo insert option, and salary range
Browse files Browse the repository at this point in the history
  • Loading branch information
Luisfp0 committed Oct 15, 2024
1 parent 5acf5aa commit b22599b
Show file tree
Hide file tree
Showing 8 changed files with 2,363 additions and 816 deletions.
139 changes: 116 additions & 23 deletions apps/web/app/(roles)/formSchema.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,120 @@
import { Topics } from 'shared/src/enums/topics'
import { z } from 'zod'

export const formSchema = z.object({
url: z
.string({ required_error: 'Campo obrigatório.' })
.url({ message: 'URL inválida.' }),
title: z.string({ required_error: 'O título da vaga é obrigatório.' }),
company: z.string({ required_error: 'Sem empresa -> Sem vaga 😶‍🌫️' }),
currency: z.string({ required_error: 'Moeda inválida.' }),
description: z.string({ required_error: 'Campo obrigatório.' }).nullable(),
language: z.string({ required_error: 'Idioma inválido.' }),
skillsId: z.array(z.string(), {
required_error: 'Adicione ao menos uma habilidade.',
}),
country: z.string({ required_error: 'País de origem inválido.' }),
minimumYears: z.number({ coerce: true }).default(0).nullable(),
topicsId: z
.string({ invalid_type_error: 'Selecione algum tópico.' })
.default(Topics.NATIONAL_VACANCIES.toString()),
salary: z
.number({ required_error: 'Salário inválido.', coerce: true })
.default(0)
.nullable(),
})
const formatSalary = (
minSalary: number,
maxSalary: number,
currency: string,
frequency: 'monthly' | 'annual',
isSingleValue: boolean,
topicId: number
): string => {
const isInternational = topicId === Topics.INTERNATIONAL_VACANCIES
const currencySymbol = currency === 'USD' ? '$' : 'R$'
const formatter = new Intl.NumberFormat(
currency === 'USD' ? 'en-US' : 'pt-BR',
{
style: 'currency',
currency: currency === 'USD' ? 'USD' : 'BRL',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}
)

export type FormSchema = z.TypeOf<typeof formSchema>
const formattedMinSalary = formatter
.format(minSalary)
.replace(currencySymbol, '')
.trim()
const formattedMaxSalary = formatter
.format(maxSalary)
.replace(currencySymbol, '')
.trim()

const frequencyText = isInternational
? frequency === 'monthly'
? 'monthly'
: 'annual'
: frequency === 'monthly'
? 'mensal'
: 'anual'

if (isSingleValue) {
return isInternational
? `${currencySymbol} ${formattedMinSalary} /${frequencyText}`
: `${currencySymbol} ${formattedMinSalary} /${frequencyText}`
} else {
return isInternational
? `From ${currencySymbol} ${formattedMinSalary} to ${currencySymbol} ${formattedMaxSalary} /${frequencyText}`
: `De ${currencySymbol} ${formattedMinSalary} até ${currencySymbol} ${formattedMaxSalary} /${frequencyText}`
}
}

export const formSchema = z
.object({
url: z
.string({ required_error: 'Campo obrigatório.' })
.url({ message: 'URL inválida.' }),
title: z.string({ required_error: 'O título da vaga é obrigatório.' }),
company: z.string({ required_error: 'Sem empresa -> Sem vaga 😶‍🌫️' }),
currency: z.enum(['USD', 'BRL'], { required_error: 'Moeda inválida.' }),
description: z.string({ required_error: 'Campo obrigatório.' }).nullable(),
language: z.string({ required_error: 'Idioma inválido.' }),
skillsId: z.array(z.string(), {
required_error: 'Adicione ao menos uma habilidade.',
}),
country: z.string({ required_error: 'País de origem inválido.' }),
minimumYears: z.number({ coerce: true }).default(0).nullable(),
topicsId: z
.string({ invalid_type_error: 'Selecione algum tópico.' })
.default(Topics.NATIONAL_VACANCIES.toString())
.transform((val) => parseInt(val, 10)),
minSalary: z.number().default(10),
maxSalary: z.number().default(10000),
salaryFrequency: z.enum(['monthly', 'annual'], {
required_error: 'Por favor, selecione a frequência salarial',
}),
isSingleValue: z.boolean().default(false),
companyLogo: z
.any()
.optional()
.refine(
(file) => {
if (file instanceof File) {
return ['image/jpeg', 'image/png', 'image/gif'].includes(file.type)
}
return true
},
{
message: 'O arquivo deve ser uma imagem (JPEG, PNG ou GIF).',
}
)
.refine(
(file) => {
if (file instanceof File) {
return file.size <= 5 * 1024 * 1024 // 5MB
}
return true
},
{
message: 'O tamanho máximo do arquivo é 5MB.',
}
),
})
.refine((data) => data.maxSalary >= data.minSalary, {
message: 'O salário máximo deve ser maior ou igual ao salário mínimo',
path: ['maxSalary'],
})
.transform((data) => ({
...data,
salary: formatSalary(
data.minSalary,
data.isSingleValue ? data.minSalary : data.maxSalary,
data.currency,
data.salaryFrequency,
data.isSingleValue,
data.topicsId
),
topicId: data.topicsId,
}))

export type FormSchema = z.infer<typeof formSchema>
25 changes: 25 additions & 0 deletions apps/web/app/(roles)/vagas/publique/action.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
'use server'

import { PutObjectCommand } from '@aws-sdk/client-s3'
import { Database, getSupabaseClient } from 'db'
import { R2 } from 'shared'
import { sendJobCreatedEmail } from 'shared/src/email/sendJobCreatedEmail'

type Role = Database['public']['Tables']['Roles']['Insert']

interface SendCompanyLogoParams {
fileName: string
fileBuffer: string
contentType: string
}

const supabase = getSupabaseClient()

export const createRole = async (roleData: Role, email: string) => {
Expand Down Expand Up @@ -61,3 +69,20 @@ export const checkUserHasRoles = async (email: string) => {

return roleData && roleData.length > 0
}

export const sendCompanyLogoToR2 = async ({
fileName,
fileBuffer,
contentType,
}: SendCompanyLogoParams): Promise<void> => {
const buffer = Buffer.from(fileBuffer, 'base64')

await R2.send(
new PutObjectCommand({
Bucket: 'company-logo-trampar-de-casa',
Key: fileName,
Body: buffer,
ContentType: contentType,
})
)
}
98 changes: 57 additions & 41 deletions apps/web/app/(roles)/vagas/publique/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
'use client'

import { zodResolver } from '@hookform/resolvers/zod'
import { FormSchema, formSchema } from 'app/(roles)/formSchema'
import {
Expand All @@ -19,9 +20,16 @@ import { RolePreviewSection } from './RolePreviewSection'
import { RoleTopic } from './RoleTopic'
import { Database } from 'db'
import login from 'app/utils/LoginPreferencesActions'
import { checkUserHasRoles, createRole, createRoleOwner } from './action'
import {
checkUserHasRoles,
createRole,
createRoleOwner,
sendCompanyLogoToR2,
} from './action'
import { useRouter } from 'next/navigation'
import { FEATURES } from './config'
import { SalaryRangeField } from 'app/components/SalaryRangeField'
import { CompanyLogoUpload } from 'app/components/CompanyLogoUpload'

type FormFields = {
name: keyof FormSchema
Expand Down Expand Up @@ -112,6 +120,10 @@ export default function RolesCreate() {
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
mode: 'onBlur',
defaultValues: {
minSalary: 1000,
maxSalary: 10000,
},
})
const toast = useToast()
const [isLoggedIn, setIsLoggedIn] = useState(false)
Expand Down Expand Up @@ -153,45 +165,48 @@ export default function RolesCreate() {
})
return
}

try {
let company_logo_url = null
if (formData.companyLogo && formData.companyLogo instanceof File) {
const fileName = `company_logo_${Date.now()}_${
formData.companyLogo.name
}`
const fileBuffer = await formData.companyLogo.arrayBuffer()
const base64FileBuffer = Buffer.from(fileBuffer).toString('base64')

await sendCompanyLogoToR2({
fileName,
fileBuffer: base64FileBuffer,
contentType: formData.companyLogo.type,
})

company_logo_url = `https://company-logo-trampar-de-casa.r2.dev/${fileName}`
}

const roleData: RolesInsert = {
language: formData.language === 'Português' ? 'Portuguese' : 'English',
country: formData.country,
currency: formData.currency,
description: formData.description,
salary: formData.salary?.toString(),
salary: formData.salary,
title: formData.title,
url: formData.url,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
company: formData.company,
skillsId: formData.skillsId || [],
ready: true,
topicId:
formData.currency === 'Real' || formData.currency === 'BRL' ? 1 : 2,
company_logo: null,
topicId: formData.topicId,
company_logo: company_logo_url,
minimumYears: formData.minimumYears,
}

const newRole = await createRole(roleData, '[email protected]')
const newRole = await createRole(roleData, email)
await createRoleOwner(newRole.id, userID)
console.log('should redirect')
router.push(`/vaga/${newRole.id}`)

form.reset({
url: '',
company: '',
country: '',
currency: undefined,
description: '',
language: undefined,
minimumYears: undefined,
salary: undefined,
skillsId: undefined,
title: '',
topicsId: undefined,
})
form.reset()

toast.toast({
title: 'Vaga criada com sucesso!',
Expand All @@ -217,34 +232,35 @@ export default function RolesCreate() {
</h1>
<section>
<RolePreviewSection />
<section className="grid grid-cols-1 gap-6 py-6 md:grid-cols-2">
<section className="grid grid-cols-1 justify-center gap-6 py-6 md:grid-cols-2">
<CompanyLogoUpload />
{fields.map((props) => (
<CustomFormField
key={props.name}
{...props}
Input={props.Input || TextInput}
/>
))}
<section className="grid grid-cols-2 gap-6">
{salaryAndCurrencyField.map((props) => (
<CustomFormField
key={props.name}
{...props}
Input={props.Input || TextInput}
/>
))}
</section>
<section className="grid grid-cols-2 gap-6">
{countryAndLanguageField.map((props) => (
<CustomFormField
key={props.name}
{...props}
Input={props.Input || TextInput}
/>
))}
</section>
<RoleTopic />
<SkillsField description="Quais habilidades são necessárias para a vaga?" />
<CustomFormField
name="currency"
label="Câmbio"
placeholder="BRL, USD, EUR..."
description="Insira a moeda de pagamento do salário"
Input={CurrencySelect}
required
/>
<SalaryRangeField currency={form.watch('currency')} />
{countryAndLanguageField.map((props) => (
<CustomFormField
key={props.name}
{...props}
Input={props.Input || TextInput}
/>
))}
<div className="space-y-6 md:col-span-2">
<RoleTopic />
<SkillsField description="Quais habilidades são necessárias para a vaga?" />
</div>
</section>
</section>
<section className="flex gap-4">
Expand Down
Loading

0 comments on commit b22599b

Please sign in to comment.