feat: add currency, salary period, and rich text description

Frontend:
- Added currency selector (BRL, USD, EUR, JPY, GBP, CNY, AED, CAD, AUD, CHF)
- Added salary period dropdown (hourly, daily, weekly, monthly, yearly)
- Created RichTextEditor component for job descriptions (Bold, Lists, Alignment)
- Updated confirmation step to display currency symbol and period label

Backend:
- JobService now persists currency in job creation
- Extended currency validation in DTOs

Seeder:
- Already includes currency in job insertion
This commit is contained in:
Tiago Yamamoto 2025-12-26 15:37:54 -03:00
parent 91e4417c95
commit cca951ca23
4 changed files with 178 additions and 19 deletions

View file

@ -8,7 +8,7 @@ type CreateJobRequest struct {
SalaryMin *float64 `json:"salaryMin,omitempty"`
SalaryMax *float64 `json:"salaryMax,omitempty"`
SalaryType *string `json:"salaryType,omitempty" validate:"omitempty,oneof=hourly daily weekly monthly yearly"`
Currency *string `json:"currency,omitempty" validate:"omitempty,oneof=BRL USD EUR GBP JPY"`
Currency *string `json:"currency,omitempty" validate:"omitempty,oneof=BRL USD EUR GBP JPY CNY AED CAD AUD CHF"`
SalaryNegotiable bool `json:"salaryNegotiable"`
EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"`
WorkingHours *string `json:"workingHours,omitempty"`
@ -29,7 +29,7 @@ type UpdateJobRequest struct {
SalaryMin *float64 `json:"salaryMin,omitempty"`
SalaryMax *float64 `json:"salaryMax,omitempty"`
SalaryType *string `json:"salaryType,omitempty" validate:"omitempty,oneof=hourly daily weekly monthly yearly"`
Currency *string `json:"currency,omitempty" validate:"omitempty,oneof=BRL USD EUR GBP JPY"`
Currency *string `json:"currency,omitempty" validate:"omitempty,oneof=BRL USD EUR GBP JPY CNY AED CAD AUD CHF"`
SalaryNegotiable *bool `json:"salaryNegotiable,omitempty"`
EmploymentType *string `json:"employmentType,omitempty" validate:"omitempty,oneof=full-time part-time dispatch contract temporary training voluntary permanent"`
WorkingHours *string `json:"workingHours,omitempty"`

View file

@ -24,10 +24,10 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*mod
query := `
INSERT INTO jobs (
company_id, created_by, title, description, salary_min, salary_max, salary_type,
company_id, created_by, title, description, salary_min, salary_max, salary_type, currency,
employment_type, working_hours, location, region_id, city_id,
requirements, benefits, visa_support, language_level, status, created_at, updated_at, salary_negotiable
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21)
RETURNING id, created_at, updated_at
`
@ -39,6 +39,7 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*mod
SalaryMin: req.SalaryMin,
SalaryMax: req.SalaryMax,
SalaryType: req.SalaryType,
Currency: req.Currency,
SalaryNegotiable: req.SalaryNegotiable,
EmploymentType: req.EmploymentType,
WorkingHours: req.WorkingHours,
@ -59,7 +60,7 @@ func (s *JobService) CreateJob(req dto.CreateJobRequest, createdBy string) (*mod
err := s.DB.QueryRow(
query,
job.CompanyID, job.CreatedBy, job.Title, job.Description, job.SalaryMin, job.SalaryMax, job.SalaryType,
job.CompanyID, job.CreatedBy, job.Title, job.Description, job.SalaryMin, job.SalaryMax, job.SalaryType, job.Currency,
job.EmploymentType, job.WorkingHours, job.Location, job.RegionID, job.CityID,
job.Requirements, job.Benefits, job.VisaSupport, job.LanguageLevel, job.Status, job.CreatedAt, job.UpdatedAt, job.SalaryNegotiable,
).Scan(&job.ID, &job.CreatedAt, &job.UpdatedAt)

View file

@ -15,6 +15,7 @@ import {
Eye, EyeOff, Globe
} from "lucide-react";
import { LocationPicker } from "@/components/location-picker";
import { RichTextEditor } from "@/components/rich-text-editor";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
// Common Country Codes
@ -37,6 +38,24 @@ const COUNTRY_CODES = [
{ code: "+51", country: "Peru (PE)" },
].sort((a, b) => a.country.localeCompare(b.country));
// Currency symbol helper
const getCurrencySymbol = (code: string): string => {
const symbols: Record<string, string> = {
'BRL': 'R$', 'USD': '$', 'EUR': '€', 'JPY': '¥', 'GBP': '£',
'CNY': '¥', 'AED': 'د.إ', 'CAD': 'C$', 'AUD': 'A$', 'CHF': 'Fr'
};
return symbols[code] || code;
};
// Salary period label helper
const getSalaryPeriodLabel = (type: string): string => {
const labels: Record<string, string> = {
'hourly': '/hora', 'daily': '/dia', 'weekly': '/semana',
'monthly': '/mês', 'yearly': '/ano'
};
return labels[type] || '';
};
export default function PostJobPage() {
const router = useRouter();
const [step, setStep] = useState<1 | 2 | 3>(1);
@ -63,6 +82,8 @@ export default function PostJobPage() {
salaryMin: "",
salaryMax: "",
salaryFixed: "", // For fixed salary mode
currency: "BRL", // Default currency
salaryType: "monthly", // Default salary period
employmentType: "",
workMode: "remote",
workingHours: "",
@ -144,6 +165,8 @@ export default function PostJobPage() {
// Salary logic: if negotiable, send null values
salaryMin: job.salaryNegotiable ? null : (salaryMode === 'fixed' ? (job.salaryFixed ? parseInt(job.salaryFixed) : null) : (job.salaryMin ? parseInt(job.salaryMin) : null)),
salaryMax: job.salaryNegotiable ? null : (salaryMode === 'fixed' ? (job.salaryFixed ? parseInt(job.salaryFixed) : null) : (job.salaryMax ? parseInt(job.salaryMax) : null)),
salaryType: job.salaryNegotiable ? null : job.salaryType,
currency: job.salaryNegotiable ? null : job.currency,
salaryNegotiable: job.salaryNegotiable,
employmentType: job.employmentType || null,
workingHours: job.workingHours || null,
@ -348,12 +371,12 @@ export default function PostJobPage() {
</div>
</div>
<div>
<Label>Descrição *</Label>
<Textarea
<Label>Descrição da Vaga *</Label>
<RichTextEditor
value={job.description}
onChange={(e) => setJob({ ...job, description: e.target.value })}
onChange={(val) => setJob({ ...job, description: val })}
placeholder="Descreva as responsabilidades, requisitos e benefícios..."
rows={5}
minHeight="150px"
/>
</div>
<div>
@ -382,17 +405,53 @@ export default function PostJobPage() {
{!job.salaryNegotiable && (
<>
{salaryMode === 'fixed' ? (
{/* Currency and Period Row */}
<div className="grid grid-cols-2 gap-2">
<div>
<Input
type="number"
value={job.salaryFixed}
onChange={(e) => setJob({ ...job, salaryFixed: e.target.value })}
placeholder="Valor"
/>
<Label className="text-xs text-muted-foreground">Moeda</Label>
<select
value={job.currency}
onChange={(e) => setJob({ ...job, currency: e.target.value })}
className="w-full px-3 py-2 border rounded-lg bg-background text-sm"
>
<option value="BRL">R$ (BRL)</option>
<option value="USD">$ (USD)</option>
<option value="EUR"> (EUR)</option>
<option value="JPY">¥ (JPY)</option>
<option value="GBP">£ (GBP)</option>
<option value="CNY">¥ (CNY)</option>
<option value="AED">د.إ (AED)</option>
<option value="CAD">$ (CAD)</option>
<option value="AUD">$ (AUD)</option>
<option value="CHF">Fr (CHF)</option>
</select>
</div>
<div>
<Label className="text-xs text-muted-foreground">Período</Label>
<select
value={job.salaryType}
onChange={(e) => setJob({ ...job, salaryType: e.target.value })}
className="w-full px-3 py-2 border rounded-lg bg-background text-sm"
>
<option value="hourly">por hora</option>
<option value="daily">por dia</option>
<option value="weekly">por semana</option>
<option value="monthly">por mês</option>
<option value="yearly">por ano</option>
</select>
</div>
</div>
{/* Salary Value(s) */}
{salaryMode === 'fixed' ? (
<Input
type="number"
value={job.salaryFixed}
onChange={(e) => setJob({ ...job, salaryFixed: e.target.value })}
placeholder="Valor"
/>
) : (
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
value={job.salaryMin}
@ -493,8 +552,8 @@ export default function PostJobPage() {
job.salaryNegotiable
? "Candidato envia proposta"
: salaryMode === 'fixed'
? (job.salaryFixed ? `R$ ${job.salaryFixed}` : "A combinar")
: (job.salaryMin && job.salaryMax ? `R$ ${job.salaryMin} - R$ ${job.salaryMax}` : "A combinar")
? (job.salaryFixed ? `${getCurrencySymbol(job.currency)} ${job.salaryFixed} ${getSalaryPeriodLabel(job.salaryType)}` : "A combinar")
: (job.salaryMin && job.salaryMax ? `${getCurrencySymbol(job.currency)} ${job.salaryMin} - ${job.salaryMax} ${getSalaryPeriodLabel(job.salaryType)}` : "A combinar")
}</p>
<p><strong>Tipo:</strong> {job.employmentType || "Qualquer"} / {job.workingHours === 'full-time' ? 'Integral' : job.workingHours === 'part-time' ? 'Meio Período' : 'Qualquer'} / {job.workMode}</p>
</div>

View file

@ -0,0 +1,99 @@
"use client";
import { useState, useRef, useCallback } from "react";
import { Bold, List, ListOrdered, AlignLeft, AlignRight } from "lucide-react";
interface RichTextEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
minHeight?: string;
}
export function RichTextEditor({ value, onChange, placeholder, minHeight = "200px" }: RichTextEditorProps) {
const editorRef = useRef<HTMLDivElement>(null);
const [isFocused, setIsFocused] = useState(false);
const execCommand = useCallback((command: string, value?: string) => {
document.execCommand(command, false, value);
if (editorRef.current) {
onChange(editorRef.current.innerHTML);
}
}, [onChange]);
const handleInput = () => {
if (editorRef.current) {
onChange(editorRef.current.innerHTML);
}
};
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
};
const ToolbarButton = ({ onClick, active, children, title }: {
onClick: () => void;
active?: boolean;
children: React.ReactNode;
title: string;
}) => (
<button
type="button"
onClick={onClick}
title={title}
className={`p-1.5 rounded hover:bg-muted transition-colors ${active ? 'bg-muted text-primary' : 'text-muted-foreground'}`}
>
{children}
</button>
);
return (
<div className={`border rounded-lg overflow-hidden ${isFocused ? 'ring-2 ring-ring ring-offset-2' : ''}`}>
{/* Toolbar */}
<div className="flex items-center gap-1 p-2 border-b bg-muted/30">
<ToolbarButton onClick={() => execCommand('bold')} title="Negrito">
<Bold className="h-4 w-4" />
</ToolbarButton>
<div className="w-px h-4 bg-border mx-1" />
<ToolbarButton onClick={() => execCommand('insertUnorderedList')} title="Lista com marcadores">
<List className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton onClick={() => execCommand('insertOrderedList')} title="Lista numerada">
<ListOrdered className="h-4 w-4" />
</ToolbarButton>
<div className="w-px h-4 bg-border mx-1" />
<ToolbarButton onClick={() => execCommand('justifyLeft')} title="Alinhar à esquerda">
<AlignLeft className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton onClick={() => execCommand('justifyRight')} title="Alinhar à direita">
<AlignRight className="h-4 w-4" />
</ToolbarButton>
</div>
{/* Editor Area */}
<div
ref={editorRef}
contentEditable
onInput={handleInput}
onPaste={handlePaste}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
className="p-3 outline-none bg-background"
style={{ minHeight }}
data-placeholder={placeholder}
dangerouslySetInnerHTML={{ __html: value }}
suppressContentEditableWarning
/>
<style jsx>{`
[contenteditable]:empty:before {
content: attr(data-placeholder);
color: hsl(var(--muted-foreground));
pointer-events: none;
}
`}</style>
</div>
);
}