From 9f7d8e9ca55275ffe7b5455ec1bd7860664f9246 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Tue, 23 Dec 2025 23:00:17 -0300 Subject: [PATCH] feat: add 6-step job wizard with Preview, Billing, Payment steps and Stripe integration preparation --- .../019_create_job_payments_table.sql | 75 +++ docs/DATABASE.md | 34 ++ frontend/package-lock.json | 2 +- frontend/package.json | 2 +- frontend/src/app/dashboard/jobs/new/page.tsx | 426 ++++++++++++++---- frontend/src/components/ui/radio-group.tsx | 44 ++ 6 files changed, 492 insertions(+), 91 deletions(-) create mode 100644 backend/migrations/019_create_job_payments_table.sql create mode 100644 frontend/src/components/ui/radio-group.tsx diff --git a/backend/migrations/019_create_job_payments_table.sql b/backend/migrations/019_create_job_payments_table.sql new file mode 100644 index 0000000..128bf6f --- /dev/null +++ b/backend/migrations/019_create_job_payments_table.sql @@ -0,0 +1,75 @@ +-- Migration: Create job_payments table +-- Description: Track payments for job postings + +CREATE TABLE IF NOT EXISTS job_payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_id UUID NOT NULL, + user_id UUID, + + -- Stripe + stripe_session_id VARCHAR(255), + stripe_payment_intent VARCHAR(255), + stripe_customer_id VARCHAR(255), + + -- Amount + amount DECIMAL(12,2) NOT NULL, + currency VARCHAR(3) DEFAULT 'USD', + + -- Status + status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'completed', 'failed', 'refunded', 'expired')), + + -- Billing Info + billing_type VARCHAR(20) CHECK (billing_type IN ('company', 'individual')), + billing_name VARCHAR(255), + billing_email VARCHAR(255), + billing_phone VARCHAR(50), + billing_address TEXT, + billing_city VARCHAR(100), + billing_state VARCHAR(100), + billing_zip VARCHAR(20), + billing_country VARCHAR(2) DEFAULT 'BR', + + -- Job Posting Details + duration_days INT DEFAULT 30, + is_featured BOOLEAN DEFAULT false, + featured_price DECIMAL(12,2) DEFAULT 0, + + -- Timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + paid_at TIMESTAMP, + expires_at TIMESTAMP, + + -- Foreign key (UUID to match jobs table) + FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE +); + +-- Indexes +CREATE INDEX idx_job_payments_job_id ON job_payments(job_id); +CREATE INDEX idx_job_payments_status ON job_payments(status); +CREATE INDEX idx_job_payments_stripe_session ON job_payments(stripe_session_id); +CREATE INDEX idx_job_payments_user ON job_payments(user_id); + +-- Comments +COMMENT ON TABLE job_payments IS 'Payment records for job postings'; +COMMENT ON COLUMN job_payments.duration_days IS 'How many days the job posting is active'; +COMMENT ON COLUMN job_payments.is_featured IS 'Whether this is a featured/sponsored listing'; + +-- Add job_posting_price table for pricing configuration +CREATE TABLE IF NOT EXISTS job_posting_prices ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + price DECIMAL(12,2) NOT NULL, + currency VARCHAR(3) DEFAULT 'USD', + duration_days INT DEFAULT 30, + is_featured BOOLEAN DEFAULT false, + stripe_price_id VARCHAR(255), + active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Insert default pricing +INSERT INTO job_posting_prices (name, description, price, currency, duration_days, is_featured, active) +VALUES + ('Standard Job Posting', 'Your job will be visible for 30 days', 32.61, 'USD', 30, false, true), + ('Featured Job Posting', 'Your job will be featured and visible for 30 days', 47.61, 'USD', 30, true, true); diff --git a/docs/DATABASE.md b/docs/DATABASE.md index d1777a5..dc217ae 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -109,6 +109,34 @@ erDiagram int user_id FK int job_id FK } + + job_payments { + uuid id PK + uuid job_id FK + uuid user_id FK + varchar stripe_session_id + varchar stripe_payment_intent + decimal amount + varchar currency + varchar status + varchar billing_type + varchar billing_name + varchar billing_email + int duration_days + timestamp created_at + timestamp paid_at + } + + job_posting_prices { + int id PK + varchar name + decimal price + varchar currency + int duration_days + boolean is_featured + varchar stripe_price_id + boolean active + } users ||--o{ user_companies : "belongs to" companies ||--o{ user_companies : "has members" @@ -116,6 +144,12 @@ erDiagram users ||--o{ jobs : "creates" jobs ||--o{ applications : "receives" users ||--o{ applications : "submits" + jobs ||--o{ job_payments : "has payment" + companies ||--o{ user_companies : "has members" + companies ||--o{ jobs : "posts" + users ||--o{ jobs : "creates" + jobs ||--o{ applications : "receives" + users ||--o{ applications : "submits" regions ||--o{ cities : "contains" regions ||--o{ companies : "located in" cities ||--o{ companies : "located in" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5dcc385..d762b37 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,7 @@ "@radix-ui/react-navigation-menu": "1.2.3", "@radix-ui/react-popover": "1.1.4", "@radix-ui/react-progress": "1.1.1", - "@radix-ui/react-radio-group": "1.2.2", + "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "1.2.2", "@radix-ui/react-select": "2.1.4", "@radix-ui/react-separator": "1.1.1", diff --git a/frontend/package.json b/frontend/package.json index 0999b23..22cd8e6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,7 +26,7 @@ "@radix-ui/react-navigation-menu": "1.2.3", "@radix-ui/react-popover": "1.1.4", "@radix-ui/react-progress": "1.1.1", - "@radix-ui/react-radio-group": "1.2.2", + "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "1.2.2", "@radix-ui/react-select": "2.1.4", "@radix-ui/react-separator": "1.1.1", diff --git a/frontend/src/app/dashboard/jobs/new/page.tsx b/frontend/src/app/dashboard/jobs/new/page.tsx index 02326a3..446f1e1 100644 --- a/frontend/src/app/dashboard/jobs/new/page.tsx +++ b/frontend/src/app/dashboard/jobs/new/page.tsx @@ -8,7 +8,10 @@ import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { ArrowLeft, ArrowRight, Check, Loader2, Building2, DollarSign, FileText, Eye } from "lucide-react" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { ArrowLeft, ArrowRight, Check, Loader2, Building2, DollarSign, FileText, Eye, CreditCard, Receipt, MapPin, Clock, Briefcase } from "lucide-react" import { jobsApi, adminCompaniesApi, type CreateJobPayload, type AdminCompany } from "@/lib/api" import { toast } from "sonner" import { cn } from "@/lib/utils" @@ -17,9 +20,14 @@ const STEPS = [ { id: 1, title: "Job Details", icon: FileText }, { id: 2, title: "Salary & Type", icon: DollarSign }, { id: 3, title: "Company", icon: Building2 }, - { id: 4, title: "Review", icon: Eye }, + { id: 4, title: "Preview", icon: Eye }, + { id: 5, title: "Billing", icon: Receipt }, + { id: 6, title: "Payment", icon: CreditCard }, ] +const JOB_POSTING_PRICE = 32.61 +const FEATURED_PRICE = 15.00 + export default function NewJobPage() { const router = useRouter() const [currentStep, setCurrentStep] = useState(1) @@ -28,16 +36,36 @@ export default function NewJobPage() { const [loadingCompanies, setLoadingCompanies] = useState(true) const [formData, setFormData] = useState({ + // Job Details title: "", description: "", - companyId: "", location: "", - employmentType: "", + // Salary salaryMin: "", salaryMax: "", - salaryType: "", + salaryType: "monthly", currency: "BRL", + employmentType: "", workingHours: "", + // Company + companyId: "", + // Options + isFeatured: false, + }) + + const [billingData, setBillingData] = useState({ + type: "company" as "company" | "individual", + firstName: "", + lastName: "", + email: "", + phone: "", + companyName: "", + address: "", + addressLine2: "", + city: "", + state: "", + zip: "", + country: "BR", }) useEffect(() => { @@ -56,31 +84,39 @@ export default function NewJobPage() { loadCompanies() }, []) - const updateField = (field: string, value: string) => { + const updateField = (field: string, value: string | boolean) => { setFormData(prev => ({ ...prev, [field]: value })) } + const updateBilling = (field: string, value: string) => { + setBillingData(prev => ({ ...prev, [field]: value })) + } + + const selectedCompany = companies.find(c => c.id === formData.companyId) + const totalPrice = JOB_POSTING_PRICE + (formData.isFeatured ? FEATURED_PRICE : 0) + const canProceed = () => { switch (currentStep) { case 1: return formData.title.length >= 5 && formData.description.length >= 20 case 2: - return true // Salary is optional + return true case 3: return formData.companyId !== "" + case 4: + return true // Preview is always valid + case 5: + return billingData.firstName && billingData.lastName && billingData.email && + (billingData.type === "individual" || billingData.companyName) default: return true } } - const handleSubmit = async () => { - if (!formData.companyId || !formData.title || !formData.description) { - toast.error("Please fill in all required fields") - return - } - + const handlePayment = async () => { setIsSubmitting(true) try { + // First create the job as draft const payload: CreateJobPayload = { companyId: formData.companyId, title: formData.title, @@ -92,23 +128,26 @@ export default function NewJobPage() { salaryType: formData.salaryType as CreateJobPayload['salaryType'] || undefined, currency: formData.currency as CreateJobPayload['currency'] || undefined, workingHours: formData.workingHours || undefined, - status: "published", + status: "draft", // Will be published after payment } - console.log("[DEBUG] Submitting job:", payload) - await jobsApi.create(payload) - toast.success("Job posted successfully!") + console.log("[DEBUG] Creating draft job:", payload) + const job = await jobsApi.create(payload) + + // TODO: Create Stripe checkout session and redirect + // For now, simulate success + toast.success("Job created! Payment integration coming soon.") router.push("/dashboard/jobs") } catch (error) { console.error("Failed to create job:", error) - toast.error("Failed to post job. Please try again.") + toast.error("Failed to create job. Please try again.") } finally { setIsSubmitting(false) } } const nextStep = () => { - if (currentStep < 4 && canProceed()) { + if (currentStep < 6 && canProceed()) { setCurrentStep(prev => prev + 1) } } @@ -119,28 +158,33 @@ export default function NewJobPage() { } } + const getCurrencySymbol = (currency: string) => { + const symbols: Record = { BRL: "R$", USD: "$", EUR: "€", GBP: "£", JPY: "¥" } + return symbols[currency] || currency + } + return ( -
+
{/* Header */}
-

Post a new job

-

Fill in the details for your job listing

+

Post a job

+

Create your job listing in a few steps

{/* Progress Steps */} -
+
{STEPS.map((step, index) => { const Icon = step.icon const isActive = currentStep === step.id const isCompleted = currentStep > step.id return ( -
+
- {isCompleted ? : } + {isCompleted ? : }
@@ -166,7 +210,7 @@ export default function NewJobPage() { {index < STEPS.length - 1 && (
step.id ? "bg-primary" : "bg-muted-foreground/30" )} /> @@ -182,9 +226,11 @@ export default function NewJobPage() { {STEPS[currentStep - 1].title} {currentStep === 1 && "Enter the basic information about this job"} - {currentStep === 2 && "Set the compensation details"} + {currentStep === 2 && "Set the compensation and employment details"} {currentStep === 3 && "Select the company posting this job"} - {currentStep === 4 && "Review your job listing before publishing"} + {currentStep === 4 && "Preview how your job will appear"} + {currentStep === 5 && "Enter your billing information"} + {currentStep === 6 && "Complete your payment to publish"} @@ -233,9 +279,8 @@ export default function NewJobPage() { <>
- +
- + updateField("salaryType", v)}> - - - + Per hour Per day @@ -287,9 +327,7 @@ export default function NewJobPage() {
- + updateField("workingHours", e.target.value)} @@ -326,9 +362,7 @@ export default function NewJobPage() {
) : ( updateField("isFeatured", e.target.checked)} + className="h-4 w-4" + /> + +
+
+ )} + + {/* Step 5: Billing */} + {currentStep === 5 && ( +
+
+ + updateBilling("type", v)} + className="space-y-2" + > +
+ + +
+
+ + +
+
+
+
-
-

Job Title

-

{formData.title || "-"}

+
+ + updateBilling("firstName", e.target.value)} + />
-
-

Company

-

- {companies.find(c => c.id === formData.companyId)?.name || "-"} -

-
-
-

Location

-

{formData.location || "-"}

-
-
-

Employment Type

-

{formData.employmentType || "-"}

-
-
-

Salary

-

- {formData.salaryMin || formData.salaryMax - ? `${formData.currency} ${formData.salaryMin || "?"} - ${formData.salaryMax || "?"} ${formData.salaryType ? `(${formData.salaryType})` : ""}` - : "-"} -

-
-
-

Working Hours

-

{formData.workingHours || "-"}

+
+ + updateBilling("lastName", e.target.value)} + />
-
-

Description

-
- {formData.description || "-"} + +
+
+ + updateBilling("email", e.target.value)} + /> +
+
+ + updateBilling("phone", e.target.value)} + /> +
+
+ + {billingData.type === "company" && ( +
+ + updateBilling("companyName", e.target.value)} + /> +
+ )} + +
+ + updateBilling("address", e.target.value)} + /> + updateBilling("addressLine2", e.target.value)} + /> +
+ +
+
+ + updateBilling("city", e.target.value)} + /> +
+
+ + updateBilling("state", e.target.value)} + /> +
+
+ + updateBilling("zip", e.target.value)} + /> +
+
+ +
+ + +
+
+ )} + + {/* Step 6: Payment */} + {currentStep === 6 && ( +
+
+ + VISA + + + MC + + + AMEX + +
+ +
+
+ Your order + 1 job posting +
+
+ Duration + 30 days +
+ {formData.isFeatured && ( +
+ Featured upgrade + +${FEATURED_PRICE.toFixed(2)} +
+ )} + +
+ Total amount due + ${totalPrice.toFixed(2)} +
+
+ +
+

+ You will be redirected to Stripe to complete your payment securely. +

+
+ +
+
+ + Once you have paid, your job posting will be online within a few minutes! +
+
+ + You will be able to modify the job description at any time +
+
+ + Your job ad will be visible for 30 days
@@ -398,22 +646,22 @@ export default function NewJobPage() { Back - {currentStep < 4 ? ( + {currentStep < 6 ? ( ) : ( - diff --git a/frontend/src/components/ui/radio-group.tsx b/frontend/src/components/ui/radio-group.tsx new file mode 100644 index 0000000..0ae3fee --- /dev/null +++ b/frontend/src/components/ui/radio-group.tsx @@ -0,0 +1,44 @@ +"use client" + +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ) +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + + + + + ) +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem }