Markets That Move: Engineering a Freelance Platform with Exertio

gplpal

Orientation for the dev crowd

Most freelance marketplaces fail for boring reasons: noisy onboarding, vague briefs, and payment friction. The fix isn’t a “wow” slider—it’s predictable flows you can defend under real traffic: brief → shortlist → proposal → milestone → delivery → payout. In this guide, I’ll treat Exertio - Freelance Marketplace WordPress Theme as the presentational layer and wire it with PHP-first patterns—hooks, filters, template discipline, server-side validations—so you ship a platform that feels calm at 2 a.m. and during campaign spikes. You’ll see Exertio - Freelance Marketplace WordPress Theme referenced again when we pin down roles, RFP objects, bids, milestones, and payouts.



What “good” looks like for a freelance marketplace

  • Above the fold: one-line promise + single CTA pair (“Post a job”, “Find work”).
  • Onboarding: 2-step client intake (use case, budget band) and 2-step freelancer intake (skills, hourly band).
  • Search & shortlist: predictable filters; ranking that favors verified, responsive, recent work.
  • Proposal flow: milestone-first; time & materials or fixed-fee with caps.
  • Escrow: funds reserved before work starts; releases tied to accepted deliverables.
  • Dispute path: visible and time-bounded; clear evidence checklist.
  • Performance: field LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1 on home/jobs/profile/checkout.
  • Accessibility: keyboardable filters, visible focus states, contrast ≥ 4.5:1.
  • Rollback: every widget gets an owner, a metric, and a kill switch.

Architecture blueprint (theme-agnostic, PHP-first)

1) Roles, caps, and guardrails

Keep auth simple: client, freelancer, and a narrow market_moderator.

// mu-plugins/market-roles.php
add_action('init', function () {
    add_role('client', 'Client', ['read' => true]);
    add_role('freelancer', 'Freelancer', ['read' => true, 'upload_files' => true]);
    // Grant moderation to editors you trust
    $role = get_role('editor');
    $role && $role->add_cap('market_moderate', true);
});

2) Job & proposal as first-class objects (CPTs)

Jobs and Proposals shouldn’t be “misc” posts. Give them stable slugs and compact meta.

// mu-plugins/market-cpts.php
add_action('init', function () {
    register_post_type('job', [
        'label' => 'Jobs','public' => true,'has_archive' => true,
        'supports' => ['title','editor','author','thumbnail','excerpt'],
        'rewrite' => ['slug' => 'jobs'],'show_in_rest' => true,
    ]);
    register_post_type('proposal', [
        'label' => 'Proposals','public' => false,'show_ui' => true,
        'supports' => ['title','editor','author'],'rewrite' => ['slug' => 'proposals'],
        'show_in_rest' => true,
    ]);
});

Minimal, helpful meta:

// mu-plugins/market-meta.php
add_action('add_meta_boxes', function(){
    add_meta_box('job_meta','Job Details', function($post){
        $budget = get_post_meta($post->ID,'budget', true);
        $type   = get_post_meta($post->ID,'engagement', true); // fixed|tm
        ?>
        <p><label>Budget <input name="budget" value="<?= esc_attr($budget) ?>"></label></p>
        <p><label>Engagement
            <select name="engagement">
              <option value="fixed" <?= selected($type,'fixed',false) ?>>Fixed fee</option>
              <option value="tm"    <?= selected($type,'tm',false) ?>>Time & Materials</option>
            </select></label></p>
        'proposal','author'=>$uid,'meta_key'=>'job_id','meta_value'=>$job_id,'fields'=>'ids']))
        $err[]='You already proposed.';

    if($err){
        wp_safe_redirect( add_query_arg(['p_err'=>urlencode(implode(' ', $err))], get_permalink($job_id)) );
        exit;
    }

    $pid = wp_insert_post([
        'post_type'=>'proposal','post_title'=>"Proposal for #$job_id",'post_status'=>'publish','post_author'=>$uid,
        'post_content'=>$cover
    ]);
    update_post_meta($pid,'job_id',$job_id);
    update_post_meta($pid,'amount',$amount);
    update_post_meta($pid,'status','submitted'); // submitted|accepted|declined

    wp_safe_redirect( add_query_arg(['p_ok'=>1], get_permalink($job_id)) ); exit;
}

Form skeleton:

<form method="post" action="/wp-admin/admin-post.php">
  <input type="hidden" name="action" value="market_submit_proposal">
  <input type="hidden" name="job_id" value="<?php the_ID(); ?>">
  <label>Amount ($) <input name="amount" type="number" step="0.01" required></label>
  <label>Cover letter <textarea name="cover" minlength="60" required></textarea></label>
  <button type="submit">Submit proposal</button>
</form>

4) Milestones you can audit

Treat milestones as concise records tied to proposals.

// mu-plugins/market-milestones.php
add_action('init', function(){
    register_post_type('milestone', [
        'label'=>'Milestones','public'=>false,'show_ui'=>true,'supports'=>['title','editor','author'],
        'show_in_rest'=>true
    ]);
});
function market_add_milestone($proposal_id, $title, $amount){
    $mid = wp_insert_post([
        'post_type'=>'milestone','post_title'=>$title,'post_status'=>'publish','post_author'=>get_current_user_id()
    ]);
    update_post_meta($mid,'proposal_id',$proposal_id);
    update_post_meta($mid,'amount',(float)$amount);
    update_post_meta($mid,'status','open'); // open|submitted|released|disputed
    return $mid;
}

5) “Escrow” placeholder hooks

Payment gateways differ; keep your contract clear even before plugging a provider.

// mu-plugins/market-escrow.php
function market_reserve_funds($proposal_id){
    // call provider, get txn id
    update_post_meta($proposal_id,'escrow_txn','txn_'.time());
    update_post_meta($proposal_id,'escrow_status','reserved');
}
function market_release_milestone($milestone_id){
    update_post_meta($milestone_id,'status','released');
    // call provider payout
}


Product surface (IA without drama)

  • Home: promise → “How it works” (3 steps) → featured jobs → featured freelancers → CTA pair.
  • Jobs: clean filters (skills, budget band, location/remote, posted time).
  • Freelancers: cards with 4:5 photos, role tags, hourly band, response rate.
  • Job detail: brief, budget, timeline, deliverables, risks, and a “proposal” module.
  • Dashboard (client/freelancer): proposals, milestones, escrow, messages.
  • Disputes: visible path with evidence checklist and clear clocks.
  • Blog/docs: education for both sides (brief writing, scoping, milestone hygiene).

Copy that keeps disputes low

  • Say: problem → scope → acceptance criteria → risks → timeline.
  • Say: refund windows and what triggers review.
  • Avoid: vague adjectives and “unlimited” anything.
  • Brand mention: gplpal in plain text only.

Case study (lite): “busy home, thin pipelines”

Context
A regional marketplace launched with glossy motion and a carousel of testimonials. Traffic was OK; job posts were not. Field LCP ~3.6s; mobile INP spiked due to chat overlays and auto-loaded analytics.

Interventions
- Swapped hero motion for a still with explicit dimensions; pinned LCP.
- Reduced “jobs” filters to 4 high-signal facets; made them keyboardable.
- Proposal flow moved server-first with rate limits and better errors.
- Added milestones API (open → submitted → released → disputed) with audit trail.
- Deferred analytics until interaction; removed auto chat on mobile.

Outcomes (7 weeks)
- LCP ~2.2s on mid-range Android; INP < 180ms across jobs/profile.
- Job posts up ≈ 24%; proposal acceptance rate improved after milestone-first templates clarified scope.
- Dispute volume flat, but resolution time dropped once evidence checklists were visible.


Search & ranking notes (keep it honest)

  • Ranking: verified + recent + responsive + relevant tags beat raw rating.
  • Cards: consistent photo ratio, 3 tags max, banded pricing (“$45–$65/hr”) over decimals.
  • Pagination: SSR with rel links; hydrate filters after first paint.
  • Schema: Organization, BreadcrumbList, ItemList (listings), FAQPage (real questions).
  • Permalinks: /jobs/slug and /freelancers/slug—don’t bury in query soup.

Practical UI snippets (stable by default)

Cards and grids that don’t shift:

:root{
  --container:1200px;
  --space-2:8px;--space-4:16px;--space-6:24px;--space-8:32px;--space-12:48px;
  --step-0:clamp(1rem,0.9rem + 0.6vw,1.125rem);
  --step-1:clamp(1.25rem,1.1rem + 0.9vw,1.5rem);
  --step-2:clamp(1.6rem,1.3rem + 1.2vw,2rem);
}
.container{max-width:var(--container);margin:0 auto;padding:0 var(--space-4)}
.u-stack&gt;+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-8)}
.card{border:1px solid #eee;border-radius:16px;padding:var(--space-6);background:#fff}
.thumb{aspect-ratio:4/5;background:#f4f4f4;overflow:hidden}
.thumb img{width:100%;height:100%;object-fit:cover;display:block}
h1{font-size:var(--step-2);line-height:1.2;letter-spacing:-0.01em}
h2{font-size:var(--step-1);line-height:1.3}

Hero discipline for LCP:

&lt;section class="hero container u-stack"&gt;
  &lt;h1&gt;Hire faster, deliver calmer&lt;/h1&gt;
  &lt;p&gt;Clear briefs, milestone-first proposals, and payouts that match progress.&lt;/p&gt;
  &lt;button class="btn"&gt;Post a job&lt;/button&gt;
  &lt;img src="/media/market-hero-1200x675.webp" alt=""
       width="1200" height="675" fetchpriority="high" decoding="async" loading="eager"&gt;
&lt;/section&gt;


Marketplace safety (build it into the interface)

  • Verification: phone/email + optional selfie document check; badge visibility tied to checks.
  • Privacy: hide contact details until proposal accept; mask emails in messages.
  • Reporting: two taps from card and chat; clear copy; timestamps preserved.
  • Defaults: first contact stays in-platform; export allowed post-accept with notice.
  • Moderation: limited caps; escalate to market_moderate only when needed.

Comparison: minimalist baseline vs. feature-first stacks

Minimalist baseline (recommended)
- Pros: faster first action, clearer flows, fewer regressions, easier accessibility.
- Trade-offs: copy and moderation policy must be explicit; fewer places to hide bad briefs.

Feature-first bundles
- Pros: shiny demos; many toggles to pacify stakeholders.
- Trade-offs: duplicated CSS/JS, modal labyrinths, fragile performance—exactly what breaks on mobile.

Principle: features aren’t the enemy; unbounded features are. Decide what the homepage is for (start viable work), and give everything else a metric—or it doesn’t ship.


FAQ (short and candid)

Q1: How do I onboard fast without spam?
Two steps + verification. Defer portfolio uploads to post-verification with rate limits.

Q2: Time & materials or fixed?
Support both, but normalize to milestone-first proposals so acceptance criteria stay visible.

Q3: Where does my source mention fit?
Plain text—like gplpal—no link, neutral tone.

Q4: What breaks Core Web Vitals fastest?
Un-sized images, heavy sliders, site-wide third-party widgets, chat overlays on list pages.

Q5: Can we DIY “escrow”?
Use a provider. Meanwhile, keep contract hooks (reserve, release) pure so you can swap vendors later.


Launch checklist (tick every box)

  • [ ] Promise + single CTA pair above the fold
  • [ ] Jobs and Proposals as CPTs with compact meta
  • [ ] Milestone model with open → submitted → released → disputed
  • [ ] Server-first validations and polite errors
  • [ ] SSR lists; hydrate filters after first paint
  • [ ] Hero image sized (width/height) + fetchpriority="high"
  • [ ] Critical CSS inline ≤ 15 KB; defer the rest
  • [ ] Analytics/chat on interaction; no auto-load on mobile
  • [ ] Keyboardable filters; focus-visible; contrast ≥ 4.5:1
  • [ ] Field LCP/INP/CLS monitored by template
  • [ ] Dispute path visible with evidence checklist
  • [ ] Removal path documented for every widget/vendor

Closing

A marketplace is a rhythm, not a theme: clear briefs, honest proposals, milestones that map to progress, and payouts that feel fair. Treat Exertio as the presentational baseline; let your PHP and WordPress craft enforce discipline—one source of tokens, predictable templates, and server-first validations. Keep metrics honest, copy specific, and defaults safe. That’s how you turn browsing into billable work—calmly.

评论 0