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
- Legacy entities are never deactivated or disabled by the backend during a plan downgrade.
- Legacy entities are not counted against the user's plan limit — e.g. a legacy car does not eat into the 5-vehicle free quota.
- Legacy entities can always be re-published / re-activated even if the limit is otherwise full.
- The frontend should show a visual indicator (e.g. a "Legacy" badge) so users understand why their old content behaves differently.
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
updatedAtto any date before2026-05-09. - Car Dealer — set the car or seller's
createdAtto any date before2026-03-26. - World Map — set the map's
createdAtto any date before2026-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:
isLegacy === true)If yes → always allow the edit. Do not show any limit warning.
If yes → always allow. Removing content never requires an upgrade.
Check
subscription.reachedLimits from the GET /subscription response.
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.
{
"postId": "abc-123",
"recipeId": "rec-456",
"active": true,
"isLegacy": true, // ← exempt from limits
"createdAt": "2026-04-01T...",
"updatedAt": "2026-05-01T..."
}
{
"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 Action | isLegacy = true | isLegacy = false / absent |
|---|---|---|
| Reactivate an inactive post | Always allow | Check reachedLimits["attached-recipes"] |
| Switch to a different recipe | Always allow | Check reachedLimits["attached-recipes"] |
| Deactivate a post | Always allow | Always allow |
| Show "Legacy" badge | Yes — show it | No |
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
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"];
}
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:
| Operation | Entity | Legacy bypass? | Limit key |
|---|---|---|---|
| Create vehicle | ListedCar | Yes | vehicle-count per plan |
| Publish vehicle | ListedCar | Yes | published-vehicles per plan |
| Create seller | SellerProfile | Yes | seller-count per plan |
| Activate seller | SellerProfile | Yes | active-sellers per plan |
Plan tiers quick reference
| Plan | Vehicles | Sellers | Price/mo |
|---|---|---|---|
| Garage Free | 5 | 1 | $0 |
| Lot | 15 | 2 | $15.99 |
| Showroom | 35 | 3 | $35.00 |
| Dealer | 75 | 4 | $59.99 |
| Dealer Pro | 150 | 5 | $119.99 |
| Auto Group | 300 | 10 | $229.99 |
| Marketplace | 600 | 25 | $449.99 |
| Enterprise | 1000 | 100 | $1,000 |
Edit Gates — Decision Table
| Operation | Condition | Action |
|---|---|---|
| Create vehicle | canCreateVehicle.allowed === true | Allow |
canCreateVehicle.allowed === false | Block — show upgrade | |
| Publish vehicle | car.isLegacy === true | Always allow |
canPublishVehicle.allowed === true | Allow | |
canPublishVehicle.allowed === false | Block — show upgrade | |
| Create seller | canCreateSeller.allowed === true | Allow |
canCreateSeller.allowed === false | Block — show upgrade | |
| Activate seller | seller.isLegacy === true | Always allow |
canActivateSeller.allowed === true | Allow | |
canActivateSeller.allowed === false | Block — show upgrade | |
| Edit / delete / deactivate | any | Always 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
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,
};
}
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.
{
"mapId": "map-001",
"mapStatus": "PUBLISHED",
"isLegacy": true,
"numberOfHighlightedCountries": 45
}
{
"mapId": "map-099",
"mapStatus": "DISABLED",
"isLegacy": false,
"numberOfHighlightedCountries": 8
}
Plan tiers quick reference
| Plan | Maps | Countries/map | Price/mo |
|---|---|---|---|
| Basic Free | 1 | 5 | $0 |
| Starter | 2 | 12 | $6.99 |
| Growth | 6 | 30 | $12.99 |
| Business | 40 | 80 | $24.99 |
| Premium 150 | 150 | 150 | $39.99 |
| Premium 400 | 400 | 220 | $59.99 |
| Premium 1000 | 1000 | ∞ | $89.99 |
Edit Gates — Decision Table
| Operation | Condition | Action |
|---|---|---|
| Create map | reachedLimits["maps"] === false | Allow |
reachedLimits["maps"] === true | Block — show upgrade | |
Always also check countries-per-map limit | See below | |
| Re-publish DISABLED map | map.isLegacy === true | Always allow |
reachedLimits["maps"] === false | Allow | |
reachedLimits["maps"] === true | Block — show upgrade | |
| Add countries to map | count ≤ creditAvailabilities["countries-per-map"] | Allow |
count > creditAvailabilities["countries-per-map"] | Block — show upgrade | |
| Edit map settings / disable map | any | Always 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
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;
}
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