Back to blog
FluentCart4 March 2026Vibe Code

How to Hijack FluentCart's Admin Panel (Politely)

A complete guide to adding pages, routes, and menu items to FluentCart's Vue 3 admin SPA from your own plugin. Including the bit where they overwrite your menu items.

fluentcartwordpressvuetutorial

You've built a FluentCart extension. You've got your REST API endpoints humming along, your data models are clean, your PHP is immaculate. Now you just need a page in FluentCart's admin panel to actually show all that lovely data. How hard can it be?

Harder than it should be, as it turns out. But not impossible — once you understand the architecture, the boot sequence, and the one place where FluentCart actively sabotages your work (we'll get to that).

Everything in this guide is battle-tested with fchub-wishlist. Not theoretical. Not "should work in principle." Actually works in production, dark mode included.

What You're Actually Dealing With

Here's the architectural plot twist nobody warns you about: FluentCart's admin isn't just a Vue SPA. The top navigation bar is server-rendered PHP. The page content is a Vue 3 single-page application. Two worlds, one page, zero documentation about how they coexist.

┌─────────────────────────────────────────────────┐
│  PHP-rendered top nav (.fct_admin_menu_wrap)     │  ← Server-side, NOT Vue
│  ┌───────┬──────────┬─────────┬───────┬─────┐   │
│  │ Home  │ Products │ Orders  │ Subs  │ More│   │
│  └───────┴──────────┴─────────┴───────┴─────┘   │
├─────────────────────────────────────────────────┤
│  #fluent_cart_plugin_app                         │  ← Vue 3 SPA mounts here
│                                                  │
│    <router-view />                               │
│    Your component renders here via route          │
│                                                  │
└─────────────────────────────────────────────────┘

Hash-Based Routing

Routes use hash-based URLs (#/path), not HTML5 history mode. Your page lives at something like admin.php?page=fluent-cart#/wishlist, not a clean /wishlist path. It's not glamorous, but it works without server-side rewrite rules.

The Boot Sequence (Read This or Suffer)

Understanding the exact order things happen is the difference between "it works" and "everything is undefined and I'm questioning my career choices." Here's the sequence:

  1. fluent_cart/loading_app fires — this is your cue to enqueue scripts
  2. FluentCart registers its own scripts (fluent-cart_global_admin_hooks, then fluent-cart_admin_app_start)
  3. fluent_cart/admin_js_loaded fires — your last chance to manipulate the dependency chain
  4. PHP renders the page HTML: the navigation menu, the #fluent_cart_plugin_app div
  5. Footer scripts execute in WordPress dependency order
  6. fluent-cart_admin_app_start applies the fluent_cart_routes filter, creates the Vue router, mounts the app

Timing Is Everything

Your script must execute before fluent-cart_admin_app_start. If it doesn't, the router is already created and your routes don't exist. No error, no warning — just a blank page and a vague sense of betrayal.

Building the Thing

Two hooks, two methods. The bootstrap wires them up:

// AdminModule.php (or wherever you bootstrap)
add_action('fluent_cart/loading_app', [AdminMenu::class, 'enqueueAssets']);
add_action('fluent_cart/admin_js_loaded', [AdminMenu::class, 'ensureLoadOrder']);

The AdminMenu class does the actual work:

public static function enqueueAssets(): void
{
    wp_enqueue_script(
        'my-plugin-admin',
        MY_PLUGIN_URL . 'admin/my-admin.js',
        ['fluent-cart_global_admin_hooks'],  // Must depend on this
        (string) filemtime(MY_PLUGIN_PATH . 'admin/my-admin.js'),
        true  // Footer non-negotiable
    );

    wp_localize_script('my-plugin-admin', 'myPluginAdmin', [
        'rest_url' => esc_url_raw(rest_url('my-plugin/v1/')),
        'nonce'    => wp_create_nonce('wp_rest'),
    ]);
}

And here's the clever bit — injecting your script into the dependency chain so it runs before the app starts:

public static function ensureLoadOrder(): void
{
    global $wp_scripts;

    if (isset($wp_scripts->registered['fluent-cart_admin_app_start'])) {
        $wp_scripts->registered['fluent-cart_admin_app_start']->deps[] = 'my-plugin-admin';
    }
}

This tells WordPress: "don't you dare run admin_app_start until my script has executed." Without this, your route registration fires into the void.

Cache Busting Matters

Use filemtime() for the version parameter, not your plugin's version constant. During development the version string doesn't change, but the file does. Cloudflare tunnels and browser caching will happily serve you yesterday's JavaScript while you wonder why nothing works.

Your Cheat Sheet

FluentCart uses Tailwind internally but exposes these semantic classes you can rely on:

ClassPurpose
.fct-layout-widthFull-width page container
.page-heading-wrapPage title bar (flex, space-between)
.page-titleH1 inside heading wrap
.actionsRight-aligned action buttons in heading
.fct-cardWhite card container with rounded corners
.fct-card-borderCard variant with visible border
.fct-card-headerCard header section
.fct-card-bodyCard content area (padding: 20px)
.fct-card-footerCard footer with top border

REST API — Roll Your Own

You can't use FluentCart's built-in $get / $post helpers. They prepend FC's own REST base URL, which won't match your plugin's namespace. Write a minimal fetch wrapper instead:

var config = window.myPluginAdmin || {};

function request(method, path, body) {
    var opts = {
        method: method === 'PUT' ? 'POST' : method,
        headers: {
            'Content-Type': 'application/json',
            'X-WP-Nonce': config.nonce || '',
        },
        credentials: 'same-origin',
    };
    // WP REST doesn't reliably support PUT in all setups
    if (method === 'PUT') {
        opts.headers['X-HTTP-Method-Override'] = 'PUT';
    }
    if (body) {
        opts.body = JSON.stringify(body);
    }
    return fetch(config.rest_url + path, opts).then(function (res) {
        return res.json().then(function (json) {
            if (!res.ok) throw json;
            return json.data || json;
        });
    });
}

The X-HTTP-Method-Override trick handles the environments where PUT requests get mangled by Apache or security plugins. Send it as POST with the override header, and WordPress's REST API does the right thing.

Page Template

The standard HTML pattern that matches FluentCart's own pages. Use this and your page looks native:

<div class="my-plugin-page fct-layout-width">
    <div class="page-heading-wrap">
        <h1 class="page-title">My Page</h1>
        <div class="actions">
            <el-button type="primary" :loading="saving" @click="save">
                Save Settings
            </el-button>
        </div>
    </div>
    <div class="fct-card">
        <div class="fct-card-body">
            <!-- Your content here -->
        </div>
    </div>
</div>

Gotchas (The Bit You'll Wish You Read First)


Now go build something. And when FluentCart updates and breaks all of this, you'll know exactly which hook to blame.