diff --git a/docs/investment-calculator-widget.md b/docs/investment-calculator-widget.md
new file mode 100644
index 00000000..b2265f6d
--- /dev/null
+++ b/docs/investment-calculator-widget.md
@@ -0,0 +1,79 @@
+# InvestmentCalculatorWidget
+
+`InvestmentCalculatorWidget` (`src/components/widget/InvestmentCalculatorWidget.tsx`)
+is the investment form of the app. It is rendered on
+`/widget/investment-calculator` and embedded in other people's pages through
+that route, which serves it in an iframe.
+
+```tsx
+
+```
+
+The inputs are the amount invested, the expected annual yield, the holding
+period and the expected annual appreciation. The widget projects the rental
+income, the capital gains, the return on investment, the break-even point and
+the internal rate of return from them.
+
+## Projection
+
+The projection is calculated by `calculateInvestmentProjection`
+(`src/lib/investmentProjection.ts`). It is a pure function, so it is unit
+tested on its own and memoised in the component.
+
+The inputs come from free-form number fields, so anything can arrive: an empty
+field, a zero, a negative number. A projection is only meaningful for a
+positive amount invested over a positive holding period, so anything else is
+projected as zero. Without that guard the widget rendered `NaN%` and
+`Infinity mo` in the metric cards.
+
+## Layout
+
+The widget is embedded in a frame whose width is not known ahead of time: it
+can be a 600px column on a desktop and a 320px phone screen on a mobile.
+
+- The container fills the width of the frame (`width: 100%`, `max-width: 600px`,
+ `box-sizing: border-box`).
+- The paddings and the font sizes scale with the viewport (`clamp()`), so the
+ widget shrinks with the frame instead of overflowing it.
+- The metrics stay in two columns, but the columns are `minmax(0, 1fr)`, so
+ they can shrink below the width of their content. A fixed `1fr` cannot
+ shrink, which is what pushed the numbers out of a narrow frame.
+- The rows that hold a label and a number wrap, and long numbers break inside
+ their own box, so a six-figure total never pushes the layout out of the
+ frame.
+
+## Loading state
+
+The embed page used to render an empty box until the client had mounted, which
+read as a broken widget rather than a loading one. It now renders
+`InvestmentCalculatorSkeleton`, which has the same padding, the same grid and
+the same blocks as the widget, so the calculator takes its place without the
+frame jumping around. The skeleton is a `role="status"` element, so assistive
+technology is told that the calculator is on its way.
+
+## Error handling
+
+The widget is rendered inside somebody else's page, so a failure inside it
+would empty the frame and leave the visitor with nothing to look at. The
+widget is wrapped in the shared `ErrorBoundary`
+(`src/components/ui/SimpleErrorBoundary`) with a fallback that explains what
+happened and offers a retry. The retry remounts the boundary, which clears
+the error it caught.
+
+## Tests
+
+- `src/lib/__tests__/investmentProjection.test.ts`: the projection, including
+ the empty, zero, negative and non-finite inputs.
+- `src/components/widget/__tests__/InvestmentCalculatorWidget.test.tsx`: the
+ rendered figures, the recalculation, the responsive layout and the skeleton.
+- `src/components/widget/__tests__/InvestmentCalculatorWidget.error.test.tsx`:
+ a failure inside the widget is caught and turned into the retryable
+ fallback.
diff --git a/src/app/widget/investment-calculator/page.tsx b/src/app/widget/investment-calculator/page.tsx
index d88647cf..45c126d4 100644
--- a/src/app/widget/investment-calculator/page.tsx
+++ b/src/app/widget/investment-calculator/page.tsx
@@ -2,7 +2,10 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
-import { InvestmentCalculatorWidget } from '@/components/widget/InvestmentCalculatorWidget';
+import {
+ InvestmentCalculatorSkeleton,
+ InvestmentCalculatorWidget,
+} from '@/components/widget/InvestmentCalculatorWidget';
import { withRouteErrorBoundary } from '@/components/error/withRouteErrorBoundary';
function InvestmentCalculatorEmbedPage() {
@@ -13,8 +16,15 @@ function InvestmentCalculatorEmbedPage() {
setMounted(true);
}, []);
+ // Issue #1269 - the page used to show an empty box until the client had
+ // mounted. The skeleton has the same shape as the widget, so the calculator
+ // takes its place without the frame jumping around.
if (!mounted) {
- return
;
+ return (
+
+
+
+ );
}
const theme = {
diff --git a/src/components/widget/InvestmentCalculatorWidget.tsx b/src/components/widget/InvestmentCalculatorWidget.tsx
index 52e5b27b..491afbe5 100644
--- a/src/components/widget/InvestmentCalculatorWidget.tsx
+++ b/src/components/widget/InvestmentCalculatorWidget.tsx
@@ -1,6 +1,8 @@
'use client';
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useCallback, useMemo, useState } from 'react';
+import { ErrorBoundary } from '@/components/ui/SimpleErrorBoundary';
+import { calculateInvestmentProjection } from '@/lib/investmentProjection';
interface WidgetTheme {
primaryColor: string;
@@ -11,17 +13,6 @@ interface WidgetTheme {
brandName: string;
}
-interface CalculatorResults {
- totalReturn: number;
- annualReturn: number;
- roi: number;
- breakEvenMonths: number;
- irr: number;
- finalValue: number;
- totalRentalIncome: number;
- capitalGains: number;
-}
-
interface InvestmentCalculatorWidgetProps {
theme?: Partial;
defaultInvestment?: number;
@@ -40,104 +31,321 @@ const DEFAULT_THEME: WidgetTheme = {
brandName: 'PropChain',
};
+/**
+ * The widget is embedded in other people's pages through an iframe, so its
+ * width is not known ahead of time: it can be a 600px column or a 320px phone
+ * viewport. The sizes below scale with the viewport instead of assuming a
+ * fixed width, and the boxes that hold a number are allowed to wrap so a
+ * six-figure total never pushes the layout out of the frame.
+ */
+const ROOT_PADDING = 'clamp(12px, 4vw, 24px)';
+const TITLE_FONT_SIZE = 'clamp(18px, 4.5vw, 24px)';
+const SUBTITLE_FONT_SIZE = 'clamp(12px, 3vw, 14px)';
+const METRIC_FONT_SIZE = 'clamp(15px, 4vw, 20px)';
+const TOTAL_FONT_SIZE = 'clamp(18px, 5vw, 24px)';
+const BUTTON_FONT_SIZE = 'clamp(14px, 3.5vw, 16px)';
+const FIELD_GAP = 'clamp(12px, 3vw, 20px)';
+const GRID_GAP = 'clamp(8px, 2.5vw, 12px)';
+const CARD_PADDING = 'clamp(12px, 3vw, 20px)';
+/**
+ * The four metrics stay in two columns, but the columns are allowed to shrink
+ * below the width of their content. A fixed `1fr` cannot shrink, so a narrow
+ * frame used to push the numbers out of the widget.
+ */
+const METRIC_GRID = 'repeat(2, minmax(0, 1fr))';
+
+const FONT_STACK =
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
+
+/**
+ * One formatter for the whole module. `Intl.NumberFormat` parses the locale
+ * data every time it is constructed, and the widget formats a dozen values on
+ * every render.
+ */
+const CURRENCY_FORMATTER = new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ maximumFractionDigits: 0,
+});
+
function formatCurrency(value: number): string {
- return new Intl.NumberFormat('en-US', {
- style: 'currency',
- currency: 'USD',
- maximumFractionDigits: 0,
- }).format(value);
+ return CURRENCY_FORMATTER.format(Number.isFinite(value) ? value : 0);
}
-export function InvestmentCalculatorWidget({
- theme = {},
+/**
+ * The inputs are number fields, so an empty field arrives as an empty string.
+ * Anything that is not a number is read as zero rather than as `NaN`, which
+ * would otherwise be rendered as "NaN" in the widget.
+ */
+function toNumber(value: string): number {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : 0;
+}
+
+interface InvestmentCalculatorSkeletonProps {
+ theme?: Partial;
+ compact?: boolean;
+}
+
+/**
+ * Issue #1269 — the widget used to be preceded by an empty box while the
+ * embed page was still booting, which read as a broken widget rather than a
+ * loading one. The skeleton has the same shape as the widget - same padding,
+ * same grid, same blocks - so the form takes its place without the page
+ * jumping around, and it is marked up as a status so assistive technology
+ * says the calculator is on its way.
+ */
+export function InvestmentCalculatorSkeleton({
+ theme,
+ compact = false,
+}: InvestmentCalculatorSkeletonProps): React.ReactElement {
+ const mergedTheme: WidgetTheme = { ...DEFAULT_THEME, ...theme };
+ const block = (height: number, width = '100%'): React.CSSProperties => ({
+ height: `${height}px`,
+ width,
+ borderRadius: '8px',
+ backgroundColor: `${mergedTheme.primaryColor}1A`,
+ });
+
+ const fields = [0, 1, 2, 3];
+ const metrics = [0, 1, 2, 3];
+
+ return (
+
+
+
+
+
+
+
+ {fields.map((field) => (
+
+
+
+
+ ))}
+
+
+
+
+
+
+ {metrics.map((metric) => (
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+interface InvestmentCalculatorErrorFallbackProps {
+ theme: WidgetTheme;
+ onRetry: () => void;
+}
+
+/**
+ * Issue #1270 — the widget is rendered inside somebody else's page. An error
+ * that escaped it took the whole frame down, leaving an empty box with no
+ * explanation. The fallback keeps the failure inside the widget, explains it,
+ * and offers a retry.
+ */
+function InvestmentCalculatorErrorFallback({
+ theme,
+ onRetry,
+}: InvestmentCalculatorErrorFallbackProps): React.ReactElement {
+ return (
+
+
+ The calculator could not be loaded
+
+
+ Something went wrong while working out the projection. Try again - if it keeps
+ happening, reload the page.
+