# Monthly Payment Estimate — React kit

A drop-in "estimated monthly payment" widget for the public Monthly Payment API.

- Guide and display rules: https://payment-estimate-api.pages.dev/
- Live test page (styles A, B and C): https://payment-estimate-api.pages.dev/test/

## Install (copy 3 files)

```
components/payment-estimate/
  payment-estimate-api.ts        # types + getOptions/getEstimate (no dependencies)
  MonthlyPaymentEstimate.tsx     # 'use client' React component (default export)
  monthly-payment-estimate.css   # styles, all classes prefixed .pme-
```

Import the CSS once, in `app/layout.tsx` (App Router) or `pages/_app.tsx` (Pages Router):

```tsx
import '@/components/payment-estimate/monthly-payment-estimate.css';
```

It needs React 18 or 19 and TypeScript 5. There are no other dependencies. Browser calls are allowed from mysheds.com (any subdomain) and localhost.

## Usage (Next.js 15 page)

```tsx
import MonthlyPaymentEstimate from '@/components/payment-estimate/MonthlyPaymentEstimate';

// A · auto: payments update as the customer answers (no button)
<MonthlyPaymentEstimate price={totalPrice} />

// B · dropdown: one large payment with a term dropdown
<MonthlyPaymentEstimate price={totalPrice} zip={deliveryZip} displayStyle="dropdown" />

// C · button: "See monthly payment" button, then term tiles
<MonthlyPaymentEstimate price={totalPrice} displayStyle="button" />
```

`price` comes from your price calculator. When it changes, the widget fetches new numbers on its own (debounced 400 ms; older responses are ignored). In the `button` style, nothing is fetched until the button is clicked. After that first click, it updates automatically like `auto`.

For custom-quote structures that have no set price, don't render the widget. Show "request a quote" instead.

## Props

| Prop | Type | Default | Notes |
|---|---|---|---|
| `price` | `number` | **required** | Total price in dollars. Must be > 0 before the widget calls the API. |
| `brand` | `string` | `'stoltzfus'` | Brand slug. |
| `zip` | `string` | — | Delivery ZIP. If you leave it out, the widget shows a ZIP input. |
| `displayStyle` | `'auto' \| 'dropdown' \| 'button'` | `'auto'` | `auto` and `button` show term tiles. `dropdown` shows one payment and a `<select>`. |
| `apiBase` | `string` | `https://payment-estimate-api.pages.dev` | |
| `className` | `string` | — | Added to the root `.pme` element. |
| `onEstimate` | `(data: EstimateResponse) => void` | — | Runs after every successful estimate. |
| `onApplyClick` | `(lender, term, offer?) => void` | — | Runs when an Apply link is clicked, before navigation. `lender` is `'upgrade' \| 'thrive' \| 'rto'`. |
| `initialCreditScore` | `string` | — | One of the `/options` values. It takes priority over the remembered answer. |
| `initialOwnProperty` | `string` | — | One of the `/options` values (`own`, `biz` or `no`). |
| `showDisclosures` | `boolean` | `true` | Renders `disclosures.forThisWidget`. Only turn this off if you render those disclosures on the page yourself. |
| `persistAnswers` | `boolean` | `true` | Remembers the 2 answers in `localStorage['pmt_est_answers']`. If storage is blocked, the widget still works. |
| `simulate` | `'unavailable' \| 'rto_unavailable' \| 'invalid' \| 'slow'` | — | For testing only. Never set it in production. |

Question labels, placeholders, help text and dropdown values are all loaded from `/options`, so none of them are hardcoded.

## Styling

Override the CSS custom properties on `.pme` or on any wrapper element:

```css
.my-product .pme {
  --pme-green: #486557; --pme-tan: #FFF4E6; --pme-gold: #958e09;
  --pme-font: 'Rubik', sans-serif; --pme-radius: 0; --pme-tile-min: 132px;
}
```

Every class starts with `.pme-` and the CSS uses no global element selectors. The layout works at 360 px wide and up.

## How errors look

| Situation | What the customer sees |
|---|---|
| **503** `estimates_unavailable` (we're confirming a rules change), a network error, or a failed `/options` load | Payments are hidden and a short neutral note says **"Payment estimates are temporarily unavailable."** |
| **400** `invalid_input` | The API's own customer-ready messages (for example "Enter a 5-digit US ZIP code") appear under the matching field. Price errors appear above the results. |
| No loans match | `loans.message` |
| Rent-to-own not offered (state, ZIP, amount or quote) | `rentToOwn.reasonText` |
| Price over every lender's max | `loans.notice` (for example "Over the $200,000 max — payments shown are for $200,000"), shown right above the loan payments |
| Price over the rent-to-own max | `rentToOwn.overMaxText`, shown next to the rent-to-own payments |

The widget never throws. `getOptions` and `getEstimate` both return `{ ok: true, data }` or `{ ok: false, status, error, fields?, message? }`.

To test without the live outage, pass `simulate="unavailable"`. You can also open `…/estimate?…&simulate=unavailable` in a browser tab.

## Two things to know if you build your own UI

- **`loans.notice`**: when no lender can cover the full price, `loans.terms` shows the one eligible lender that covers the most, at its capped amount. `loans.notice` then carries the warning. Always show it next to the loan payments when it isn't `null`.
- **Use `applyLink`, not `applyUrl`**, for every Apply button (`loans.terms[].offers[].applyLink`, `rentToOwn.applyLink`). `applyLink` counts the click and then forwards to the lender. `applyUrl` skips the count. Open it in a new tab only when `applyOpensNewTab` is true.

## Display rules (the widget already follows these)

1. Every loan payment is shown with its APR and term. A payment never appears alone.
2. The `*` on APRs and the `†` on interest-only terms are kept. The matching disclosures appear on the page.
3. Lender names appear only at the Apply step. The term tiles and dropdown never name a lender.
4. Rent-to-own never shows an APR or interest rate, because it's a lease, not a loan.
5. Never call any of these products "unsecured".
6. When the API returns `loans.notice` or `rentToOwn.overMaxText`, it is shown next to those payments.
7. Every block in `disclosures.forThisWidget` is rendered in order, word for word, using its `html`. The client sanitizer keeps only `<strong>`, `<b>`, `<em>` and `<sup>`.
8. Each Apply button has its `applyHint` next to it. Apply links go to `applyLink` and open in a new tab only when `applyOpensNewTab` is true.

Questions: Tim Brazil, Lending Manager.
