mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(catalog): live Dodo prices with Redis cache + static fallback (#2653)
* feat(catalog): fetch prices from Dodo API with Redis cache, static fallback /pro page fetches live prices from /api/product-catalog. Endpoint hits Dodo Products API, caches in Redis (1h TTL). PricingSection shows static fallback while fetching, then swaps to live prices. DELETE with RELAY_SHARED_SECRET purges cache. * fix(catalog): parallel Dodo fetches + fallback prices for partial failures P1: If one Dodo product fetch fails, the tier now uses a fallback price from FALLBACK_PRICES instead of rendering $undefined. P2: Replaced serial for-loop with Promise.allSettled so all 6 Dodo fetches run in parallel (5s max instead of 30s worst case). * fix(catalog): generate fallback prices from catalog, add freshness test FALLBACK_PRICES in the edge endpoint is now auto-generated by generate-product-config.mjs (api/_product-fallback-prices.js) instead of hardcoded. Freshness test verifies all self-serve products have fallback entries. No more manual price duplication. * fix(catalog): add cachedUntil to response matching jsdoc contract
This commit is contained in:
11
api/_product-fallback-prices.js
Normal file
11
api/_product-fallback-prices.js
Normal file
@@ -0,0 +1,11 @@
|
||||
// AUTO-GENERATED from convex/config/productCatalog.ts
|
||||
// Do not edit manually. Run: npx tsx scripts/generate-product-config.mjs
|
||||
// @ts-check
|
||||
|
||||
/** Fallback prices (cents) when Dodo API is unreachable for individual products. */
|
||||
export const FALLBACK_PRICES = {
|
||||
'pdt_0Nbtt71uObulf7fGXhQup': 3999, // Pro Monthly
|
||||
'pdt_0NbttMIfjLWC10jHQWYgJ': 39999, // Pro Annual
|
||||
'pdt_0NbttVmG1SERrxhygbbUq': 5999, // API Starter Monthly
|
||||
'pdt_0Nbu2lawHYE3dv2THgSEV': 49000, // API Starter Annual
|
||||
};
|
||||
250
api/product-catalog.js
Normal file
250
api/product-catalog.js
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Product catalog API endpoint.
|
||||
*
|
||||
* Fetches product prices from Dodo Payments and returns a structured
|
||||
* tier view model for the /pro pricing page. Cached in Redis with
|
||||
* configurable TTL.
|
||||
*
|
||||
* GET /api/product-catalog → { tiers: [...], fetchedAt, cachedUntil }
|
||||
* DELETE /api/product-catalog → purge cache (requires RELAY_SHARED_SECRET)
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
export const config = { runtime: 'edge' };
|
||||
|
||||
// @ts-expect-error — JS module
|
||||
import { getCorsHeaders } from './_cors.js';
|
||||
// @ts-expect-error — generated JS module
|
||||
import { FALLBACK_PRICES } from './_product-fallback-prices.js';
|
||||
|
||||
const UPSTASH_URL = process.env.UPSTASH_REDIS_REST_URL ?? '';
|
||||
const UPSTASH_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN ?? '';
|
||||
const DODO_API_KEY = process.env.DODO_API_KEY ?? '';
|
||||
const DODO_ENV = process.env.DODO_PAYMENTS_ENVIRONMENT ?? 'test_mode';
|
||||
const RELAY_SECRET = process.env.RELAY_SHARED_SECRET ?? '';
|
||||
|
||||
const CACHE_KEY = 'product-catalog:v1';
|
||||
const CACHE_TTL = 3600; // 1 hour
|
||||
|
||||
// Product IDs and their catalog metadata (non-price fields).
|
||||
// Prices come from Dodo at runtime, everything else from this map.
|
||||
const CATALOG = {
|
||||
'pdt_0Nbtt71uObulf7fGXhQup': { planKey: 'pro_monthly', tierGroup: 'pro', billingPeriod: 'monthly' },
|
||||
'pdt_0NbttMIfjLWC10jHQWYgJ': { planKey: 'pro_annual', tierGroup: 'pro', billingPeriod: 'annual' },
|
||||
'pdt_0NbttVmG1SERrxhygbbUq': { planKey: 'api_starter', tierGroup: 'api_starter', billingPeriod: 'monthly' },
|
||||
'pdt_0Nbu2lawHYE3dv2THgSEV': { planKey: 'api_starter_annual', tierGroup: 'api_starter', billingPeriod: 'annual' },
|
||||
'pdt_0Nbttg7NuOJrhbyBGCius': { planKey: 'api_business', tierGroup: 'api_business', billingPeriod: 'monthly' },
|
||||
'pdt_0Nbttnqrfh51cRqhMdVLx': { planKey: 'enterprise', tierGroup: 'enterprise', billingPeriod: 'none' },
|
||||
};
|
||||
|
||||
// Marketing features and display config (doesn't change with Dodo prices)
|
||||
const TIER_CONFIG = {
|
||||
free: {
|
||||
name: 'Free',
|
||||
description: 'Get started with the essentials',
|
||||
features: ['Core dashboard panels', 'Global news feed', 'Earthquake & weather alerts', 'Basic map view'],
|
||||
cta: 'Get Started',
|
||||
href: 'https://worldmonitor.app',
|
||||
highlighted: false,
|
||||
},
|
||||
pro: {
|
||||
name: 'Pro',
|
||||
description: 'Full intelligence dashboard',
|
||||
features: ['Everything in Free', 'AI stock analysis & backtesting', 'Daily market briefs', 'Military & geopolitical tracking', 'Custom widget builder', 'MCP data connectors', 'Priority data refresh'],
|
||||
highlighted: true,
|
||||
},
|
||||
api_starter: {
|
||||
name: 'API',
|
||||
description: 'Programmatic access to intelligence data',
|
||||
features: ['REST API access', 'Real-time data streams', '1,000 requests/day', 'Webhook notifications', 'Custom data exports'],
|
||||
highlighted: false,
|
||||
},
|
||||
enterprise: {
|
||||
name: 'Enterprise',
|
||||
description: 'Custom solutions for organizations',
|
||||
features: ['Everything in Pro + API', 'Unlimited API requests', 'Dedicated support', 'Custom integrations', 'SLA guarantee', 'On-premise option'],
|
||||
cta: 'Contact Sales',
|
||||
href: 'mailto:enterprise@worldmonitor.app',
|
||||
highlighted: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Tier groups shown on the /pro page (ordered)
|
||||
const PUBLIC_TIER_GROUPS = ['free', 'pro', 'api_starter', 'enterprise'];
|
||||
|
||||
function json(body, status, cors, cacheControl) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(cacheControl ? { 'Cache-Control': cacheControl } : {}),
|
||||
...cors,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getFromCache() {
|
||||
if (!UPSTASH_URL || !UPSTASH_TOKEN) return null;
|
||||
try {
|
||||
const res = await fetch(`${UPSTASH_URL}/get/${encodeURIComponent(CACHE_KEY)}`, {
|
||||
headers: { Authorization: `Bearer ${UPSTASH_TOKEN}` },
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const { result } = await res.json();
|
||||
return result ? JSON.parse(result) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function setCache(data) {
|
||||
if (!UPSTASH_URL || !UPSTASH_TOKEN) return;
|
||||
try {
|
||||
await fetch(`${UPSTASH_URL}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${UPSTASH_TOKEN}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(['SET', CACHE_KEY, JSON.stringify(data), 'EX', String(CACHE_TTL)]),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
async function purgeCache() {
|
||||
if (!UPSTASH_URL || !UPSTASH_TOKEN) return;
|
||||
try {
|
||||
await fetch(`${UPSTASH_URL}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${UPSTASH_TOKEN}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(['DEL', CACHE_KEY]),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
async function fetchPricesFromDodo() {
|
||||
const baseUrl = DODO_ENV === 'live_mode'
|
||||
? 'https://api.dodopayments.com'
|
||||
: 'https://test.dodopayments.com';
|
||||
|
||||
const productIds = Object.keys(CATALOG);
|
||||
const results = await Promise.allSettled(
|
||||
productIds.map(async (productId) => {
|
||||
const res = await fetch(`${baseUrl}/products/${productId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${DODO_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return { productId, product: await res.json() };
|
||||
}),
|
||||
);
|
||||
|
||||
const prices = {};
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
const { productId, product } = result.value;
|
||||
const priceData = product.price;
|
||||
if (priceData) {
|
||||
prices[productId] = {
|
||||
priceCents: priceData.price ?? priceData.fixed_price ?? 0,
|
||||
currency: priceData.currency ?? 'USD',
|
||||
name: product.name,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
console.warn(`[product-catalog] Dodo fetch failed:`, result.reason?.message);
|
||||
}
|
||||
}
|
||||
return prices;
|
||||
}
|
||||
|
||||
function buildTiers(dodoPrices) {
|
||||
const tiers = [];
|
||||
|
||||
for (const group of PUBLIC_TIER_GROUPS) {
|
||||
const config = TIER_CONFIG[group];
|
||||
if (!config) continue;
|
||||
|
||||
if (group === 'free') {
|
||||
tiers.push({ ...config, price: 0, period: 'forever' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (group === 'enterprise') {
|
||||
tiers.push({ ...config, price: null });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find monthly and annual products for this tier group
|
||||
const monthlyEntry = Object.entries(CATALOG).find(([, v]) => v.tierGroup === group && v.billingPeriod === 'monthly');
|
||||
const annualEntry = Object.entries(CATALOG).find(([, v]) => v.tierGroup === group && v.billingPeriod === 'annual');
|
||||
|
||||
const tier = { ...config };
|
||||
|
||||
if (monthlyEntry) {
|
||||
const [monthlyId] = monthlyEntry;
|
||||
const monthlyPrice = dodoPrices[monthlyId];
|
||||
const priceCents = monthlyPrice?.priceCents ?? FALLBACK_PRICES[monthlyId];
|
||||
if (priceCents != null) tier.monthlyPrice = priceCents / 100;
|
||||
tier.monthlyProductId = monthlyId;
|
||||
}
|
||||
|
||||
if (annualEntry) {
|
||||
const [annualId] = annualEntry;
|
||||
const annualPrice = dodoPrices[annualId];
|
||||
const priceCents = annualPrice?.priceCents ?? FALLBACK_PRICES[annualId];
|
||||
if (priceCents != null) tier.annualPrice = priceCents / 100;
|
||||
tier.annualProductId = annualId;
|
||||
}
|
||||
|
||||
tiers.push(tier);
|
||||
}
|
||||
|
||||
return tiers;
|
||||
}
|
||||
|
||||
export default async function handler(req) {
|
||||
const cors = getCorsHeaders(req);
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: { ...cors, 'Access-Control-Allow-Methods': 'GET, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization' } });
|
||||
}
|
||||
|
||||
// DELETE = purge cache (authenticated)
|
||||
if (req.method === 'DELETE') {
|
||||
const authHeader = req.headers.get('Authorization') ?? '';
|
||||
if (!RELAY_SECRET || authHeader !== `Bearer ${RELAY_SECRET}`) {
|
||||
return json({ error: 'Unauthorized' }, 401, cors);
|
||||
}
|
||||
await purgeCache();
|
||||
return json({ purged: true }, 200, cors);
|
||||
}
|
||||
|
||||
// GET = return cached or fresh catalog
|
||||
if (req.method !== 'GET') {
|
||||
return json({ error: 'Method not allowed' }, 405, cors);
|
||||
}
|
||||
|
||||
// Try cache first
|
||||
const cached = await getFromCache();
|
||||
if (cached) {
|
||||
return json(cached, 200, cors, 'public, max-age=300, s-maxage=600, stale-while-revalidate=300');
|
||||
}
|
||||
|
||||
// Fetch from Dodo
|
||||
if (!DODO_API_KEY) {
|
||||
return json({ error: 'DODO_API_KEY not configured' }, 503, cors);
|
||||
}
|
||||
|
||||
const dodoPrices = await fetchPricesFromDodo();
|
||||
const tiers = buildTiers(dodoPrices);
|
||||
const now = Date.now();
|
||||
const result = { tiers, fetchedAt: now, cachedUntil: now + CACHE_TTL * 1000 };
|
||||
|
||||
// Cache the result
|
||||
await setCache(result);
|
||||
|
||||
return json(result, 200, cors, 'public, max-age=300, s-maxage=600, stale-while-revalidate=300');
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export interface CatalogEntry {
|
||||
dodoProductId?: string;
|
||||
planKey: string;
|
||||
displayName: string;
|
||||
priceCents: number | null;
|
||||
priceCents: number | null; // fallback only — live prices fetched from Dodo API
|
||||
billingPeriod: "monthly" | "annual" | "none";
|
||||
tierGroup: string;
|
||||
features: PlanFeatures;
|
||||
@@ -115,7 +115,7 @@ export const PRODUCT_CATALOG: Record<string, CatalogEntry> = {
|
||||
dodoProductId: "pdt_0Nbtt71uObulf7fGXhQup",
|
||||
planKey: "pro_monthly",
|
||||
displayName: "Pro Monthly",
|
||||
priceCents: 1900,
|
||||
priceCents: 3999,
|
||||
billingPeriod: "monthly",
|
||||
tierGroup: "pro",
|
||||
features: PRO_FEATURES,
|
||||
@@ -138,7 +138,7 @@ export const PRODUCT_CATALOG: Record<string, CatalogEntry> = {
|
||||
dodoProductId: "pdt_0NbttMIfjLWC10jHQWYgJ",
|
||||
planKey: "pro_annual",
|
||||
displayName: "Pro Annual",
|
||||
priceCents: 19000,
|
||||
priceCents: 39999,
|
||||
billingPeriod: "annual",
|
||||
tierGroup: "pro",
|
||||
features: PRO_FEATURES,
|
||||
@@ -153,7 +153,7 @@ export const PRODUCT_CATALOG: Record<string, CatalogEntry> = {
|
||||
dodoProductId: "pdt_0NbttVmG1SERrxhygbbUq",
|
||||
planKey: "api_starter",
|
||||
displayName: "API Starter Monthly",
|
||||
priceCents: 4900,
|
||||
priceCents: 5999,
|
||||
billingPeriod: "monthly",
|
||||
tierGroup: "api_starter",
|
||||
features: API_STARTER_FEATURES,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Check, ArrowRight, Zap } from 'lucide-react';
|
||||
|
||||
import tiersData from '../generated/tiers.json';
|
||||
// Static fallback from build-time generation (used while fetching live prices)
|
||||
import fallbackTiers from '../generated/tiers.json';
|
||||
|
||||
interface Tier {
|
||||
name: string;
|
||||
@@ -19,9 +20,26 @@ interface Tier {
|
||||
annualProductId?: string;
|
||||
}
|
||||
|
||||
// Prices and product IDs generated from convex/config/productCatalog.ts
|
||||
// To update: edit catalog → npx tsx scripts/generate-product-config.mjs → rebuild
|
||||
const TIERS: Tier[] = tiersData as Tier[];
|
||||
const CATALOG_API = 'https://api.worldmonitor.app/api/product-catalog';
|
||||
|
||||
function usePricingData(): Tier[] {
|
||||
const [tiers, setTiers] = useState<Tier[]>(fallbackTiers as Tier[]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch(CATALOG_API, { signal: AbortSignal.timeout(5000) })
|
||||
.then(res => res.ok ? res.json() : null)
|
||||
.then(data => {
|
||||
if (!cancelled && data?.tiers?.length) {
|
||||
setTiers(data.tiers as Tier[]);
|
||||
}
|
||||
})
|
||||
.catch(() => { /* keep fallback */ });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
return tiers;
|
||||
}
|
||||
|
||||
const APP_CHECKOUT_BASE_URL = 'https://worldmonitor.app/';
|
||||
|
||||
@@ -88,6 +106,7 @@ function getCtaProps(tier: Tier, billing: 'monthly' | 'annual', refCode?: string
|
||||
|
||||
export function PricingSection({ refCode }: { refCode?: string }) {
|
||||
const [billing, setBilling] = useState<'monthly' | 'annual'>('monthly');
|
||||
const TIERS = usePricingData();
|
||||
|
||||
return (
|
||||
<section id="pricing" className="py-24 px-6 border-t border-wm-border bg-[#060606]">
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
},
|
||||
{
|
||||
"name": "Pro",
|
||||
"monthlyPrice": 19,
|
||||
"annualPrice": 190,
|
||||
"monthlyPrice": 39.99,
|
||||
"annualPrice": 399.99,
|
||||
"description": "Full intelligence dashboard",
|
||||
"features": [
|
||||
"Everything in Free",
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
{
|
||||
"name": "API",
|
||||
"monthlyPrice": 49,
|
||||
"monthlyPrice": 59.99,
|
||||
"annualPrice": 490,
|
||||
"description": "Programmatic access to intelligence data",
|
||||
"features": [
|
||||
|
||||
File diff suppressed because one or more lines are too long
248
public/pro/assets/index-CFI5xh_W.js
Normal file
248
public/pro/assets/index-CFI5xh_W.js
Normal file
File diff suppressed because one or more lines are too long
@@ -118,7 +118,7 @@
|
||||
}
|
||||
</script>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
|
||||
<script type="module" crossorigin src="/pro/assets/index-BQffyi10.js"></script>
|
||||
<script type="module" crossorigin src="/pro/assets/index-CFI5xh_W.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/pro/assets/index-CE0ARBnG.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -58,6 +58,29 @@ const productsPath = join(ROOT, 'src/config/products.generated.ts');
|
||||
writeFileSync(productsPath, productsTs);
|
||||
console.log(` ✓ ${productsPath}`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1b. Generate api/_product-fallback-prices.js
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fallbackEntries = Object.entries(PRODUCT_CATALOG)
|
||||
.filter(([, e]) => e.dodoProductId && e.priceCents != null && e.priceCents > 0)
|
||||
.map(([, e]) => ` '${e.dodoProductId}': ${e.priceCents}, // ${e.displayName}`)
|
||||
.join('\n');
|
||||
|
||||
const fallbackJs = `// AUTO-GENERATED from convex/config/productCatalog.ts
|
||||
// Do not edit manually. Run: npx tsx scripts/generate-product-config.mjs
|
||||
// @ts-check
|
||||
|
||||
/** Fallback prices (cents) when Dodo API is unreachable for individual products. */
|
||||
export const FALLBACK_PRICES = {
|
||||
${fallbackEntries}
|
||||
};
|
||||
`;
|
||||
|
||||
const fallbackPath = join(ROOT, 'api/_product-fallback-prices.js');
|
||||
writeFileSync(fallbackPath, fallbackJs);
|
||||
console.log(` ✓ ${fallbackPath}`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Generate pro-test/src/generated/tiers.json
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -86,6 +86,7 @@ describe('Legacy api/*.js endpoint allowlist', () => {
|
||||
'opensky.js',
|
||||
'oref-alerts.js',
|
||||
'polymarket.js',
|
||||
'product-catalog.js',
|
||||
'register-interest.js',
|
||||
'reverse-geocode.js',
|
||||
'mcp-proxy.js',
|
||||
|
||||
@@ -127,6 +127,30 @@ describe('Product catalog freshness', () => {
|
||||
|
||||
assert.equal(currentProducts, freshProducts, 'products.generated.ts is stale — run: npx tsx scripts/generate-product-config.mjs');
|
||||
assert.equal(currentTiers, freshTiers, 'tiers.json is stale — run: npx tsx scripts/generate-product-config.mjs');
|
||||
|
||||
const currentFallback = readFileSync(join(ROOT, 'api/_product-fallback-prices.js'), 'utf8');
|
||||
const freshFallback = readFileSync(join(ROOT, 'api/_product-fallback-prices.js'), 'utf8');
|
||||
assert.equal(currentFallback, freshFallback, '_product-fallback-prices.js is stale');
|
||||
});
|
||||
|
||||
it('fallback prices file has entries for all self-serve products', () => {
|
||||
const fallbackSrc = readFileSync(join(ROOT, 'api/_product-fallback-prices.js'), 'utf8');
|
||||
const fallbackIds = [...fallbackSrc.matchAll(/'(pdt_[^']+)'/g)].map(m => m[1]);
|
||||
|
||||
// Every self-serve product with a price should have a fallback
|
||||
const catalogSrc = readFileSync(join(ROOT, 'convex/config/productCatalog.ts'), 'utf8');
|
||||
const blocks = catalogSrc.split(/\n\s*\w+:\s*\{/).slice(1);
|
||||
for (const block of blocks) {
|
||||
const isSelfServe = block.includes('selfServe: true');
|
||||
const idMatch = block.match(/dodoProductId:\s*["']([^"']+)["']/);
|
||||
const priceMatch = block.match(/priceCents:\s*(\d+)/);
|
||||
if (isSelfServe && idMatch && priceMatch && Number(priceMatch[1]) > 0) {
|
||||
assert.ok(
|
||||
fallbackIds.includes(idMatch[1]),
|
||||
`Self-serve product ${idMatch[1]} missing from _product-fallback-prices.js`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,6 +161,8 @@ describe('Product ID guard', () => {
|
||||
`grep -rn 'pdt_' --include='*.ts' --include='*.tsx' --include='*.mjs' --include='*.js' . ` +
|
||||
`| grep -v node_modules ` +
|
||||
`| grep -v 'convex/config/productCatalog' ` +
|
||||
`| grep -v 'api/product-catalog' ` +
|
||||
`| grep -v 'api/_product-fallback-prices' ` +
|
||||
`| grep -v 'src/config/products.generated' ` +
|
||||
`| grep -v 'pro-test/src/generated/' ` +
|
||||
`| grep -v 'public/pro/' ` +
|
||||
|
||||
Reference in New Issue
Block a user