Skip to content

Repository files navigation

FCEstimator

FCEstimator

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, and tel fields 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.endpoint at 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.

Install

npm install fcestimator
import { EstimatorWidget } from "fcestimator";
import "fcestimator/styles.css";

Quick start

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.

Letting non-developers build pricing configs

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 EstimatorWidget a 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.

Config schema

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>;
}

The formula language

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 id on that service (numbers/booleans as-is; selects as their string value)
  • fieldId_weight for select/multiselect fields — the weight of the selected option (summed for multiselect)
  • fieldId_count for multiselect fields — 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

Dependencies between services

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.

Hiding a service's cost from the customer

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.

Collecting contact info & submitting an estimate

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.

Error handling

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.

Theming — every layer is overridable

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.

Browser & framework compatibility

  • React: 17, 18, and 19 are all supported (peerDependencies allow >=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.ts types, via exports map — 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: calculateEstimate and the formula engine are pure, dependency-free TypeScript — safe to run on the server (Next.js RSC/SSR, etc). The React components use useState/useEffect and should be rendered client-side (e.g. behind "use client" in Next.js's App Router) since they're interactive forms.
  • Node: engines.node >= 18 for the build tooling; the published output itself has no Node-specific APIs and runs fine in any browser or bundler.

API reference

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.

Project structure

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

Development

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 package

Changelog

0.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.
  • useEstimator gained setIntakeValue. EstimatorWidget gained onSubmitSuccess, onSubmitError, submissionHeaders props.
  • 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.

License

MIT

About

FCEstimator — a scalable, customizable project cost estimator widget for React

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages