diff --git a/packages/pages-components/.storybook/snapshots/__snapshots__/components-hoursstatus--coming-soon-translated.png b/packages/pages-components/.storybook/snapshots/__snapshots__/components-hoursstatus--coming-soon-translated.png new file mode 100644 index 00000000..9d5a3d7f Binary files /dev/null and b/packages/pages-components/.storybook/snapshots/__snapshots__/components-hoursstatus--coming-soon-translated.png differ diff --git a/packages/pages-components/.storybook/snapshots/__snapshots__/components-hoursstatus--coming-soon.png b/packages/pages-components/.storybook/snapshots/__snapshots__/components-hoursstatus--coming-soon.png new file mode 100644 index 00000000..9a7fa401 Binary files /dev/null and b/packages/pages-components/.storybook/snapshots/__snapshots__/components-hoursstatus--coming-soon.png differ diff --git a/packages/pages-components/CHANGELOG.md b/packages/pages-components/CHANGELOG.md index 636f76e2..954e613b 100644 --- a/packages/pages-components/CHANGELOG.md +++ b/packages/pages-components/CHANGELOG.md @@ -2,11 +2,11 @@ ##### Chores -* prevent shell injection in github action (vuln-44056) ([#148](https://github.com/yext/js/pull/148)) ([62edc854](https://github.com/yext/js/commit/62edc8541bb93c17941dc8c58e7f4bddb1ebc758)) +- prevent shell injection in github action (vuln-44056) ([#148](https://github.com/yext/js/pull/148)) ([62edc854](https://github.com/yext/js/commit/62edc8541bb93c17941dc8c58e7f4bddb1ebc758)) ##### New Features -* **pages-components:** expose entity prop to the analytics track method ([#149](https://github.com/yext/js/pull/149)) ([5e5932ab](https://github.com/yext/js/commit/5e5932abd2346475be18091a2f99a3dd50665122)) +- **pages-components:** expose entity prop to the analytics track method ([#149](https://github.com/yext/js/pull/149)) ([5e5932ab](https://github.com/yext/js/commit/5e5932abd2346475be18091a2f99a3dd50665122)) #### 2.1.1 (2026-04-21) diff --git a/packages/pages-components/src/components/hours/hoursStatus.hydration.test.tsx b/packages/pages-components/src/components/hours/hoursStatus.hydration.test.tsx index 53d2c965..3bcb59b3 100644 --- a/packages/pages-components/src/components/hours/hoursStatus.hydration.test.tsx +++ b/packages/pages-components/src/components/hours/hoursStatus.hydration.test.tsx @@ -31,7 +31,9 @@ describe("HoursStatus hydration", () => { Settings.defaultZone = "America/New_York"; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - vi.spyOn(globalThis, "setTimeout").mockImplementation((() => 0) as typeof setTimeout); + vi.spyOn(globalThis, "setTimeout").mockImplementation( + (() => 0) as unknown as typeof setTimeout + ); const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const container = document.createElement("div"); @@ -45,7 +47,10 @@ describe("HoursStatus hydration", () => { let root: ReturnType | undefined; await act(async () => { - root = hydrateRoot(container, ); + root = hydrateRoot( + container, + + ); await Promise.resolve(); }); @@ -59,4 +64,98 @@ describe("HoursStatus hydration", () => { root?.unmount(); }); }); + + it("hydrates to the coming soon label without warnings", async () => { + const mockedNow = DateTime.fromObject( + { year: 2025, month: 1, day: 7, hour: 10 }, + { zone: "America/New_York" } + ); + + Settings.now = () => mockedNow.toMillis(); + Settings.defaultZone = "America/New_York"; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation((() => 0) as unknown as typeof setTimeout); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const container = document.createElement("div"); + container.innerHTML = renderToString( +
+ ); + document.body.appendChild(container); + + expect(container.textContent).toBe(""); + + let root: ReturnType | undefined; + + await act(async () => { + root = hydrateRoot( + container, + + ); + await Promise.resolve(); + }); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(setTimeoutSpy).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Coming Soon"); + expect(container.textContent).not.toContain("Open Now"); + expect(container.textContent).not.toContain("Closed"); + expect(container.textContent).not.toContain("Closes at"); + expect(container.textContent).not.toContain("Opens at"); + + await act(async () => { + root?.unmount(); + }); + }); + + it("hydrates to the coming soon template override without warnings", async () => { + const mockedNow = DateTime.fromObject( + { year: 2025, month: 1, day: 7, hour: 10 }, + { zone: "America/New_York" } + ); + + Settings.now = () => mockedNow.toMillis(); + Settings.defaultZone = "America/New_York"; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation((() => 0) as unknown as typeof setTimeout); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const container = document.createElement("div"); + container.innerHTML = renderToString( +
+ ); + document.body.appendChild(container); + + expect(container.textContent).toBe(""); + + let root: ReturnType | undefined; + + await act(async () => { + root = hydrateRoot( + container, + "Pronto"} + /> + ); + await Promise.resolve(); + }); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(setTimeoutSpy).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Pronto"); + expect(container.textContent).not.toContain("Coming Soon"); + + await act(async () => { + root?.unmount(); + }); + }); }); diff --git a/packages/pages-components/src/components/hours/hoursStatus.stories.tsx b/packages/pages-components/src/components/hours/hoursStatus.stories.tsx index 9837fda0..6c64450e 100644 --- a/packages/pages-components/src/components/hours/hoursStatus.stories.tsx +++ b/packages/pages-components/src/components/hours/hoursStatus.stories.tsx @@ -85,6 +85,31 @@ export const Open247: Story = { }, }; +export const ComingSoon: Story = { + args: { + hours: HoursData, + comingSoon: true, + }, + parameters: { + mockedLuxonDateTime: DateTime.fromObject( + { year: 2025, month: 1, day: 7, hour: 10 } // Tuesday 10 AM + ), + }, +}; + +export const ComingSoonTranslated: Story = { + args: { + hours: HoursData, + comingSoon: true, + comingSoonTemplate: () => "Pronto", + }, + parameters: { + mockedLuxonDateTime: DateTime.fromObject( + { year: 2025, month: 1, day: 7, hour: 10 } // Tuesday 10 AM + ), + }, +}; + export const IndefinitelyClosedActive: Story = { args: { hours: HoursTemporarilyClosed, diff --git a/packages/pages-components/src/components/hours/hoursStatus.tsx b/packages/pages-components/src/components/hours/hoursStatus.tsx index 8fca83fc..9fde4955 100644 --- a/packages/pages-components/src/components/hours/hoursStatus.tsx +++ b/packages/pages-components/src/components/hours/hoursStatus.tsx @@ -10,11 +10,22 @@ function isOpen24h(params: StatusParams): boolean { return params?.currentInterval?.is24h?.() || false; } +function isComingSoon(params: StatusParams): boolean { + return !!params.comingSoon; +} + function isIndefinitelyClosed(params: StatusParams): boolean { return !params.futureInterval; } +function defaultComingSoonTemplate(_: StatusParams): React.ReactNode { + return Coming Soon; +} + function defaultCurrentTemplate(params: StatusParams): React.ReactNode { + if (isComingSoon(params)) { + return defaultComingSoonTemplate(params); + } if (isOpen24h(params)) { return Open 24 Hours; } @@ -25,21 +36,21 @@ function defaultCurrentTemplate(params: StatusParams): React.ReactNode { } function defaultSeparatorTemplate(params: StatusParams): React.ReactNode { - if (isOpen24h(params) || isIndefinitelyClosed(params)) { + if (isComingSoon(params) || isOpen24h(params) || isIndefinitelyClosed(params)) { return null; } return ; } function defaultFutureTemplate(params: StatusParams): React.ReactNode { - if (isOpen24h(params) || isIndefinitelyClosed(params)) { + if (isComingSoon(params) || isOpen24h(params) || isIndefinitelyClosed(params)) { return null; } return {params.isOpen ? "Closes at" : "Opens at"}; } function defaultTimeTemplate(params: StatusParams): React.ReactNode { - if (isOpen24h(params) || isIndefinitelyClosed(params)) { + if (isComingSoon(params) || isOpen24h(params) || isIndefinitelyClosed(params)) { return null; } let time = ""; @@ -54,7 +65,7 @@ function defaultTimeTemplate(params: StatusParams): React.ReactNode { } function defaultDayOfWeekTemplate(params: StatusParams): React.ReactNode { - if (isOpen24h(params) || isIndefinitelyClosed(params)) { + if (isComingSoon(params) || isOpen24h(params) || isIndefinitelyClosed(params)) { return null; } const dayOptions: Intl.DateTimeFormatOptions = { @@ -78,6 +89,7 @@ function defaultStatusTemplate( props?: HoursStatusProps ): React.ReactNode { const currentTemplate = params.currentTemplate || defaultCurrentTemplate; + const comingSoonTemplate = params.comingSoonTemplate || defaultComingSoonTemplate; const separatorTemplate = params.separatorTemplate || defaultSeparatorTemplate; const futureTemplate = params.futureTemplate || defaultFutureTemplate; const timeTemplate = params.timeTemplate || defaultTimeTemplate; @@ -85,7 +97,7 @@ function defaultStatusTemplate( return (
- {currentTemplate(params)} + {params.comingSoon ? comingSoonTemplate(params) : currentTemplate(params)} {separatorTemplate(params)} {futureTemplate(params)} {timeTemplate(params)} @@ -101,6 +113,8 @@ const emptyStyle = { minHeight: `${1.5}em` }; * describing the current Open/Closed status of the entity * * @param {HoursType} hours data from Yext Streams + * @param {Boolean} comingSoon display a coming soon state instead of the normal status + * @param {Function} comingSoonTemplate override rendering for the "coming soon" part of this component "[[Coming Soon]]" * @param {Intl.DateTimeFormatOptions} timeOptions * @param {Intl.DateTimeFormatOptions} dayOptions * @param {Function} statusTemplate completely override rendering for this component @@ -121,26 +135,34 @@ const HoursStatus: React.FC = (props) => { setIsClient(true); }, []); - if (!props.hours) { + const statusTemplateFn = props.statusTemplate || defaultStatusTemplate; + const comingSoon = !!props.comingSoon; + let isOpen = false; + let currentInterval: StatusParams["currentInterval"] = null; + let futureInterval: StatusParams["futureInterval"] = null; + + if (!props.hours && !comingSoon) { return <>; } - const statusTemplateFn = props.statusTemplate || defaultStatusTemplate; - const h = new Hours(props.hours, props.timezone); - const isOpen = h.isOpenNow(); - const currentInterval = h.getCurrentInterval(); - const futureInterval = h.getNextInterval(); - - // When the current interval ends, or the next interval starts, trigger component rerender - const isOpenChangeTime = currentInterval?.end || futureInterval?.start; - if (isOpenChangeTime && !hasStatusTimeout) { - setHasStatusTimeout(true); - const delayMS = isOpenChangeTime.toMillis() - DateTime.now().toMillis(); - setTimeout(() => setHasStatusTimeout(false), delayMS); + if (!comingSoon && props.hours) { + const h = new Hours(props.hours, props.timezone); + isOpen = h.isOpenNow(); + currentInterval = h.getCurrentInterval(); + futureInterval = h.getNextInterval(); + + // When the current interval ends, or the next interval starts, trigger component rerender + const isOpenChangeTime = currentInterval?.end || futureInterval?.start; + if (isOpenChangeTime && !hasStatusTimeout) { + setHasStatusTimeout(true); + const delayMS = isOpenChangeTime.toMillis() - DateTime.now().toMillis(); + setTimeout(() => setHasStatusTimeout(false), delayMS); + } } const statusParams: StatusParams = { isOpen, + comingSoon, currentInterval, futureInterval, ...props, diff --git a/packages/pages-components/src/components/hours/hoursTable.hydration.test.tsx b/packages/pages-components/src/components/hours/hoursTable.hydration.test.tsx index 2f68f87a..da2677c1 100644 --- a/packages/pages-components/src/components/hours/hoursTable.hydration.test.tsx +++ b/packages/pages-components/src/components/hours/hoursTable.hydration.test.tsx @@ -75,4 +75,50 @@ describe("HoursTable hydration", () => { root?.unmount(); }); }); + + it("stays empty when coming soon is enabled", async () => { + const mockedNow = DateTime.fromObject( + { year: 2025, month: 1, day: 9, hour: 12 }, + { zone: "America/New_York" } + ); + + Settings.now = () => mockedNow.toMillis(); + Settings.defaultZone = "America/New_York"; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockImplementation( + function (this: Intl.DateTimeFormat) { + return { + ...originalResolvedOptions.call(this), + timeZone: "America/New_York", + }; + } + ); + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const container = document.createElement("div"); + container.innerHTML = renderToString( + + ); + document.body.appendChild(container); + + expect(container.innerHTML).toBe(""); + + let root: ReturnType | undefined; + + await act(async () => { + root = hydrateRoot( + container, + + ); + await Promise.resolve(); + }); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(container.innerHTML).toBe(""); + + await act(async () => { + root?.unmount(); + }); + }); }); diff --git a/packages/pages-components/src/components/hours/hoursTable.ssr.test.tsx b/packages/pages-components/src/components/hours/hoursTable.ssr.test.tsx index 3e62bbde..7a36339f 100644 --- a/packages/pages-components/src/components/hours/hoursTable.ssr.test.tsx +++ b/packages/pages-components/src/components/hours/hoursTable.ssr.test.tsx @@ -36,4 +36,12 @@ describe("HoursTable SSR", () => { expect(html).not.toContain("is-today"); expect(dayLabels[0]).toBe("Sunday"); }); + + it("renders nothing when coming soon is enabled", () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const html = renderToString(); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(html).toBe(""); + }); }); diff --git a/packages/pages-components/src/components/hours/hoursTable.tsx b/packages/pages-components/src/components/hours/hoursTable.tsx index a0438504..e2f86353 100644 --- a/packages/pages-components/src/components/hours/hoursTable.tsx +++ b/packages/pages-components/src/components/hours/hoursTable.tsx @@ -146,6 +146,10 @@ const HoursTable: React.FC = (props) => { setIsClient(true); }, []); + if (props.comingSoon) { + return <>; + } + if (!props.hours) { return <>; } @@ -156,6 +160,10 @@ const HoursTable: React.FC = (props) => { }; export const ClientSideHoursTable: React.FC = (props) => { + if (props.comingSoon) { + return <>; + } + const h = new Hours(props.hours, Intl.DateTimeFormat().resolvedOptions().timeZone); const now = DateTime.now(); @@ -169,6 +177,10 @@ export const ClientSideHoursTable: React.FC = (props) => { }; export const ServerSideHoursTable: React.FC = (props) => { + if (props.comingSoon) { + return <>; + } + const { hours, dayOfWeekNames, intervalTranslations } = props; const hoursTableData: HoursTableDayData[] = days.map((day) => { diff --git a/packages/pages-components/src/components/hours/types.ts b/packages/pages-components/src/components/hours/types.ts index 55d13112..82fdbb90 100644 --- a/packages/pages-components/src/components/hours/types.ts +++ b/packages/pages-components/src/components/hours/types.ts @@ -37,6 +37,8 @@ export type DayOfWeekNames = { export interface HoursTableProps { /** Hours data from Yext Streams */ hours: HoursType; + /** Display an empty state instead of the table */ + comingSoon?: boolean; /** Label for each day of week, ordered starting from Sunday */ dayOfWeekNames?: DayOfWeekNames; /** Set the day of the first row of the table */ @@ -83,6 +85,8 @@ export enum Day { export interface StatusParams { /** Whether the entity is currently open */ isOpen: boolean; + /** Whether the entity should display a coming soon state */ + comingSoon?: boolean; /** The first interval that contains the current time */ currentInterval: HoursInterval | null; /** The next interval that hasn't started */ @@ -96,6 +100,8 @@ export interface StatusParams { export interface TemplateParams { /** Override rendering for the "current" part of this component "[[Open Now]] - closes at 5:00PM Monday" */ currentTemplate?: (s: StatusParams) => React.ReactNode; + /** Override rendering for the "coming soon" part of this component "[[Coming Soon]]" */ + comingSoonTemplate?: (s: StatusParams) => React.ReactNode; /** Override rendering for the "separator" part of this component "Open Now [[-]] closes at 5:00PM Monday" */ separatorTemplate?: (s: StatusParams) => React.ReactNode; /** FutureTemplate override rendering for the "future" part of this component "Open Now - [[closes at]] 5:00PM Monday" */ @@ -113,6 +119,8 @@ export interface HoursStatusProps extends TemplateParams { hours: HoursType; /** The IANA or UTC Offset timezone of the hours data from Yext Streams */ timezone: string; + /** Display a coming soon state instead of the normal open/closed status */ + comingSoon?: boolean; /** Formatting for the "time" part of this component "Open Now - closes at [[5:00PM]] Monday" */ timeOptions?: Intl.DateTimeFormatOptions; /** Formatting for the "day" part of this component "Open Now - closes at 5:00PM [[Monday]]" */