Frontend Dev Guide

Subscription gating, legacy exemptions, and edit gates — for all three MarketPush apps.

What is isLegacy?

💡

Short answer: isLegacy marks entities that users created before pricing was introduced. These users already had content when we started charging, so we promised them their existing work would never be blocked. The flag is how the backend enforces that promise — and the frontend must respect it too.

When we launched subscription plans on each app, many users already had data. It would be unfair to suddenly disable their existing recipes, cars, or maps just because they're now over the free-tier limit. So we introduced a cutoff date — anything created before that date is "legacy" and is permanently exempt from limit enforcement.

The flag is computed by the backend on every read by comparing the entity's createdAt (or updatedAt for Blog Recipes posts) against a hardcoded cutoff date. It is returned in API responses for the frontend to use — but it is never sent by the client and you cannot change it.

App Affected Entity Cutoff Date Date Field Used Stored in DB?
Blog Recipes Post (recipe attachment) 2026-05-09 updatedAt No — response only
Car Dealer ListedCar, SellerProfile 2026-03-26 createdAt No — response only
World Map WorldMap 2026-03-30 createdAt No — computed at read time

What legacy exemption means in practice

🧪

Testing legacy behaviour

Because isLegacy is derived from a date field in Firestore, you cannot trigger it just by using the app — you need to backdate the record. To test:

  • Blog Recipes — set the post's updatedAt to any date before 2026-05-09.
  • Car Dealer — set the car or seller's createdAt to any date before 2026-03-26.
  • World Map — set the map's createdAt to any date before 2026-03-30.

We can do this manually in Firestore on request — just say which entity you need converted to a legacy one for testing.

Note: The cutoff dates above are provisional. The final production legacy date will be decided at launch and updated here and in the backend constants.

General Rule: When to Allow Edits

Across all three apps the logic follows the same pattern. Always check in this order:

1
Is the entity legacy? (isLegacy === true)
If yes → always allow the edit. Do not show any limit warning.
↓ No
2
Is the user trying to deactivate / delete?
If yes → always allow. Removing content never requires an upgrade.
↓ No — user wants to create / activate / publish
3
Has the user reached their plan limit?
Check subscription.reachedLimits from the GET /subscription response.
Within limit — render the edit button, allow the action.
Limit reached — disable the button, show an upgrade prompt.

🍽️
Blog Recipes

isLegacy on Posts

In Blog Recipes the relevant entity is the Post — which represents a recipe being attached to a blog post. A Post is legacy when its updatedAt is before 2026-05-09.

isLegacy is never stored in Firestore. The backend computes it on every read and injects it into the API response. Do not send it back when creating or updating a post.

GET /post — legacy post
{
  "postId": "abc-123",
  "recipeId": "rec-456",
  "active": true,
  "isLegacy": true,  // ← exempt from limits
  "createdAt": "2026-04-01T...",
  "updatedAt": "2026-05-01T..."
}
GET /post — modern post
{
  "postId": "def-789",
  "recipeId": "rec-101",
  "active": true,
  // isLegacy absent → false
  "createdAt": "2026-06-01T...",
  "updatedAt": "2026-06-01T..."
}

What to show / hide based on isLegacy

UI ActionisLegacy = trueisLegacy = false / absent
Reactivate an inactive postAlways allowCheck reachedLimits["attached-recipes"]
Switch to a different recipeAlways allowCheck reachedLimits["attached-recipes"]
Deactivate a postAlways allowAlways allow
Show "Legacy" badgeYes — show itNo

Edit Gates — Decision Table

Operation Condition Action
Create post (attach recipe) reachedLimits["attached-recipes"] === false Allow
Create post (attach recipe) reachedLimits["attached-recipes"] === true Block — show upgrade
Reactivate / switch recipe post.isLegacy === true Allow
Reactivate / switch recipe reachedLimits["attached-recipes"] === true Block — show upgrade
Deactivate / delete post any Always allow
Create / update a recipe itself any Always allow

Code Examples

hooks/usePostPermissions.ts
import { Post, Recipe, Subscription } from "./types";

// Returns true if the user can attach/reactivate this recipe on this post.
export function canAttachRecipe(
  subscription: Pick<Subscription, "reachedLimits">,
  existingPost?: Pick<Post, "isLegacy">,
): boolean {
  // Legacy posts are always exempt
  if (existingPost?.isLegacy) return true;

  // Otherwise check the plan limit
  return !subscription.reachedLimits["attached-recipes"];
}
components/PostRow.tsx
function PostRow({ post, recipe, subscription }) {
  const allowed = canAttachRecipe(subscription, post);

  return (
    <tr>
      <td>{post.postId}</td>
      <td>
        {post.isLegacy && (
          <span className="badge legacy" title="Created before pricing — always exempt">
            Legacy
          </span>
        )}
      </td>
      <td>
        <button
          disabled={!allowed}
          title={!allowed ? "Upgrade your plan to attach more recipes" : ""}
          onClick={() => reactivatePost(post.postId)}
        >
          Reactivate
        </button>
        {!allowed && !post.isLegacy && (
          <a href="/upgrade">Upgrade plan</a>
        )}
      </td>
    </tr>
  );
}

🚗
Car Dealer

isLegacy on Vehicles & Sellers

Both ListedCar and SellerProfile can be legacy. The cutoff is 2026-03-26, based on createdAt. Like Blog Recipes, the flag is response-only and never stored in Firestore.

Car Dealer has four gated operations — each has its own limit and its own isLegacy bypass:

OperationEntityLegacy bypass?Limit key
Create vehicleListedCarYesvehicle-count per plan
Publish vehicleListedCarYespublished-vehicles per plan
Create sellerSellerProfileYesseller-count per plan
Activate sellerSellerProfileYesactive-sellers per plan

Plan tiers quick reference

PlanVehiclesSellersPrice/mo
Garage Free51$0
Lot152$15.99
Showroom353$35.00
Dealer754$59.99
Dealer Pro1505$119.99
Auto Group30010$229.99
Marketplace60025$449.99
Enterprise1000100$1,000

Edit Gates — Decision Table

OperationConditionAction
Create vehiclecanCreateVehicle.allowed === trueAllow
canCreateVehicle.allowed === falseBlock — show upgrade
Publish vehiclecar.isLegacy === trueAlways allow
canPublishVehicle.allowed === trueAllow
canPublishVehicle.allowed === falseBlock — show upgrade
Create sellercanCreateSeller.allowed === trueAllow
canCreateSeller.allowed === falseBlock — show upgrade
Activate sellerseller.isLegacy === trueAlways allow
canActivateSeller.allowed === trueAllow
canActivateSeller.allowed === falseBlock — show upgrade
Edit / delete / deactivateanyAlways allow
⚠️

The API returns HTTP 403 (not 402) for Car Dealer limit violations. The error body includes vehicleLimit / sellerLimit and the current count — use these to build a specific message like "You have 15 of 15 vehicles. Upgrade to add more."

Code Examples

hooks/useVehiclePermissions.ts
interface VehiclePermissions {
  canCreate: boolean;
  canPublish: (car: { isLegacy?: boolean }) => boolean;
  limitMessage: string;
  vehicleLimit: number;
  currentCount: number;
}

export function useVehiclePermissions(
  checkCreate: CanCreateVehicleResponse,
  checkPublish: CanPublishVehicleResponse,
): VehiclePermissions {
  return {
    canCreate: checkCreate.allowed,
    canPublish: (car) => car.isLegacy || checkPublish.allowed,
    limitMessage: checkPublish.message,
    vehicleLimit: checkPublish.vehicleLimit,
    currentCount: checkPublish.publishedCount,
  };
}
components/CarCard.tsx
function CarCard({ car, permissions }) {
  const publishAllowed = permissions.canPublish(car);

  return (
    <div className="car-card">
      <h3>{car.carDetails.carModel}</h3>

      {car.isLegacy && (
        <span className="badge legacy">Legacy</span>
      )}

      <button
        disabled={!publishAllowed && car.carStatus !== "PUBLISHED"}
        onClick={() => publishCar(car.carId)}
      >
        Publish
      </button>

      {!publishAllowed && !car.isLegacy && (
        <p className="limit-warning">
          {permissions.currentCount} / {permissions.vehicleLimit} published.
          <a href="/upgrade"> Upgrade to publish more.</a>
        </p>
      )}
    </div>
  );
}

🗺️
World Map

isLegacy on Maps

World Map is the only app where isLegacy is persisted in Firestore. It is set to false on every new map and is never flipped back. The cutoff is 2026-03-30.

World Map also has a second dimension for limits: how many countries a single map contains. Both limits must be checked independently.

Legacy map — always publishable
{
  "mapId": "map-001",
  "mapStatus": "PUBLISHED",
  "isLegacy": true,
  "numberOfHighlightedCountries": 45
}
Modern map on full plan
{
  "mapId": "map-099",
  "mapStatus": "DISABLED",
  "isLegacy": false,
  "numberOfHighlightedCountries": 8
}

Plan tiers quick reference

PlanMapsCountries/mapPrice/mo
Basic Free15$0
Starter212$6.99
Growth630$12.99
Business4080$24.99
Premium 150150150$39.99
Premium 400400220$59.99
Premium 10001000$89.99

Edit Gates — Decision Table

OperationConditionAction
Create mapreachedLimits["maps"] === falseAllow
reachedLimits["maps"] === trueBlock — show upgrade
Always also check countries-per-map limitSee below
Re-publish DISABLED mapmap.isLegacy === trueAlways allow
reachedLimits["maps"] === falseAllow
reachedLimits["maps"] === trueBlock — show upgrade
Add countries to mapcount ≤ creditAvailabilities["countries-per-map"]Allow
count > creditAvailabilities["countries-per-map"]Block — show upgrade
Edit map settings / disable mapanyAlways allow
ℹ️

The countries-per-map limit is checked on every save (both create and update), not just on publish. If a user is on the free plan (5 countries) and tries to save a 6th country highlight, the save will be blocked. Show a live counter in the UI: "4 / 5 countries used on this plan."

Code Examples

hooks/useMapPermissions.ts
interface Subscription {
  reachedLimits: Record<string, boolean>;
  creditAvailabilities: Record<string, number>;
}

export function canPublishMap(
  map: { isLegacy: boolean },
  subscription: Subscription,
): boolean {
  if (map.isLegacy) return true;  // exempt — always publishable
  return !subscription.reachedLimits["maps"];
}

export function canAddCountry(
  currentCount: number,
  subscription: Subscription,
): boolean {
  const limit = subscription.creditAvailabilities["countries-per-map"];
  if (limit === Infinity || limit === -1) return true; // unlimited plan
  return currentCount < limit;
}
components/MapEditor.tsx
function MapEditor({ map, subscription }) {
  const countriesLimit = subscription.creditAvailabilities["countries-per-map"];
  const count = map.numberOfHighlightedCountries;
  const atCountryLimit = !canAddCountry(count, subscription);
  const publishAllowed = canPublishMap(map, subscription);

  return (
    <div>
      <h2>{map.mapName}</h2>

      {map.isLegacy && (
        <span className="badge legacy">Legacy</span>
      )}

      </* Live countries counter */>
      <p className={atCountryLimit ? "warn" : ""}>
        {count} / {countriesLimit === -1 ? "∞" : countriesLimit} countries
      </p>

      <button
        disabled={atCountryLimit}
        title={atCountryLimit ? "Upgrade to highlight more countries" : ""}
      >
        + Add Country
      </button>

      <button
        disabled={!publishAllowed}
        onClick={() => publishMap(map.mapId)}
      >
        Publish
      </button>

      {!publishAllowed && !map.isLegacy && (
        <a href="/upgrade">Upgrade to publish more maps</a>
      )}
    </div>
  );
}

Golden rule: The backend is the source of truth for whether an action is allowed. Pre-check subscription state on the frontend to give users a smooth experience (disabled buttons, live counters), but always handle the 402 / 403 error responses from the API gracefully — the backend may refuse an action even if the frontend thought it was fine.

MarketPush Apps · Internal dev guide · Generated 2026-05-11