A scalable, customizable project cost estimator for React. Define the services you offer, the inputs that affect price, and the pricing formulas (with variables, conditionals, and dependencies between services) — then drop a live-updating estimate widget into any site. Ships with a visual Builder so non-developers can create and edit pricing configs without touching code.
<EstimatorWidget />— the customer-facing widget. Renders your services as a form; computes a live total as the visitor fills it in.<Builder />— the visual admin tool. Add services, fields, and formulas; get inline validation and a live preview; export/import JSON.- Formula engine — a small, sandboxed expression language (no
eval), supporting arithmetic, comparisons, booleans, ternaries, functions, and cross-service references — so pricing logic can be as simple or as advanced as you need. - More than number/checkbox/dropdown fields.
text,textarea,email, andtelfields collect free-form, informational info (names, notes, addresses) alongside your priced fields — inside a service, or once per config in a dedicated contact/intake section. - Built-in "Get Estimate" submission. Point
submission.endpointat your own backend and the widget handles the button, required-field/email validation, and the POST — your backend owns actually sending the email. - Everything is data. A config is a plain JSON object. Version it, store it in a database, generate it from a CMS — whatever fits your stack.
npm install fcestimatorimport { EstimatorWidget } from "fcestimator";
import "fcestimator/styles.css";import { EstimatorWidget, type EstimatorConfig } from "fcestimator";
import "fcestimator/styles.css";
const config: EstimatorConfig = {
id: "web-project",
name: "Website Project Estimator",
currency: "USD",
globalVariables: { hourly_rate: 95 },
services: [
{
id: "design",
name: "Design",
fields: [
{ id: "pages", label: "Number of pages", type: "number", default: 5, min: 1 },
],
formula: "pages * 400",
},
{
id: "development",
name: "Development",
fields: [
{ id: "hours", label: "Estimated hours", type: "number", default: 40 },
],
// formulas can reference another service's computed amount
formula: 'hours * hourly_rate + svc("design") * 0.05',
dependsOn: ["design"],
},
],
rules: [
{ id: "big_discount", label: "Volume discount", condition: "total > 15000", effect: "total * 0.9" },
],
};
export default function Page() {
return <EstimatorWidget config={config} onChange={(result) => console.log(result.total)} />;
}That's the whole integration. See examples/demo for a runnable app with both the widget and the builder.
import { useState } from "react";
import { Builder, type EstimatorConfig } from "fcestimator";
import "fcestimator/styles.css";
function AdminPage({ initialConfig }: { initialConfig: EstimatorConfig }) {
const [config, setConfig] = useState(initialConfig);
return (
<Builder
config={config}
onChange={setConfig}
onSave={(finalConfig) => fetch("/api/estimator-config", { method: "PUT", body: JSON.stringify(finalConfig) })}
/>
);
}The Builder has three tabs:
- Build — add/edit services, fields, global variables, and rules, with inline formula validation as you type.
- Preview — the exact
EstimatorWidgeta visitor would see, live. - JSON — view, hand-edit, import, or export the raw config.
Save is disabled while validateConfig() reports any issues, so a broken config can't be published from the UI.
interface EstimatorConfig {
id: string;
name: string;
currency: string; // "USD", "EUR", ...
description?: string;
globalVariables?: Record<string, number>; // e.g. { hourly_rate: 95 }
services: ServiceDef[];
rules?: RuleDef[]; // discounts, surcharges, minimums
intake?: IntakeConfig; // once-per-config contact/info section (see below)
submission?: SubmissionConfig; // "Get Estimate" submission endpoint (see below)
}
interface ServiceDef {
id: string; // referenced in formulas via svc("id")
name: string;
description?: string;
optional?: boolean; // shows an on/off toggle
enabledByDefault?: boolean;
hiddenFromCustomer?: boolean; // priced into the total, never shown in the customer widget (see below)
fields: FieldDef[];
formula: string; // must evaluate to a number
dependsOn?: string[]; // explicit ordering hint (also auto-inferred from svc() calls)
}
interface FieldDef {
id: string; // referenced by name inside this service's formula
label: string;
type: "number" | "boolean" | "select" | "multiselect" | "text" | "textarea" | "email" | "tel";
default?: number | boolean | string | string[];
min?: number; max?: number; step?: number; // number fields
options?: { value: string; label: string; weight?: number }[]; // select / multiselect
placeholder?: string; // text / textarea / email / tel fields
help?: string;
required?: boolean;
}
interface RuleDef {
id: string;
label: string;
condition: string; // boolean expression, e.g. "total > 5000"
effect: string; // expression producing the new total, e.g. "total * 0.9"
}
interface IntakeConfig {
title?: string;
description?: string;
fields: FieldDef[]; // typically text/email/tel — rendered once, not per-service
}
interface SubmissionConfig {
endpoint: string; // your backend URL; the widget POSTs a JSON payload here
buttonLabel?: string; // defaults to "Get Estimate"
headers?: Record<string, string>;
}Formulas are plain strings evaluated by a small hand-written parser — not eval/Function, so a formula can never execute arbitrary code, only arithmetic over the variables you've exposed to it.
| Category | Examples |
|---|---|
| Arithmetic | + - * / % ^ (^ is exponent, right-associative) |
| Comparison | == != < > <= >= |
| Logical | && || ! (short-circuiting) |
| Conditional | condition ? a : b |
| Grouping | (a + b) * c |
| Functions | min(a, b), max(a, b), round(x, digits), floor(x), ceil(x), abs(x), clamp(x, lo, hi) |
| Cross-service | svc("otherServiceId") — that service's computed amount (0 if disabled) |
Variables in scope for a service's formula:
- Every field
idon that service (numbers/booleans as-is; selects as their string value) fieldId_weightforselect/multiselectfields — theweightof the selected option (summed for multiselect)fieldId_countformultiselectfields — number of options selected- Every key in
globalVariables svc("id")to pull in another service's amount
text, textarea, email, and tel fields are never added to formula scope — they're informational only (notes, addresses, etc.). Referencing one of their ids in a formula is treated the same as any other typo: an "unknown variable" error on that service, reported per-service without breaking the rest of the estimate. The Builder's formula editor won't offer them as autocomplete chips either, and flags a reference to one as an unresolved variable.
Variables in scope for a rule's condition/effect:
total— the running total (subtotal after all services, or after earlier rules)- Every key in
globalVariables svc("id")for any service's amount
Example formulas:
pages * 400 * tier_weight
hours * hourly_rate + (cms ? 800 : 0)
1200 + min(products, 500) * 6
svc("design") * 0.1 + svc("development")
total > 15000 ? total * 0.9 : total
List dependsOn: ["otherId"] on a service (or just call svc("otherId") in its formula — references are auto-detected too). Services are computed in dependency order via a topological sort; a circular dependency is reported as an error on that estimate rather than crashing the widget, and every other service still computes normally.
Set hiddenFromCustomer: true on a service (e.g. a materials or vendor-cost line you set yourself, not the customer) and it's priced into the subtotal/total exactly like any other service, but <EstimatorWidget /> never renders it — no section, no fields, no $ amount. It behaves normally everywhere else: the admin Builder (including its live Preview, since Preview is <EstimatorWidget />) shows it in full, and it can still be referenced from other services' formulas via svc("id").
{
id: "materials",
name: "Materials",
hiddenFromCustomer: true,
fields: [],
formula: "sqft * material_rate", // material_rate from globalVariables, sqft from another service via svc()
}Note this only hides the explicit line item — a customer who sums the visible line items and compares against the total could still infer that something else is priced in, just not what or how much.
Two optional, independent pieces:
intake adds a once-per-config section — rendered after the services, before the total — for information that isn't tied to any single service (name, email, phone, project notes, ...). It reuses the same FieldDef shape as service fields, so required and per-field validation work the same way.
submission adds a "Get Estimate" button. On click, the widget POSTs a JSON payload to submission.endpoint — your own backend. This package never calls an email API directly; wiring that endpoint up to actually send an email/notification is on your backend.
const config: EstimatorConfig = {
// ...services, rules...
intake: {
title: "Your details",
description: "So we know who to send this estimate to.",
fields: [
{ id: "name", label: "Full name", type: "text", required: true },
{ id: "email", label: "Email", type: "email", required: true },
{ id: "phone", label: "Phone", type: "tel" },
],
},
submission: {
endpoint: "https://your-backend.example.com/estimate-requests",
buttonLabel: "Get Estimate",
},
};
<EstimatorWidget
config={config}
onSubmitSuccess={(payload) => console.log("submitted", payload)}
onSubmitError={(error) => console.error("submission failed", error)}
/>The button stays disabled until every required intake field is filled and any email-type field passes a basic format check — with inline per-field error messages, same as native form validation. The POST body (EstimateSubmissionPayload) includes the config id/name, a timestamp, the intake values, every service's field values, and the full computed EstimateResult (subtotal, total, per-service amounts, applied rules) — everything your backend needs to build the email without a second lookup.
If you're not using <EstimatorWidget /> directly, buildSubmissionPayload, submitEstimate, and the useEstimatorSubmission hook are all exported individually so you can wire up your own submit UI.
calculateEstimate never throws. A bad formula, an unknown variable, or a dependency cycle is captured per-service (or per-rule) in the result (ServiceResult.error, RuleResult.error) and summarized in EstimateResult.errors — so one typo in a formula degrades gracefully instead of breaking the whole estimate for visitors.
Nothing about the look is hardcoded into the components. There are four ways to change it, from lightest to heaviest touch:
1. CSS variables, globally. All styles are scoped under .pe-* classes and driven entirely by CSS custom properties. Override them on :root or any wrapper element to match your site:
.my-estimator-wrapper {
--pe-color-accent: #7c3aed;
--pe-radius: 4px;
--pe-font: "Inter", sans-serif;
}2. CSS variables, per instance. Pass a theme prop to override just one widget without touching global CSS — handy if you're rendering several estimators with different brand colors on the same page:
<EstimatorWidget
config={config}
theme={{ "--pe-color-accent": "#7c3aed", "--pe-radius": "4px" }}
/>Builder accepts the same theme prop.
3. Your own CSS, targeting our class names. Every element (.pe-widget, .pe-service, .pe-field__input, .pe-btn--primary, …) is a plain, unscoped, unhashed class name — nothing uses CSS Modules or a styled-components-style hash. A same-specificity or higher rule in your own stylesheet simply wins, so you can restyle anything (spacing, borders, layout, font weights) without fighting specificity wars.
4. No built-in styles at all. Pass unstyled (or simply don't import fcestimator/styles.css) and the components render with zero built-in appearance — while keeping every .pe-* class name in place as a hook for your own stylesheet built entirely from scratch:
import { EstimatorWidget } from "fcestimator";
// no "fcestimator/styles.css" import at all
<EstimatorWidget config={config} unstyled />See src/styles/estimator.css for the full list of variables, and the doc comment on EstimatorTheme in src/components/EstimatorWidget.tsx for the typed list you get autocomplete for.
- React: 17, 18, and 19 are all supported (
peerDependenciesallow>=17 <20); the package itself ships no React version, so it uses whatever your app already has installed. - Module formats: published as both ESM (
import) and CommonJS (require), plus.d.tstypes, viaexportsmap — works with Next.js, Vite, Create React App, Remix, and plain Node/CommonJS tooling without extra config. - Browsers: compiled to ES2019, which covers all evergreen browsers (Chrome/Edge/Firefox/Safari, roughly the last ~5 years). No IE11 support.
- SSR:
calculateEstimateand the formula engine are pure, dependency-free TypeScript — safe to run on the server (Next.js RSC/SSR, etc). The React components useuseState/useEffectand should be rendered client-side (e.g. behind"use client"in Next.js's App Router) since they're interactive forms. - Node:
engines.node >= 18for the build tooling; the published output itself has no Node-specific APIs and runs fine in any browser or bundler.
import {
EstimatorWidget, // React component: renders + computes live, handles intake + submission UI
Builder, // React component: visual config editor
useEstimator, // hook: (config, initialState?) -> { state, result, setFieldValue, setIntakeValue, setServiceEnabled, reset }
useEstimatorSubmission, // hook: (config, state, result, options?) -> { intakeErrors, isValid, status, error, submit, reset }
calculateEstimate, // (config, state) -> EstimateResult — pure function, usable outside React (e.g. on a server)
createDefaultState, // (config) -> EstimatorState
validateConfig, // (config) -> ValidationIssue[]
isConfigValid, // (config) -> boolean
evaluateExpr, // (formula, scope, extraFns?) -> string | number | boolean
validateIntakeFields, // (fields, values) -> Record<fieldId, errorMessage>
isIntakeValid, // (fields, values) -> boolean
buildSubmissionPayload, // (config, state, result) -> EstimateSubmissionPayload
submitEstimate, // (endpoint, payload, options?) -> Promise<{ ok, status?, error? }>
} from "fcestimator";Because calculateEstimate is a plain pure function, you can also run it server-side (e.g. to generate a PDF quote or validate a submitted estimate before creating an invoice) using the same config and formulas as the widget.
assets/
logo.svg # full lockup (icon + wordmark)
mark.svg # icon only, for favicons/app icons
scripts/
copy-css.mjs # copies src/styles/estimator.css -> dist/styles.css on build
src/
types.ts # config schema
engine/
expr.ts # tokenizer + parser + evaluator (the formula language)
calculator.ts # dependency resolution + estimate computation
validate.ts # config validation (used by the Builder)
submit.ts # intake validation + submission payload + fetch POST
utils/
topsort.ts # dependency ordering for services
fieldTypes.ts # which field types are informational (excluded from formula scope)
components/
EstimatorWidget.tsx # customer-facing widget (services, intake section, submit button)
FieldInput.tsx
admin/
Builder.tsx # visual config editor
ServiceEditor.tsx
FieldEditor.tsx
RuleEditor.tsx
FormulaEditor.tsx # formula input w/ live validation + variable picker
IntakeEditor.tsx # editor for the contact/intake section
SubmissionEditor.tsx # editor for the submission endpoint
hooks/
useEstimator.ts # owns EstimatorState, computes EstimateResult
useEstimatorSubmission.ts # owns "Get Estimate" submit status + validation
styles/estimator.css
examples/demo/ # runnable Vite app showing both widget + builder
LICENSE # MIT
npm install
npm run dev # tsup --watch, builds dist/ on change
npm test # vitest
npm run typecheck
cd examples/demo
npm install
npm run dev # runs the demo app against the local package0.2.0
- New field types:
text,textarea,email,tel— informational fields for collecting free-form info (notes, addresses, etc.) alongside priced fields. They're excluded from formula scope by design; see The formula language. - New
config.intake— an optional, once-per-config contact/info section separate from services. - New
config.submission+ built-in "Get Estimate" button — POSTs a JSON payload (intake + selections + computed estimate) to your own backend, with required-field/email validation gating the button. See Collecting contact info & submitting an estimate. - New exports:
useEstimatorSubmission,validateIntakeField(s),isIntakeValid,buildSubmissionPayload,submitEstimate,IntakeEditor,SubmissionEditor,isInformationalFieldType. useEstimatorgainedsetIntakeValue.EstimatorWidgetgainedonSubmitSuccess,onSubmitError,submissionHeadersprops.- New
ServiceDef.hiddenFromCustomer— prices a service into the total without ever showing its section/fields/amount to the customer. See Hiding a service's cost from the customer. - All additions are optional/additive — existing configs, persisted
EstimatorState, and current usage keep working unchanged.
0.1.1
- Bumped
vitest(dev dependency only) to resolve esbuild/vite security advisories. No change to published output.
MIT