npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

fcestimator

v0.2.0

Published

FCEstimator — a scalable, customizable project cost estimator widget for React. Configure any set of services, fields, and pricing formulas (with variables, conditionals, and dependencies) and drop it into any site.

Downloads

442

Readme

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

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.
  • 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