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:
parent
7310627bee
commit
38a94bcbce
9 changed files with 1103 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
290
backend/internal/handlers/payment_handler.go
Normal file
290
backend/internal/handlers/payment_handler.go
Normal 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, ",")
|
||||
}
|
||||
304
backend/internal/infrastructure/email/email_service.go
Normal file
304
backend/internal/infrastructure/email/email_service.go
Normal 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>
|
||||
`
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
111
frontend/messages/en-US.json
Normal file
111
frontend/messages/en-US.json
Normal 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"
|
||||
}
|
||||
}
|
||||
111
frontend/messages/es-ES.json
Normal file
111
frontend/messages/es-ES.json
Normal 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"
|
||||
}
|
||||
}
|
||||
111
frontend/messages/ja-JP.json
Normal file
111
frontend/messages/ja-JP.json
Normal 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": "利用規約"
|
||||
}
|
||||
}
|
||||
111
frontend/messages/pt-BR.json
Normal file
111
frontend/messages/pt-BR.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue