@homefile/components-v2 2.60.0 → 2.60.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,7 +26,17 @@ function toSafeValue(value) {
26
26
  return value;
27
27
  }
28
28
  export const ReadOnlyDynamicForm = ({ form: fields, onClick, onEdit, }) => {
29
- return (_jsx(Box, { bg: "lightBlue.1", overflow: "scroll", children: fields === null || fields === void 0 ? void 0 : fields.map((field) => {
29
+ return (
30
+ // `overflow="auto"`, not `"scroll"`. `scroll` reserves a scrollbar gutter on
31
+ // BOTH axes whether or not there is anything to scroll — measured at 15px
32
+ // each in Chrome with classic (non-overlay) scrollbars. That gutter is
33
+ // inside the box, so every field rendered here stops 15px short of the
34
+ // panel's right edge and a dead 15px band runs along the bottom, which is
35
+ // what showed up as the item card and warranty banner "not filling the
36
+ // panel". Nothing in this form scrolls sideways, so the horizontal gutter
37
+ // was pure loss. `auto` shows each bar only when that axis actually
38
+ // overflows and reserves nothing otherwise.
39
+ _jsx(Box, { bg: "lightBlue.1", overflow: "auto", children: fields === null || fields === void 0 ? void 0 : fields.map((field) => {
30
40
  const { children = [], icon, id, name, type } = field;
31
41
  const value = toSafeValue(field.value);
32
42
  const baseProps = {
@@ -1,23 +1,67 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
- import { Text, Flex, Stack, IconButton, Box, Image, SimpleGrid } from '@chakra-ui/react';
3
+ import { Text, Flex, Stack, IconButton, Box, Image, SimpleGrid, Grid } from '@chakra-ui/react';
4
4
  import { Edit } from '../../../../../components';
5
5
  import { colors } from '../../../../../theme/colors';
6
6
  import { formatCurrency } from '../../../../../utils';
7
7
  // Dashed rule between sections (XD: light gray dashed).
8
8
  const DashedRule = () => (_jsx(Box, { borderBottom: "1px dashed", borderColor: "lightGray.2" }));
9
9
  // Brand / Model# / Serial# row — label #6C7679, value #464646 (XD).
10
- const DetailRow = ({ label, value }) => (_jsxs(Flex, { children: [_jsx(Text, { w: "35%", fontFamily: "secondary", fontSize: "14px", lineHeight: "18px", color: "#6C7679", children: label }), _jsx(Text, { fontFamily: "secondary", fontSize: "14px", lineHeight: "18px", color: "gray.3", children: value != null ? String(value) : '' })] }));
10
+ //
11
+ // The label column sizes to its content with a floor, rather than taking a
12
+ // fixed 35%: a real label is "Brand/Manufacturer:" (a schema key in ~50 places
13
+ // across the platform, so it cannot simply be shortened), and 35% of a narrow
14
+ // drawer is not wide enough for it. The percentage broke it mid-word into
15
+ // "Brand/Manufact" + "urer:". `keep-all` stops the mid-word break outright,
16
+ // so a label that still outgrows the column wraps at a natural boundary.
17
+ // Values arrive from different extractors with inconsistent casing — a brand
18
+ // comes back "Maytag", a product type "front-load washing machine" — so the
19
+ // column reads as a ransom note. Capitalize the FIRST character only and leave
20
+ // the rest of the string untouched: that makes the column consistent without
21
+ // rewriting the data, and keeps model numbers like "NC501" or "DCC-3400"
22
+ // exactly as extracted (title-casing them would produce "Nc501").
23
+ const leadCap = (s) => s.length > 0 ? s[0].toUpperCase() + s.slice(1) : s;
24
+ // Renders as TWO grid cells rather than a self-contained row, so every label
25
+ // shares one column and every value starts on the same left edge.
26
+ //
27
+ // Each row previously sized its own label independently, so the value column
28
+ // was ragged — the longest label ("Avg Retail Price Range:") pushed its own
29
+ // value right while the others sat further left. A fixed percentage was the
30
+ // original spec and broke the opposite way, wrapping "Brand/Manufacturer:"
31
+ // mid-word. A shared `max-content` column solves both: it is exactly as wide
32
+ // as the longest label, so nothing wraps and everything aligns.
33
+ const DetailRow = ({ label, value }) => (_jsxs(_Fragment, { children: [_jsx(Text, { wordBreak: "keep-all", fontFamily: "secondary", fontSize: "14px", lineHeight: "18px", color: "#6C7679", children: label }), _jsx(Text, { fontFamily: "secondary", fontSize: "14px", lineHeight: "18px", color: "gray.3", children: value != null ? leadCap(String(value)) : '' })] }));
34
+ // A value-row amount is currency-formatted only when it IS a single number.
35
+ //
36
+ // Catalog-resolved prices arrive as RANGE strings ("$229-$249") because that is
37
+ // what can honestly be established for a model. formatCurrency runs Number() on
38
+ // its input, and Number("$229-$249") is NaN, so formatting one prints "$NaN" on
39
+ // the owner's card. A non-numeric amount is already human-readable — render it
40
+ // as-is rather than mangling it.
41
+ const formatValueAmount = (value) => {
42
+ if (value == null || value === '')
43
+ return '';
44
+ const numeric = typeof value === 'number' ? value : Number(String(value).replace(/[$,\s]/g, ''));
45
+ return Number.isFinite(numeric)
46
+ ? formatCurrency({ value: numeric })
47
+ : String(value);
48
+ };
11
49
  // Retail Price / Est Replacement Cost — label left #6C7679, value right #464646.
12
- const ValueRow = ({ label, value, bold, }) => (_jsxs(Flex, { justify: "space-between", children: [_jsx(Text, { fontFamily: "secondary", fontSize: "16px", lineHeight: "20px", color: "#6C7679", children: label }), _jsx(Text, { fontFamily: "secondary", fontSize: "16px", lineHeight: "20px", color: "gray.3", fontWeight: bold ? 'bold' : 'normal', children: value != null && value !== ''
13
- ? formatCurrency({ value: value })
14
- : '' })] }));
50
+ const ValueRow = ({ label, value, bold, }) => (_jsxs(Flex, { justify: "space-between", children: [_jsx(Text, { fontFamily: "secondary", fontSize: "16px", lineHeight: "20px", color: "#6C7679", children: label }), _jsx(Text, { fontFamily: "secondary", fontSize: "16px", lineHeight: "20px", color: "gray.3", fontWeight: bold ? 'bold' : 'normal', children: formatValueAmount(value) })] }));
15
51
  export const ReadOnlyAppliances = ({ description, id, name, children = [], onEdit, value, }) => {
16
52
  var _a;
17
53
  const imageField = children.find(({ type }) => type === 'images');
18
54
  const images = (_a = imageField === null || imageField === void 0 ? void 0 : imageField.value) !== null && _a !== void 0 ? _a : [];
19
- const [mainImage, setMainImage] = useState(images[0]);
55
+ // The chosen thumbnail is stored; the displayed image is DERIVED from it.
56
+ //
57
+ // `useState(images[0])` captured the first image only on the first render —
58
+ // and images arrive after that render, so the card kept showing an empty
59
+ // <Image> (a broken-picture box) until something else forced a re-render.
60
+ // Deriving means there is no second copy of "which image" to go stale, and a
61
+ // selection that is no longer in the list falls back instead of blanking.
62
+ const [chosenImage, setChosenImage] = useState(undefined);
63
+ const mainImage = chosenImage && images.includes(chosenImage) ? chosenImage : images[0];
20
64
  const detailFields = children.filter(({ type }) => type === 'text');
21
65
  const valueFields = children.filter(({ type }) => type === 'currency');
22
- return (_jsxs(Stack, { spacing: "base", p: "base", bg: "neutral.white", mt: "2px", children: [(name || onEdit) && (_jsxs(Flex, { justify: "space-between", align: "center", w: "full", children: [_jsx(Text, { fontFamily: "primary", fontSize: "12px", letterSpacing: "0", color: "#525458", textTransform: "uppercase", children: name }), onEdit && (_jsx(IconButton, { "aria-label": "Edit appliance", variant: "ghost", w: "fit-content", minW: "auto", h: "auto", p: "0", flexShrink: 0, icon: _jsx(Edit, { size: 18, stroke: colors.blue[3] }), onClick: () => onEdit(id) }))] })), _jsxs(Flex, { gap: "base", align: "stretch", children: [_jsxs(Flex, { direction: "column", gap: "base", flexShrink: 0, w: "174px", children: [_jsx(Image, { src: mainImage, alt: "home item", boxSize: "174px", objectFit: "cover" }), images.length > 0 && (_jsx(SimpleGrid, { columns: 4, spacing: "2", children: images.map((image) => (_jsx(Box, { as: "button", backgroundImage: image, backgroundSize: "cover", backgroundPosition: "center", boxSize: "34px", onClick: () => setMainImage(image) }, image))) }))] }), _jsxs(Stack, { flex: "1", spacing: "base", children: [_jsxs(Stack, { spacing: "13px", children: [_jsx(Text, { fontFamily: "primary", fontWeight: "600", fontSize: "22px", lineHeight: "15px", letterSpacing: "0", color: "gray.3", children: value }), _jsx(Text, { fontFamily: "secondary", fontSize: "14px", lineHeight: "15px", color: "gray.3", children: description })] }), _jsx(DashedRule, {}), detailFields.length > 0 && (_jsx(Stack, { spacing: "0", children: detailFields.map((field) => (_jsx(DetailRow, { label: field.name ? `${field.name}:` : undefined, value: field.value }, `${field.id}-${field.name}`))) })), valueFields.length > 0 && (_jsxs(_Fragment, { children: [_jsx(DashedRule, {}), _jsx(Stack, { spacing: "0", children: valueFields.map((field, i) => (_jsx(ValueRow, { label: field.name, value: field.value, bold: i === valueFields.length - 1 }, `${field.id}-${field.name}`))) })] }))] })] })] }));
66
+ return (_jsxs(Stack, { spacing: "base", p: "base", bg: "neutral.white", mt: "2px", children: [(name || onEdit) && (_jsxs(Flex, { justify: "space-between", align: "center", w: "full", children: [_jsx(Text, { fontFamily: "primary", fontSize: "12px", letterSpacing: "0", color: "#525458", textTransform: "uppercase", children: name }), onEdit && (_jsx(IconButton, { "aria-label": "Edit appliance", variant: "ghost", w: "fit-content", minW: "auto", h: "auto", p: "0", flexShrink: 0, icon: _jsx(Edit, { size: 18, stroke: colors.blue[3] }), onClick: () => onEdit(id) }))] })), _jsxs(Flex, { gap: "base", align: "stretch", children: [_jsxs(Flex, { direction: "column", gap: "base", flexShrink: 0, w: "174px", children: [_jsx(Image, { src: mainImage, alt: "home item", boxSize: "174px", objectFit: "cover" }), images.length > 0 && (_jsx(SimpleGrid, { columns: 4, spacing: "2", children: images.map((image) => (_jsx(Box, { as: "button", backgroundImage: image, backgroundSize: "cover", backgroundPosition: "center", boxSize: "34px", onClick: () => setChosenImage(image) }, image))) }))] }), _jsxs(Stack, { flex: "1", spacing: "base", children: [_jsxs(Stack, { spacing: "13px", children: [_jsx(Text, { fontFamily: "primary", fontWeight: "600", fontSize: "22px", lineHeight: "26px", letterSpacing: "0", color: "gray.3", children: value }), _jsx(Text, { fontFamily: "secondary", fontSize: "14px", lineHeight: "15px", color: "gray.3", children: description })] }), _jsx(DashedRule, {}), detailFields.length > 0 && (_jsx(Grid, { templateColumns: "max-content 1fr", columnGap: "base", rowGap: "0", alignItems: "start", children: detailFields.map((field) => (_jsx(DetailRow, { label: field.name ? `${field.name}:` : undefined, value: field.value }, `${field.id}-${field.name}`))) })), valueFields.length > 0 && (_jsxs(_Fragment, { children: [_jsx(DashedRule, {}), _jsx(Stack, { spacing: "0", children: valueFields.map((field, i) => (_jsx(ValueRow, { label: field.name, value: field.value, bold: i === valueFields.length - 1 }, `${field.id}-${field.name}`))) })] }))] })] })] }));
23
67
  };
@@ -1,2 +1,2 @@
1
1
  import { ReadOnlyWarrantyBannerI } from '../../../../../interfaces';
2
- export declare const ReadOnlyWarrantyBanner: ({ icon, value, state, link, }: ReadOnlyWarrantyBannerI) => import("react/jsx-runtime").JSX.Element | null;
2
+ export declare const ReadOnlyWarrantyBanner: ({ icon, value, state, link, onAction, actionLabel, }: ReadOnlyWarrantyBannerI) => import("react/jsx-runtime").JSX.Element | null;
@@ -1,6 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Flex, Image, Link, Text } from '@chakra-ui/react';
2
+ import { t } from 'i18next';
3
+ import { Box, Circle, Flex, Image, Link, Text } from '@chakra-ui/react';
4
+ import { ArrowLeft } from '../../../../../components';
3
5
  import { fieldIcons } from '../../../../../helpers';
6
+ import { colors } from '../../../../../theme/colors';
4
7
  /**
5
8
  * Warranty-status banner. Built to the EXACT CSS from the "Read Only Item" XD
6
9
  * (dev/spec mode), under-warranty artboard:
@@ -25,18 +28,42 @@ import { fieldIcons } from '../../../../../helpers';
25
28
  const STATE = {
26
29
  underWarranty: { bg: '#F2F9F2', badge: '#E0EBE0', icon: 'warranty' },
27
30
  expired: { bg: 'lightGray.1', badge: 'lightGray.2', icon: 'warranty' }, // TODO: swap to the expired icon
31
+ // EXACT CSS from the unknown-warranty XD artboard (Matt, 09-21) — no longer
32
+ // provisional. Light blue reads INFORMATIONAL: deliberately neither the
33
+ // green of "covered" nor the gray of "lapsed", because the point of the
34
+ // state is that we do not know which it is.
35
+ // container #DEEDF7 · badge #BFDCEF · "?" #5F7482 Poppins SemiBold 29px
36
+ unverified: { bg: '#DEEDF7', badge: '#BFDCEF', icon: 'warranty' },
28
37
  recall: { bg: 'error.1', badge: 'error.2', icon: 'warranty' }, // TODO: swap to the recall icon
29
38
  };
30
- export const ReadOnlyWarrantyBanner = ({ icon, value, state = 'underWarranty', link, }) => {
39
+ export const ReadOnlyWarrantyBanner = ({ icon, value,
40
+ // Defaults to the NEUTRAL state, not to "covered". The only production
41
+ // caller spreads field data (ReadOnlyDynamicForm), so a warranty field that
42
+ // arrives without an explicit state used to assert coverage by omission —
43
+ // the same fail-unsafe default as the STATE lookup below. Callers that mean
44
+ // "under warranty" must say so.
45
+ state = 'unverified', link, onAction, actionLabel, }) => {
31
46
  var _a;
32
47
  // No data → render nothing. Never fabricate a status (critical for recall).
33
48
  if (!value)
34
49
  return null;
35
- const s = (_a = STATE[state]) !== null && _a !== void 0 ? _a : STATE.underWarranty;
50
+ // Fail SAFE on an unrecognised state: degrade to "we don't know", never to
51
+ // "you are covered". This is not hypothetical — a caller on a NEWER build
52
+ // than the installed package can send a state this copy has no key for (e.g.
53
+ // 'unverified' against 2.60.0), and a green fallback would assert coverage
54
+ // the data cannot support. Neutral is the only defensible default.
55
+ const s = (_a = STATE[state]) !== null && _a !== void 0 ? _a : STATE.unverified;
36
56
  // Field-provided icon overrides; otherwise the state's own (swappable) icon.
37
57
  const iconKey = icon !== null && icon !== void 0 ? icon : s.icon;
38
58
  const bannerIcon = iconKey ? fieldIcons[iconKey] : '';
39
- return (_jsxs(Flex, { align: "center", gap: "10px", minH: "76px", pl: "10px", pr: "base", bg: s.bg, mt: "2px", children: [_jsx(Flex, { align: "center", justify: "center", flexShrink: 0, boxSize: "45px", borderRadius: "full", bg: s.badge, children: state === 'recall' ? (
40
- // Extra-bold Poppins "!" sized to the internal icon (~29px).
41
- _jsx(Text, { fontFamily: "primary", fontWeight: "extrabold", fontSize: "29px", lineHeight: "1", color: "neutral.white", children: "!" })) : (bannerIcon && (_jsx(Image, { src: bannerIcon, alt: "warranty status", w: "27px", h: "29px", objectFit: "contain" }))) }), link ? (_jsx(Link, { href: link, isExternal: true, fontFamily: "primary", fontSize: "16px", lineHeight: "20px", letterSpacing: "0", color: "gray.3", sx: { textWrap: 'balance' }, children: String(value) })) : (_jsx(Text, { fontFamily: "primary", fontSize: "16px", lineHeight: "20px", letterSpacing: "0", color: "gray.3", sx: { textWrap: 'balance' }, children: String(value) }))] }));
59
+ // States drawn as a typographic glyph rather than an image asset. Both are
60
+ // punctuation the brand face already carries, so neither needs an icon file.
61
+ const glyph = state === 'recall' ? '!' : state === 'unverified' ? '?' : '';
62
+ return (_jsxs(Flex, { align: "center", gap: "10px", minH: "76px", pl: "10px", pr: "base", bg: s.bg, mt: "2px", children: [_jsx(Flex, { align: "center", justify: "center", flexShrink: 0, boxSize: "45px", borderRadius: "full", bg: s.badge, children: glyph ? (
63
+ // Typographic glyph sized to the internal icon (29px): "!" for
64
+ // recall, "?" for unverified. Recall keeps extra-bold white on its
65
+ // saturated badge; unverified is exact XD — Poppins SemiBold (600)
66
+ // in #5F7482, a desaturated slate that reads on the pale badge
67
+ // without competing with the message.
68
+ _jsx(Text, { fontFamily: "primary", fontWeight: state === 'recall' ? 'extrabold' : '600', fontSize: "29px", lineHeight: "1", color: state === 'recall' ? 'neutral.white' : '#5F7482', children: glyph })) : (bannerIcon && (_jsx(Image, { src: bannerIcon, alt: "warranty status", w: "27px", h: "29px", objectFit: "contain" }))) }), link ? (_jsx(Link, { href: link, isExternal: true, fontFamily: "primary", fontSize: "16px", lineHeight: "20px", letterSpacing: "0", color: "gray.3", sx: { textWrap: 'balance' }, children: String(value) })) : (_jsx(Text, { fontFamily: "primary", fontSize: "16px", lineHeight: "20px", letterSpacing: "0", color: "gray.3", sx: { textWrap: 'balance' }, children: String(value) })), onAction && (_jsx(Circle, { as: "button", type: "button", onClick: onAction, "aria-label": actionLabel !== null && actionLabel !== void 0 ? actionLabel : t('buttons.forward'), size: "40px", bg: "neutral.white", flexShrink: 0, ml: "auto", transition: "all 0.2s ease-in-out", _hover: { boxShadow: 'lg' }, children: _jsx(Box, { transform: "rotate(180deg)", lineHeight: "0", children: _jsx(ArrowLeft, { stroke: colors.blue[2], size: 24 }) }) }))] }));
42
69
  };
@@ -1,22 +1,39 @@
1
1
  import { IconTypes, ValueTypes } from '../../../../interfaces';
2
2
  /**
3
- * The three warranty states the banner can render. Derived upstream from the
4
- * item's purchase date + parsed warranty term (see tickets #356/#357):
3
+ * The warranty states the banner can render. Derived upstream from the item's
4
+ * purchase date + parsed warranty term (see tickets #356/#357):
5
5
  * - `underWarranty` — still covered
6
6
  * - `expired` — warranty term has lapsed
7
+ * - `unverified` — the TERM is known but the purchase DATE is not, so no
8
+ * expiry can be computed and neither "covered" nor "lapsed" is knowable
7
9
  * - `recall` — an active manufacturer recall
8
10
  *
9
11
  * HONESTY: `recall` must ONLY be passed with REAL recall data. There is no
10
12
  * recall data source in the platform today (#357), so callers must not pass
11
13
  * state="recall" until one exists — the banner never fabricates an alert.
14
+ *
15
+ * HONESTY: `unverified` is the DEFAULT-CORRECT state for catalog warranty
16
+ * data, and callers should reach for it rather than dressing the gap up as a
17
+ * status. We hold the term on most products (warranty_parts is populated on
18
+ * 68/91) but an item carries only `createdAt` — when WE first saw it, not when
19
+ * the owner bought it. Passing `underWarranty` there would assert coverage the
20
+ * user may not have, which is the most damaging thing this banner can do;
21
+ * passing `expired` borrows a lapsed-looking palette for something we simply
22
+ * do not know. `unverified` claims only what is true: this is the term, and
23
+ * the start date is missing.
12
24
  */
13
- export type WarrantyBannerState = 'underWarranty' | 'expired' | 'recall';
25
+ export type WarrantyBannerState = 'underWarranty' | 'expired' | 'unverified' | 'recall';
14
26
  export interface ReadOnlyWarrantyBannerI {
15
27
  /** Status message, e.g. "This item is under the manufacturer's warranty till 2030." */
16
28
  value?: ValueTypes;
17
29
  /** `fieldIcons` key for the badge glyph (e.g. 'boc-warranty'). */
18
30
  icon?: IconTypes;
19
- /** Which state to render. Defaults to `underWarranty`. */
31
+ /**
32
+ * Which state to render. Defaults to `unverified`, and an unrecognised
33
+ * value falls back to it too — the banner degrades to "we don't know",
34
+ * never to "you are covered". Pass `underWarranty` explicitly when coverage
35
+ * is actually known.
36
+ */
20
37
  state?: WarrantyBannerState;
21
38
  /**
22
39
  * Optional destination for the banner. Down the road: a recall notice on
@@ -24,4 +41,14 @@ export interface ReadOnlyWarrantyBannerI {
24
41
  * message becomes a link.
25
42
  */
26
43
  link?: string;
44
+ /**
45
+ * In-app action for the banner, rendered as the circular arrow button at the
46
+ * right edge (unknown-warranty XD). Distinct from `link`, which is an
47
+ * external href — "add your receipt" opens a panel, it does not navigate
48
+ * away. The button renders ONLY when this is supplied, so states and callers
49
+ * without an action are unchanged.
50
+ */
51
+ onAction?: () => void;
52
+ /** Accessible label for that button. Defaults to a generic action label. */
53
+ actionLabel?: string;
27
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homefile/components-v2",
3
- "version": "2.60.0",
3
+ "version": "2.60.1",
4
4
  "author": "Homefile",
5
5
  "license": "UNLICENSED",
6
6
  "typings": "dist/index.d.ts",