feat: implement high priority features

1. Advanced Search (backend)
   - Add salaryMin, salaryMax, currency, sortBy to JobFilterQuery
   - Add 5+ filters: visa, salary range, currency, language level
   - Add 4 sort options: recent, salary_asc, salary_desc, relevance

2. Email Service (backend)
   - Create Resend API integration (email_service.go)
   - 3 HTML email templates: welcome, password_reset, application_received
   - Add RESEND_API_KEY, EMAIL_FROM, APP_URL env vars

3. i18n (frontend)
   - Create 4 language files: pt-BR, en-US, es-ES, ja-JP
   - 100+ translation keys per language
   - Covers: common, nav, auth, jobs, profile, company, footer

4. Stripe Integration (backend)
   - Create payment_handler.go with checkout session creation
   - Webhook handler with signature verification
   - Support for checkout.session.completed, payment_intent events
This commit is contained in:
Tiago Yamamoto 2025-12-24 11:40:53 -03:00
parent 7310627bee
commit 38a94bcbce
9 changed files with 1103 additions and 0 deletions

View file

@ -44,3 +44,10 @@ CLOUDFLARE_ZONE_ID=your-zone-id
CPANEL_HOST=https://cpanel.yourdomain.com:2083
CPANEL_USERNAME=your-cpanel-username
CPANEL_API_TOKEN=your-cpanel-api-token
# =============================================================================
# Email Service (Resend)
# =============================================================================
RESEND_API_KEY=re_xxxx_your_api_key
EMAIL_FROM=noreply@gohorsejobs.com
APP_URL=https://gohorsejobs.com

View file

@ -123,6 +123,12 @@ type JobFilterQuery struct {
VisaSupport *bool `form:"visaSupport"`
LanguageLevel *string `form:"languageLevel"`
Search *string `form:"search"` // Covers title, description, company name
// Advanced filters
SalaryMin *float64 `form:"salaryMin"` // Minimum salary filter
SalaryMax *float64 `form:"salaryMax"` // Maximum salary filter
Currency *string `form:"currency"` // BRL, USD, EUR, GBP, JPY
SortBy *string `form:"sortBy"` // recent, salary_asc, salary_desc, relevance
}
// PaginatedResponse represents a paginated API response

View file

@ -0,0 +1,290 @@
package handlers
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/rede5/gohorsejobs/backend/internal/services"
)
// PaymentHandler handles Stripe payment operations
type PaymentHandler struct {
jobService *services.JobService
}
// NewPaymentHandler creates a new payment handler
func NewPaymentHandler(jobService *services.JobService) *PaymentHandler {
return &PaymentHandler{jobService: jobService}
}
// CreateCheckoutRequest represents a checkout session request
type CreateCheckoutRequest struct {
JobID int `json:"jobId"`
PriceID string `json:"priceId"` // Stripe Price ID
SuccessURL string `json:"successUrl"` // URL after success
CancelURL string `json:"cancelUrl"` // URL after cancel
}
// CreateCheckoutResponse represents the checkout session response
type CreateCheckoutResponse struct {
SessionID string `json:"sessionId"`
CheckoutURL string `json:"checkoutUrl"`
}
// CreateCheckout creates a Stripe checkout session for job posting payment
// @Summary Create checkout session
// @Description Create a Stripe checkout session for job posting payment
// @Tags Payments
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body CreateCheckoutRequest true "Checkout request"
// @Success 200 {object} CreateCheckoutResponse
// @Failure 400 {string} string "Bad Request"
// @Failure 500 {string} string "Internal Server Error"
// @Router /api/v1/payments/create-checkout [post]
func (h *PaymentHandler) CreateCheckout(w http.ResponseWriter, r *http.Request) {
var req CreateCheckoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.JobID == 0 || req.PriceID == "" {
http.Error(w, "JobID and PriceID are required", http.StatusBadRequest)
return
}
// Get Stripe secret key
stripeSecretKey := os.Getenv("STRIPE_SECRET_KEY")
if stripeSecretKey == "" {
http.Error(w, "Payment service not configured", http.StatusInternalServerError)
return
}
// Create Stripe checkout session via API
sessionID, checkoutURL, err := createStripeCheckoutSession(stripeSecretKey, req)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to create checkout session: %v", err), http.StatusInternalServerError)
return
}
response := CreateCheckoutResponse{
SessionID: sessionID,
CheckoutURL: checkoutURL,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// HandleWebhook processes Stripe webhook events
// @Summary Handle Stripe webhook
// @Description Process Stripe webhook events (payment success, failure, etc.)
// @Tags Payments
// @Accept json
// @Produce json
// @Success 200 {string} string "OK"
// @Failure 400 {string} string "Bad Request"
// @Router /api/v1/payments/webhook [post]
func (h *PaymentHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
webhookSecret := os.Getenv("STRIPE_WEBHOOK_SECRET")
if webhookSecret == "" {
http.Error(w, "Webhook secret not configured", http.StatusInternalServerError)
return
}
// Read body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
// Verify signature
signature := r.Header.Get("Stripe-Signature")
if !verifyStripeSignature(body, signature, webhookSecret) {
http.Error(w, "Invalid signature", http.StatusBadRequest)
return
}
// Parse event
var event map[string]interface{}
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
eventType, ok := event["type"].(string)
if !ok {
http.Error(w, "Missing event type", http.StatusBadRequest)
return
}
// Handle event types
switch eventType {
case "checkout.session.completed":
h.handleCheckoutComplete(event)
case "payment_intent.succeeded":
h.handlePaymentSuccess(event)
case "payment_intent.payment_failed":
h.handlePaymentFailed(event)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"received": true}`))
}
func (h *PaymentHandler) handleCheckoutComplete(event map[string]interface{}) {
// Extract session data and update job payment status
data, _ := event["data"].(map[string]interface{})
obj, _ := data["object"].(map[string]interface{})
sessionID, _ := obj["id"].(string)
metadata, _ := obj["metadata"].(map[string]interface{})
jobIDStr, _ := metadata["job_id"].(string)
if jobIDStr != "" && sessionID != "" {
// TODO: Update job_payments table to mark as completed
fmt.Printf("Payment completed for job %s, session %s\n", jobIDStr, sessionID)
}
}
func (h *PaymentHandler) handlePaymentSuccess(event map[string]interface{}) {
// Payment succeeded
fmt.Println("Payment succeeded")
}
func (h *PaymentHandler) handlePaymentFailed(event map[string]interface{}) {
// Payment failed
fmt.Println("Payment failed")
}
// GetPaymentStatus returns the status of a payment
// @Summary Get payment status
// @Description Get the status of a job posting payment
// @Tags Payments
// @Produce json
// @Param id path string true "Payment ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {string} string "Not Found"
// @Router /api/v1/payments/status/{id} [get]
func (h *PaymentHandler) GetPaymentStatus(w http.ResponseWriter, r *http.Request) {
paymentID := r.PathValue("id")
if paymentID == "" {
http.Error(w, "Payment ID is required", http.StatusBadRequest)
return
}
// TODO: Query job_payments table for status
response := map[string]interface{}{
"id": paymentID,
"status": "pending",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// Helper function to create Stripe checkout session via API
func createStripeCheckoutSession(secretKey string, req CreateCheckoutRequest) (string, string, error) {
client := &http.Client{}
// Build form data
data := fmt.Sprintf(
"mode=payment&success_url=%s&cancel_url=%s&line_items[0][price]=%s&line_items[0][quantity]=1&metadata[job_id]=%d",
req.SuccessURL, req.CancelURL, req.PriceID, req.JobID,
)
httpReq, err := http.NewRequest("POST", "https://api.stripe.com/v1/checkout/sessions",
io.NopCloser(io.Reader(nil)))
if err != nil {
return "", "", err
}
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", secretKey))
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
httpReq.Body = io.NopCloser(strings.NewReader(data))
resp, err := client.Do(httpReq)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return "", "", fmt.Errorf("Stripe API error: %s", string(body))
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", "", err
}
sessionID, _ := result["id"].(string)
checkoutURL, _ := result["url"].(string)
return sessionID, checkoutURL, nil
}
// Verify Stripe webhook signature
func verifyStripeSignature(payload []byte, header, secret string) bool {
if header == "" {
return false
}
// Parse signature header
var timestamp string
var signatures []string
parts := splitHeader(header)
for _, p := range parts {
if len(p) > 2 && p[0] == 't' && p[1] == '=' {
timestamp = p[2:]
} else if len(p) > 3 && p[0] == 'v' && p[1] == '1' && p[2] == '=' {
signatures = append(signatures, p[3:])
}
}
if timestamp == "" || len(signatures) == 0 {
return false
}
// Check timestamp (5 min tolerance)
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if time.Now().Unix()-ts > 300 {
return false
}
// Compute expected signature
signedPayload := fmt.Sprintf("%s.%s", timestamp, string(payload))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
expectedSig := hex.EncodeToString(mac.Sum(nil))
// Compare signatures
for _, sig := range signatures {
if hmac.Equal([]byte(sig), []byte(expectedSig)) {
return true
}
}
return false
}
func splitHeader(header string) []string {
return strings.Split(header, ",")
}

View file

@ -0,0 +1,304 @@
package email
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"text/template"
)
// EmailService handles transactional email sending
type EmailService struct {
apiKey string
fromAddr string
baseURL string
}
// NewEmailService creates a new email service using Resend API
func NewEmailService() *EmailService {
apiKey := os.Getenv("RESEND_API_KEY")
fromAddr := os.Getenv("EMAIL_FROM")
if fromAddr == "" {
fromAddr = "noreply@gohorsejobs.com"
}
return &EmailService{
apiKey: apiKey,
fromAddr: fromAddr,
baseURL: "https://api.resend.com/emails",
}
}
// IsConfigured returns true if email service is properly configured
func (s *EmailService) IsConfigured() bool {
return s.apiKey != ""
}
// EmailPayload represents the Resend API request body
type EmailPayload struct {
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html"`
Text string `json:"text,omitempty"`
}
// SendEmail sends an email via Resend API
func (s *EmailService) SendEmail(to []string, subject, htmlBody, textBody string) error {
if !s.IsConfigured() {
return fmt.Errorf("email service not configured: RESEND_API_KEY missing")
}
payload := EmailPayload{
From: s.fromAddr,
To: to,
Subject: subject,
HTML: htmlBody,
Text: textBody,
}
jsonBody, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal email payload: %w", err)
}
req, err := http.NewRequest("POST", s.baseURL, bytes.NewBuffer(jsonBody))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.apiKey))
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("email API error: status %d", resp.StatusCode)
}
return nil
}
// WelcomeEmailData contains data for welcome email template
type WelcomeEmailData struct {
Name string
Email string
AppURL string
AppName string
}
// SendWelcome sends a welcome email to new users
func (s *EmailService) SendWelcome(to, name string) error {
data := WelcomeEmailData{
Name: name,
Email: to,
AppURL: os.Getenv("APP_URL"),
AppName: "GoHorse Jobs",
}
html, err := renderTemplate(welcomeTemplate, data)
if err != nil {
return err
}
return s.SendEmail([]string{to}, "🐴 Bem-vindo ao GoHorse Jobs!", html, "")
}
// PasswordResetData contains data for password reset email
type PasswordResetData struct {
Name string
ResetLink string
AppName string
ExpiresIn string
}
// SendPasswordReset sends a password reset email
func (s *EmailService) SendPasswordReset(to, name, resetToken string) error {
appURL := os.Getenv("APP_URL")
if appURL == "" {
appURL = "https://gohorsejobs.com"
}
data := PasswordResetData{
Name: name,
ResetLink: fmt.Sprintf("%s/reset-password?token=%s", appURL, resetToken),
AppName: "GoHorse Jobs",
ExpiresIn: "1 hora",
}
html, err := renderTemplate(passwordResetTemplate, data)
if err != nil {
return err
}
return s.SendEmail([]string{to}, "🔑 Redefinição de senha - GoHorse Jobs", html, "")
}
// ApplicationReceivedData contains data for application notification
type ApplicationReceivedData struct {
ApplicantName string
JobTitle string
CompanyName string
JobURL string
AppName string
}
// SendApplicationReceived notifies recruiter about new application
func (s *EmailService) SendApplicationReceived(to, applicantName, jobTitle, companyName, jobID string) error {
appURL := os.Getenv("APP_URL")
if appURL == "" {
appURL = "https://gohorsejobs.com"
}
data := ApplicationReceivedData{
ApplicantName: applicantName,
JobTitle: jobTitle,
CompanyName: companyName,
JobURL: fmt.Sprintf("%s/jobs/%s/applications", appURL, jobID),
AppName: "GoHorse Jobs",
}
html, err := renderTemplate(applicationReceivedTemplate, data)
if err != nil {
return err
}
subject := fmt.Sprintf("📩 Nova candidatura para %s", jobTitle)
return s.SendEmail([]string{to}, subject, html, "")
}
// Helper function to render templates
func renderTemplate(tmplStr string, data interface{}) (string, error) {
tmpl, err := template.New("email").Parse(tmplStr)
if err != nil {
return "", fmt.Errorf("failed to parse template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to execute template: %w", err)
}
return buf.String(), nil
}
// Email templates
const welcomeTemplate = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #10b981, #059669); padding: 30px; text-align: center; }
.header h1 { color: white; margin: 0; font-size: 24px; }
.content { padding: 30px; }
.btn { display: inline-block; background: #10b981; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; margin-top: 20px; }
.footer { background: #f8f8f8; padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🐴 Bem-vindo ao {{.AppName}}!</h1>
</div>
<div class="content">
<p>Olá, <strong>{{.Name}}</strong>!</p>
<p>Sua conta foi criada com sucesso. Agora você pode:</p>
<ul>
<li>🔍 Buscar vagas de emprego</li>
<li>📝 Candidatar-se às melhores oportunidades</li>
<li>💼 Gerenciar seu perfil profissional</li>
</ul>
<a href="{{.AppURL}}/jobs" class="btn">Ver Vagas Disponíveis</a>
</div>
<div class="footer">
<p>© 2024 {{.AppName}}. Todos os direitos reservados.</p>
</div>
</div>
</body>
</html>
`
const passwordResetTemplate = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #f59e0b, #d97706); padding: 30px; text-align: center; }
.header h1 { color: white; margin: 0; font-size: 24px; }
.content { padding: 30px; }
.btn { display: inline-block; background: #f59e0b; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; margin-top: 20px; }
.warning { background: #fef3cd; border: 1px solid #ffc107; padding: 15px; border-radius: 8px; margin-top: 20px; }
.footer { background: #f8f8f8; padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔑 Redefinição de Senha</h1>
</div>
<div class="content">
<p>Olá, <strong>{{.Name}}</strong>!</p>
<p>Recebemos uma solicitação para redefinir a senha da sua conta.</p>
<a href="{{.ResetLink}}" class="btn">Redefinir Minha Senha</a>
<div class="warning">
<strong> Atenção:</strong> Este link expira em <strong>{{.ExpiresIn}}</strong>.
Se você não solicitou esta redefinição, ignore este email.
</div>
</div>
<div class="footer">
<p>© 2024 {{.AppName}}. Todos os direitos reservados.</p>
</div>
</div>
</body>
</html>
`
const applicationReceivedTemplate = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #3b82f6, #1d4ed8); padding: 30px; text-align: center; }
.header h1 { color: white; margin: 0; font-size: 24px; }
.content { padding: 30px; }
.job-card { background: #f0f9ff; border: 1px solid #bae6fd; padding: 20px; border-radius: 8px; margin: 20px 0; }
.btn { display: inline-block; background: #3b82f6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; margin-top: 20px; }
.footer { background: #f8f8f8; padding: 20px; text-align: center; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📩 Nova Candidatura!</h1>
</div>
<div class="content">
<p>Uma nova candidatura foi recebida para a vaga em <strong>{{.CompanyName}}</strong>:</p>
<div class="job-card">
<h3 style="margin: 0 0 10px 0;">{{.JobTitle}}</h3>
<p style="margin: 0;"><strong>Candidato:</strong> {{.ApplicantName}}</p>
</div>
<a href="{{.JobURL}}" class="btn">Ver Candidatura</a>
</div>
<div class="footer">
<p>© 2024 {{.AppName}}. Todos os direitos reservados.</p>
</div>
</div>
</body>
</html>
`

View file

@ -132,6 +132,58 @@ func (s *JobService) GetJobs(filter dto.JobFilterQuery) ([]models.JobWithCompany
argId++
}
// --- Advanced Filters ---
if filter.VisaSupport != nil {
baseQuery += fmt.Sprintf(" AND j.visa_support = $%d", argId)
countQuery += fmt.Sprintf(" AND j.visa_support = $%d", argId)
args = append(args, *filter.VisaSupport)
argId++
}
if filter.SalaryMin != nil {
baseQuery += fmt.Sprintf(" AND j.salary_min >= $%d", argId)
countQuery += fmt.Sprintf(" AND j.salary_min >= $%d", argId)
args = append(args, *filter.SalaryMin)
argId++
}
if filter.SalaryMax != nil {
baseQuery += fmt.Sprintf(" AND j.salary_max <= $%d", argId)
countQuery += fmt.Sprintf(" AND j.salary_max <= $%d", argId)
args = append(args, *filter.SalaryMax)
argId++
}
if filter.Currency != nil && *filter.Currency != "" && *filter.Currency != "all" {
baseQuery += fmt.Sprintf(" AND j.currency = $%d", argId)
countQuery += fmt.Sprintf(" AND j.currency = $%d", argId)
args = append(args, *filter.Currency)
argId++
}
if filter.LanguageLevel != nil && *filter.LanguageLevel != "" && *filter.LanguageLevel != "all" {
baseQuery += fmt.Sprintf(" AND j.language_level = $%d", argId)
countQuery += fmt.Sprintf(" AND j.language_level = $%d", argId)
args = append(args, *filter.LanguageLevel)
argId++
}
// --- Sorting ---
sortClause := " ORDER BY j.is_featured DESC, j.created_at DESC" // default
if filter.SortBy != nil {
switch *filter.SortBy {
case "recent":
sortClause = " ORDER BY j.created_at DESC"
case "salary_asc":
sortClause = " ORDER BY j.salary_min ASC NULLS LAST"
case "salary_desc":
sortClause = " ORDER BY j.salary_max DESC NULLS LAST"
case "relevance":
sortClause = " ORDER BY j.is_featured DESC, j.created_at DESC"
}
}
baseQuery += sortClause
// Pagination
limit := filter.Limit
if limit == 0 {

View file

@ -0,0 +1,111 @@
{
"common": {
"appName": "GoHorse Jobs",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"view": "View",
"search": "Search",
"filter": "Filter",
"clear": "Clear",
"apply": "Apply",
"close": "Close",
"back": "Back",
"next": "Next",
"previous": "Previous",
"submit": "Submit",
"confirm": "Confirm",
"yes": "Yes",
"no": "No"
},
"nav": {
"home": "Home",
"jobs": "Jobs",
"companies": "Companies",
"about": "About",
"contact": "Contact",
"login": "Login",
"register": "Sign Up",
"logout": "Logout",
"profile": "Profile",
"dashboard": "Dashboard",
"settings": "Settings"
},
"auth": {
"login": "Login",
"register": "Create account",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm password",
"forgotPassword": "Forgot password?",
"resetPassword": "Reset password",
"rememberMe": "Remember me",
"noAccount": "Don't have an account?",
"hasAccount": "Already have an account?",
"createAccount": "Create account",
"loginSuccess": "Login successful!",
"logoutSuccess": "You have been logged out.",
"invalidCredentials": "Invalid email or password."
},
"jobs": {
"title": "Jobs",
"searchPlaceholder": "Search jobs...",
"filter": {
"all": "All",
"location": "Location",
"type": "Type",
"workMode": "Work Mode",
"salary": "Salary",
"remote": "Remote",
"hybrid": "Hybrid",
"onsite": "On-site",
"fullTime": "Full-time",
"partTime": "Part-time",
"contract": "Contract",
"temporary": "Temporary"
},
"apply": "Apply Now",
"applied": "Application Sent",
"save": "Save Job",
"saved": "Job Saved",
"share": "Share",
"noResults": "No jobs found.",
"postedAt": "Posted",
"salary": "Salary",
"benefits": "Benefits",
"requirements": "Requirements",
"description": "Description"
},
"profile": {
"title": "My Profile",
"name": "Name",
"email": "Email",
"phone": "Phone",
"city": "City",
"bio": "About me",
"skills": "Skills",
"experience": "Experience",
"education": "Education",
"resume": "Resume",
"uploadResume": "Upload resume"
},
"company": {
"title": "Company",
"name": "Company name",
"about": "About the company",
"employees": "Employees",
"industry": "Industry",
"website": "Website",
"location": "Location",
"jobs": "Open positions"
},
"footer": {
"rights": "All rights reserved.",
"privacy": "Privacy",
"terms": "Terms of use"
}
}

View file

@ -0,0 +1,111 @@
{
"common": {
"appName": "GoHorse Jobs",
"loading": "Cargando...",
"error": "Error",
"success": "Éxito",
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"edit": "Editar",
"view": "Ver",
"search": "Buscar",
"filter": "Filtrar",
"clear": "Limpiar",
"apply": "Aplicar",
"close": "Cerrar",
"back": "Volver",
"next": "Siguiente",
"previous": "Anterior",
"submit": "Enviar",
"confirm": "Confirmar",
"yes": "Sí",
"no": "No"
},
"nav": {
"home": "Inicio",
"jobs": "Empleos",
"companies": "Empresas",
"about": "Acerca de",
"contact": "Contacto",
"login": "Iniciar sesión",
"register": "Registrarse",
"logout": "Cerrar sesión",
"profile": "Perfil",
"dashboard": "Panel",
"settings": "Configuración"
},
"auth": {
"login": "Iniciar sesión",
"register": "Crear cuenta",
"email": "Correo electrónico",
"password": "Contraseña",
"confirmPassword": "Confirmar contraseña",
"forgotPassword": "¿Olvidaste tu contraseña?",
"resetPassword": "Restablecer contraseña",
"rememberMe": "Recuérdame",
"noAccount": "¿No tienes una cuenta?",
"hasAccount": "¿Ya tienes una cuenta?",
"createAccount": "Crear cuenta",
"loginSuccess": "¡Inicio de sesión exitoso!",
"logoutSuccess": "Has cerrado sesión.",
"invalidCredentials": "Correo electrónico o contraseña inválidos."
},
"jobs": {
"title": "Empleos",
"searchPlaceholder": "Buscar empleos...",
"filter": {
"all": "Todos",
"location": "Ubicación",
"type": "Tipo",
"workMode": "Modalidad",
"salary": "Salario",
"remote": "Remoto",
"hybrid": "Híbrido",
"onsite": "Presencial",
"fullTime": "Tiempo completo",
"partTime": "Medio tiempo",
"contract": "Contrato",
"temporary": "Temporal"
},
"apply": "Postularse",
"applied": "Postulación enviada",
"save": "Guardar empleo",
"saved": "Empleo guardado",
"share": "Compartir",
"noResults": "No se encontraron empleos.",
"postedAt": "Publicado hace",
"salary": "Salario",
"benefits": "Beneficios",
"requirements": "Requisitos",
"description": "Descripción"
},
"profile": {
"title": "Mi Perfil",
"name": "Nombre",
"email": "Correo electrónico",
"phone": "Teléfono",
"city": "Ciudad",
"bio": "Sobre mí",
"skills": "Habilidades",
"experience": "Experiencia",
"education": "Educación",
"resume": "Currículum",
"uploadResume": "Subir currículum"
},
"company": {
"title": "Empresa",
"name": "Nombre de la empresa",
"about": "Sobre la empresa",
"employees": "Empleados",
"industry": "Industria",
"website": "Sitio web",
"location": "Ubicación",
"jobs": "Vacantes"
},
"footer": {
"rights": "Todos los derechos reservados.",
"privacy": "Privacidad",
"terms": "Términos de uso"
}
}

View file

@ -0,0 +1,111 @@
{
"common": {
"appName": "GoHorse Jobs",
"loading": "読み込み中...",
"error": "エラー",
"success": "成功",
"save": "保存",
"cancel": "キャンセル",
"delete": "削除",
"edit": "編集",
"view": "表示",
"search": "検索",
"filter": "フィルター",
"clear": "クリア",
"apply": "適用",
"close": "閉じる",
"back": "戻る",
"next": "次へ",
"previous": "前へ",
"submit": "送信",
"confirm": "確認",
"yes": "はい",
"no": "いいえ"
},
"nav": {
"home": "ホーム",
"jobs": "求人",
"companies": "企業",
"about": "概要",
"contact": "お問い合わせ",
"login": "ログイン",
"register": "新規登録",
"logout": "ログアウト",
"profile": "プロフィール",
"dashboard": "ダッシュボード",
"settings": "設定"
},
"auth": {
"login": "ログイン",
"register": "アカウント作成",
"email": "メールアドレス",
"password": "パスワード",
"confirmPassword": "パスワード確認",
"forgotPassword": "パスワードをお忘れですか?",
"resetPassword": "パスワードリセット",
"rememberMe": "ログイン状態を保持",
"noAccount": "アカウントをお持ちでないですか?",
"hasAccount": "すでにアカウントをお持ちですか?",
"createAccount": "アカウント作成",
"loginSuccess": "ログインしました!",
"logoutSuccess": "ログアウトしました。",
"invalidCredentials": "メールアドレスまたはパスワードが正しくありません。"
},
"jobs": {
"title": "求人情報",
"searchPlaceholder": "求人を検索...",
"filter": {
"all": "すべて",
"location": "勤務地",
"type": "雇用形態",
"workMode": "勤務形態",
"salary": "給与",
"remote": "リモート",
"hybrid": "ハイブリッド",
"onsite": "出社",
"fullTime": "正社員",
"partTime": "パート",
"contract": "契約",
"temporary": "派遣"
},
"apply": "応募する",
"applied": "応募済み",
"save": "保存する",
"saved": "保存済み",
"share": "共有",
"noResults": "求人が見つかりませんでした。",
"postedAt": "掲載日",
"salary": "給与",
"benefits": "福利厚生",
"requirements": "応募条件",
"description": "仕事内容"
},
"profile": {
"title": "マイプロフィール",
"name": "名前",
"email": "メールアドレス",
"phone": "電話番号",
"city": "都市",
"bio": "自己紹介",
"skills": "スキル",
"experience": "経験",
"education": "学歴",
"resume": "履歴書",
"uploadResume": "履歴書をアップロード"
},
"company": {
"title": "企業情報",
"name": "会社名",
"about": "企業概要",
"employees": "従業員数",
"industry": "業界",
"website": "ウェブサイト",
"location": "所在地",
"jobs": "募集中の求人"
},
"footer": {
"rights": "全著作権所有。",
"privacy": "プライバシー",
"terms": "利用規約"
}
}

View file

@ -0,0 +1,111 @@
{
"common": {
"appName": "GoHorse Jobs",
"loading": "Carregando...",
"error": "Erro",
"success": "Sucesso",
"save": "Salvar",
"cancel": "Cancelar",
"delete": "Excluir",
"edit": "Editar",
"view": "Ver",
"search": "Buscar",
"filter": "Filtrar",
"clear": "Limpar",
"apply": "Aplicar",
"close": "Fechar",
"back": "Voltar",
"next": "Próximo",
"previous": "Anterior",
"submit": "Enviar",
"confirm": "Confirmar",
"yes": "Sim",
"no": "Não"
},
"nav": {
"home": "Início",
"jobs": "Vagas",
"companies": "Empresas",
"about": "Sobre",
"contact": "Contato",
"login": "Entrar",
"register": "Cadastrar",
"logout": "Sair",
"profile": "Perfil",
"dashboard": "Painel",
"settings": "Configurações"
},
"auth": {
"login": "Entrar",
"register": "Criar conta",
"email": "E-mail",
"password": "Senha",
"confirmPassword": "Confirmar senha",
"forgotPassword": "Esqueceu a senha?",
"resetPassword": "Redefinir senha",
"rememberMe": "Lembrar-me",
"noAccount": "Não tem uma conta?",
"hasAccount": "Já tem uma conta?",
"createAccount": "Criar conta",
"loginSuccess": "Login realizado com sucesso!",
"logoutSuccess": "Você saiu da sua conta.",
"invalidCredentials": "E-mail ou senha inválidos."
},
"jobs": {
"title": "Vagas",
"searchPlaceholder": "Buscar vagas...",
"filter": {
"all": "Todas",
"location": "Localização",
"type": "Tipo",
"workMode": "Modelo",
"salary": "Salário",
"remote": "Remoto",
"hybrid": "Híbrido",
"onsite": "Presencial",
"fullTime": "Tempo integral",
"partTime": "Meio período",
"contract": "Contrato",
"temporary": "Temporário"
},
"apply": "Candidatar-se",
"applied": "Candidatura enviada",
"save": "Salvar vaga",
"saved": "Vaga salva",
"share": "Compartilhar",
"noResults": "Nenhuma vaga encontrada.",
"postedAt": "Publicada há",
"salary": "Salário",
"benefits": "Benefícios",
"requirements": "Requisitos",
"description": "Descrição"
},
"profile": {
"title": "Meu Perfil",
"name": "Nome",
"email": "E-mail",
"phone": "Telefone",
"city": "Cidade",
"bio": "Sobre mim",
"skills": "Habilidades",
"experience": "Experiência",
"education": "Formação",
"resume": "Currículo",
"uploadResume": "Enviar currículo"
},
"company": {
"title": "Empresa",
"name": "Nome da empresa",
"about": "Sobre a empresa",
"employees": "Funcionários",
"industry": "Setor",
"website": "Site",
"location": "Localização",
"jobs": "Vagas abertas"
},
"footer": {
"rights": "Todos os direitos reservados.",
"privacy": "Privacidade",
"terms": "Termos de uso"
}
}