@akinon/pz-theme 2.0.36-rc.0 → 2.0.37-beta.0

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +16 -31
  2. package/jest/__mocks__/hookform-resolvers.js +5 -0
  3. package/jest/__mocks__/next-auth-react.js +5 -0
  4. package/jest.config.js +3 -0
  5. package/package.json +4 -4
  6. package/src/blocks/accordion-block.tsx +35 -3
  7. package/src/blocks/commerce-format.ts +220 -0
  8. package/src/blocks/icon-block.tsx +6 -1
  9. package/src/blocks/image-gallery-block.tsx +1 -1
  10. package/src/blocks/link-block.tsx +9 -1
  11. package/src/blocks/slider-block.tsx +2 -1
  12. package/src/blocks/tab-block.tsx +15 -2
  13. package/src/blocks/video-block.tsx +6 -0
  14. package/src/components/__tests__/part-state-preview-scope.test.tsx +148 -0
  15. package/src/components/action-toolbar.tsx +182 -3
  16. package/src/components/part-state-preview.tsx +92 -0
  17. package/src/hooks/__tests__/use-overlay.test.tsx +503 -0
  18. package/src/hooks/use-designer-features.tsx +16 -1
  19. package/src/hooks/use-overlay.ts +538 -0
  20. package/src/hooks/use-visibility-context.ts +14 -2
  21. package/src/sections/__tests__/divider-section.test.tsx +28 -1
  22. package/src/sections/contact-form-section.tsx +4 -1
  23. package/src/sections/coupon-banner-section.tsx +2 -1
  24. package/src/sections/featured-product-spotlight-section.tsx +4 -4
  25. package/src/sections/find-in-store-section.tsx +2 -1
  26. package/src/sections/hover-showcase-section.tsx +2 -1
  27. package/src/sections/installment-options-section.tsx +2 -1
  28. package/src/sections/order-tracking-lookup-section.tsx +6 -1
  29. package/src/sections/posts-slider-section.tsx +8 -3
  30. package/src/sections/pre-order-launch-banner-section.tsx +2 -1
  31. package/src/sections/tabs-section.tsx +7 -13
  32. package/src/theme-block.tsx +81 -16
  33. package/src/theme-placeholder-client.tsx +8 -1
  34. package/src/theme-placeholder-wrapper.tsx +17 -3
  35. package/src/theme-placeholder.tsx +51 -5
  36. package/src/theme-section.tsx +20 -4
  37. package/src/utils/__tests__/breakpoint-cascade.test.ts +351 -0
  38. package/src/utils/__tests__/group-box-chrome.test.ts +83 -0
  39. package/src/utils/__tests__/iterator-bindings.test.tsx +1178 -0
  40. package/src/utils/__tests__/part-ownership-guard.test.tsx +298 -0
  41. package/src/utils/breakpoints.ts +116 -0
  42. package/src/utils/custom-css.ts +233 -0
  43. package/src/utils/index.ts +353 -42
  44. package/src/utils/iterator-utils.ts +675 -62
  45. package/src/utils/page-context-discovery.test.ts +188 -0
  46. package/src/utils/page-context-discovery.ts +48 -4
  47. package/src/utils/part-styles.ts +229 -0
  48. package/src/utils/row-visibility.ts +273 -0
  49. package/src/utils/visibility-rules.test.ts +579 -0
  50. package/src/utils/visibility-rules.ts +346 -3
@@ -9,6 +9,7 @@ import {
9
9
  getResponsiveValue,
10
10
  resolveThemeCssVariables
11
11
  } from '../utils';
12
+ import { isNarrowBreakpoint } from '../utils/breakpoints';
12
13
 
13
14
  import { Section } from '../theme-section';
14
15
  import { WithDesignerFeatures } from '../components/with-designer-features';
@@ -115,7 +116,7 @@ const HoverShowcaseSection: React.FC<HoverShowcaseSectionProps> = ({
115
116
  themeSettings
116
117
  );
117
118
 
118
- const isMobile = currentBreakpoint === 'mobile';
119
+ const isMobile = isNarrowBreakpoint(currentBreakpoint);
119
120
 
120
121
  const items = (section.blocks || []).filter(
121
122
  (b: Block) => b.type === 'hover-showcase-item'
@@ -20,6 +20,7 @@ import {
20
20
  getResponsiveValue,
21
21
  resolveThemeCssVariables
22
22
  } from '../utils';
23
+ import { isNarrowBreakpoint } from '../utils/breakpoints';
23
24
 
24
25
  import { postToEditor } from '../editor-bridge';
25
26
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
@@ -417,7 +418,7 @@ export default function InstallmentOptionsSection({
417
418
  }: InstallmentOptionsSectionProps) {
418
419
  const themeSettings = useThemeSettingsContext();
419
420
  const { t } = useLocalization();
420
- const isMobile = currentBreakpoint === 'mobile';
421
+ const isMobile = isNarrowBreakpoint(currentBreakpoint);
421
422
 
422
423
  const maxWidth = getResponsiveValue(
423
424
  section.styles?.['max-width'],
@@ -22,6 +22,7 @@ import {
22
22
  getResponsiveValue,
23
23
  resolveThemeCssVariables
24
24
  } from '../utils';
25
+ import { isNarrowBreakpoint } from '../utils/breakpoints';
25
26
 
26
27
  import { postToEditor } from '../editor-bridge';
27
28
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
@@ -55,8 +56,12 @@ const readProperty = (
55
56
  if (typeof raw === 'string') return raw;
56
57
  if (typeof raw === 'object') {
57
58
  const responsive = raw as Record<string, unknown>;
59
+ // Same order as the emitted cascade: a tablet inherits mobile before it
60
+ // inherits desktop. Reading desktop first made 768-1023 show one thing and
61
+ // the stylesheet paint another.
58
62
  const picked =
59
63
  responsive[breakpoint] ??
64
+ (breakpoint === 'tablet' ? responsive.mobile : undefined) ??
60
65
  responsive.desktop ??
61
66
  responsive.mobile ??
62
67
  Object.values(responsive)[0];
@@ -1051,7 +1056,7 @@ export default function OrderTrackingLookupSection({
1051
1056
  const trackingUrl = resolveTrackingUrl(lookupOrder);
1052
1057
  const trackingNumber = resolveTrackingNumber(lookupOrder);
1053
1058
  const localeCode = locale || 'en';
1054
- const isMobile = currentBreakpoint === 'mobile';
1059
+ const isMobile = isNarrowBreakpoint(currentBreakpoint);
1055
1060
 
1056
1061
  return (
1057
1062
  <div
@@ -12,6 +12,7 @@ import {
12
12
  getCSSStyles,
13
13
  getResponsiveValue
14
14
  } from '../utils';
15
+ import { DESKTOP_MIN_WIDTH, TABLET_MIN_WIDTH } from '../utils/breakpoints';
15
16
 
16
17
  import { postToEditor } from '../editor-bridge';
17
18
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
@@ -275,19 +276,23 @@ const PostsSliderSection: React.FC<PostsSliderSectionProps> = ({
275
276
  ? tabletItems
276
277
  : desktopItems;
277
278
 
279
+ // This section had the three bands before the system did, and it picked the
280
+ // same two lines by hand. They come from the shared constants now, so the
281
+ // carousel switches at the width the stylesheet switches at rather than at a
282
+ // number that merely used to agree.
278
283
  const responsive = {
279
284
  desktop: {
280
- breakpoint: { max: 3000, min: 1024 },
285
+ breakpoint: { max: 3000, min: DESKTOP_MIN_WIDTH },
281
286
  items: desktopItems,
282
287
  slidesToSlide: desktopSlides
283
288
  },
284
289
  tablet: {
285
- breakpoint: { max: 1023, min: 768 },
290
+ breakpoint: { max: DESKTOP_MIN_WIDTH - 1, min: TABLET_MIN_WIDTH },
286
291
  items: tabletItems,
287
292
  slidesToSlide: tabletSlides
288
293
  },
289
294
  mobile: {
290
- breakpoint: { max: 767, min: 0 },
295
+ breakpoint: { max: TABLET_MIN_WIDTH - 1, min: 0 },
291
296
  items: mobileItems,
292
297
  slidesToSlide: mobileSlides
293
298
  }
@@ -9,6 +9,7 @@ import { useThemeSettingsContext } from '../theme-settings-context';
9
9
  import { safeDeepClone } from '../utils/safe-clone';
10
10
  import { Section } from '../theme-section';
11
11
  import { getCSSStyles, getResponsiveValue } from '../utils';
12
+ import { isNarrowBreakpoint } from '../utils/breakpoints';
12
13
 
13
14
  import { postToEditor } from '../editor-bridge';
14
15
  interface PreOrderLaunchBannerSectionProps {
@@ -352,7 +353,7 @@ export default function PreOrderLaunchBannerSection({
352
353
  return {
353
354
  ...baseStyles,
354
355
  flexDirection:
355
- currentBreakpoint === 'mobile' ? 'column-reverse' : 'row-reverse'
356
+ isNarrowBreakpoint(currentBreakpoint) ? 'column-reverse' : 'row-reverse'
356
357
  };
357
358
  }, [
358
359
  currentBreakpoint,
@@ -3,6 +3,7 @@ import { Section } from '../theme-section';
3
3
  import ThemeBlock from '../theme-block';
4
4
  import {
5
5
  getCSSStyles,
6
+ getResponsiveValue as readResponsiveValue,
6
7
  resolveThemeCssVariables,
7
8
  resolveThemeStyleObject
8
9
  } from '../utils';
@@ -42,20 +43,13 @@ const TabsSection: React.FC<TabsSectionProps> = ({
42
43
  )
43
44
  .sort((a, b) => (a.order || 0) - (b.order || 0));
44
45
 
46
+ // Reads through the shared chain rather than a local desktop fallback: a
47
+ // tablet inherits the mobile value, which is what the emitted stylesheet
48
+ // does at 768-1023. A private reader here made the markup disagree with the
49
+ // CSS on compositions nobody had touched.
45
50
  const getResponsiveValue = useCallback(
46
- <T,>(value: unknown, fallback: T): T => {
47
- if (value === undefined || value === null || value === '')
48
- return fallback;
49
- if (typeof value === 'object' && !Array.isArray(value)) {
50
- const responsiveValue = value as Record<string, T | undefined> & {
51
- desktop?: T;
52
- };
53
- const matchedValue =
54
- responsiveValue[currentBreakpoint] ?? responsiveValue.desktop;
55
- return matchedValue !== undefined ? (matchedValue as T) : fallback;
56
- }
57
- return value as T;
58
- },
51
+ <T,>(value: unknown, fallback: T): T =>
52
+ readResponsiveValue(value, currentBreakpoint, fallback) as T,
59
53
  [currentBreakpoint]
60
54
  );
61
55
 
@@ -3,9 +3,35 @@
3
3
  import React from 'react';
4
4
  import blockRendererRegistry from './blocks/block-renderer-registry';
5
5
  import { WithDesignerFeatures } from './components/with-designer-features';
6
- import { isPublishWindowVisible } from './utils';
6
+ import { getResponsiveValue, isPublishWindowVisible } from './utils';
7
7
  import { devWarn } from './utils/dev-warn';
8
8
 
9
+ /**
10
+ * Narrows what a designer typed into something usable as a document-wide id.
11
+ *
12
+ * The value ends up in three places at once — an `#fragment` in a link, an
13
+ * `id` attribute, and any `#id` selector written later — so it is reduced to
14
+ * the intersection all three accept. Stripped: whitespace (runs become a
15
+ * single hyphen first, so "Size guide" stays readable as "Size-guide"), and
16
+ * every character outside `[A-Za-z0-9_-]`. That drops the duplicate-hostile
17
+ * ones specifically: `#` and `.` and `:` and `[` `]` would split a selector,
18
+ * `/` and `?` would end a URL fragment, and quotes would close an attribute.
19
+ * Leading characters that are not a letter or underscore go too, because an id
20
+ * must not start with a digit. Nothing left means no id at all.
21
+ *
22
+ * Two blocks given the same id is the designer's problem, not a crash: the
23
+ * markup is still valid HTML and the browser simply jumps to the first one.
24
+ */
25
+ export const sanitizeHtmlId = (value: unknown): string => {
26
+ if (typeof value !== 'string') return '';
27
+
28
+ return value
29
+ .trim()
30
+ .replace(/\s+/g, '-')
31
+ .replace(/[^A-Za-z0-9_-]/g, '')
32
+ .replace(/^[^A-Za-z_]+/, '');
33
+ };
34
+
9
35
  export interface Block {
10
36
  id: string;
11
37
  styleSourceId?: string;
@@ -76,6 +102,28 @@ export default function ThemeBlock({
76
102
  return null;
77
103
  }
78
104
 
105
+ const htmlId = sanitizeHtmlId(
106
+ getResponsiveValue(block.properties?.htmlId, currentBreakpoint, '')
107
+ );
108
+
109
+ const renderedBlock = (
110
+ <BlockRenderer
111
+ block={block}
112
+ placeholderId={placeholderId}
113
+ sectionId={sectionId}
114
+ isDesigner={isDesigner}
115
+ isSelected={isSelected}
116
+ selectedBlockId={selectedBlockId}
117
+ currentBreakpoint={currentBreakpoint}
118
+ onMoveUp={onMoveUp}
119
+ onMoveDown={onMoveDown}
120
+ onDuplicate={onDuplicate}
121
+ onToggleVisibility={onToggleVisibility}
122
+ onDelete={onDelete}
123
+ onRename={onRename}
124
+ />
125
+ );
126
+
79
127
  return (
80
128
  <WithDesignerFeatures
81
129
  block={block}
@@ -91,21 +139,38 @@ export default function ThemeBlock({
91
139
  onDelete={onDelete}
92
140
  onRename={onRename}
93
141
  >
94
- <BlockRenderer
95
- block={block}
96
- placeholderId={placeholderId}
97
- sectionId={sectionId}
98
- isDesigner={isDesigner}
99
- isSelected={isSelected}
100
- selectedBlockId={selectedBlockId}
101
- currentBreakpoint={currentBreakpoint}
102
- onMoveUp={onMoveUp}
103
- onMoveDown={onMoveDown}
104
- onDuplicate={onDuplicate}
105
- onToggleVisibility={onToggleVisibility}
106
- onDelete={onDelete}
107
- onRename={onRename}
108
- />
142
+ {htmlId ? (
143
+ <>
144
+ {/*
145
+ The id rides a zero-size marker rather than the block's own
146
+ element. The wrapper this renders inside is the block's box, but it
147
+ belongs to WithDesignerFeatures, and every alternative that owns an
148
+ element changes the layout of a block that asked only for a name:
149
+ an extra flex item adds a gap, and `display: contents` has no box
150
+ at all, which is precisely what a browser needs to scroll to a
151
+ fragment. Absolute positioning keeps the marker out of flow (no
152
+ flex item, no grid cell, no space) while still generating the box
153
+ that makes the jump land — at the block's top-left corner, since
154
+ that wrapper is always `relative`.
155
+ */}
156
+ <span
157
+ id={htmlId}
158
+ data-theme-anchor=""
159
+ aria-hidden="true"
160
+ style={{
161
+ position: 'absolute',
162
+ top: 0,
163
+ left: 0,
164
+ width: 0,
165
+ height: 0,
166
+ overflow: 'hidden'
167
+ }}
168
+ />
169
+ {renderedBlock}
170
+ </>
171
+ ) : (
172
+ renderedBlock
173
+ )}
109
174
  </WithDesignerFeatures>
110
175
  );
111
176
  }
@@ -10,6 +10,7 @@ import {
10
10
  import { useVisibilityContext } from './hooks/use-visibility-context';
11
11
  import { applyVisibilityRulesToSections } from './utils/visibility-rules';
12
12
  import { isPublishWindowVisible } from './utils/publish-window';
13
+ import { useThemePageContext } from './theme-page-context';
13
14
 
14
15
  import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
15
16
  import { PreviewToEditorMessageType } from '@akinon/theme-contract';
@@ -46,7 +47,13 @@ export default function ThemePlaceholderClient({
46
47
  const [isDesigner, setIsDesigner] = useState(
47
48
  () => typeof window !== 'undefined' && window.__themeEditorDesigner === true
48
49
  );
49
- const visibilityContext = useVisibilityContext(currentBreakpoint);
50
+ // Null unless a ThemePageContextProvider sits above this placeholder; data
51
+ // conditions then have nothing to read and resolve the same way on both sides.
52
+ const pageContext = useThemePageContext();
53
+ const visibilityContext = useVisibilityContext(currentBreakpoint, {
54
+ pageContext,
55
+ isDesigner
56
+ });
50
57
  const renderedSections = applyVisibilityRulesToSections(
51
58
  sections,
52
59
  visibilityContext
@@ -9,8 +9,11 @@ import {
9
9
  unregisterPlaceholder
10
10
  } from './placeholder-registry';
11
11
  import { useThemePageContext } from './theme-page-context';
12
+ import { ThemePageContextProvider } from './theme-page-context';
12
13
  import { ThemeSettingsProvider } from './theme-settings-context';
13
14
  import { generateThemeCSS } from './utils';
15
+ import { breakpointForWidth } from './utils/breakpoints';
16
+ import { PartStatePreview } from './components/part-state-preview';
14
17
  import { useVisibilityContext } from './hooks/use-visibility-context';
15
18
  import { createPageContextDiscovery } from './utils/page-context-discovery';
16
19
  import { applyVisibilityRulesToSections } from './utils/visibility-rules';
@@ -427,8 +430,10 @@ export default function ThemePlaceholderWrapper({
427
430
  if (!checkIsInIframe()) {
428
431
  const checkBreakpoint = () => {
429
432
  const width = window.innerWidth;
430
- // Use 1024px as the cutoff for desktop/mobile based on DEFAULT_BREAKPOINTS
431
- const newBreakpoint = width < 1024 ? 'mobile' : 'desktop';
433
+ // The cutoffs live in utils/breakpoints, alongside the media queries
434
+ // the generated stylesheet is built from, so the viewport this reports
435
+ // and the query that is matching it can never drift apart.
436
+ const newBreakpoint = breakpointForWidth(width);
432
437
 
433
438
  setCurrentBreakpoint((prev) => {
434
439
  if (prev !== newBreakpoint) {
@@ -708,14 +713,22 @@ export default function ThemePlaceholderWrapper({
708
713
  generateThemeCSS(sections, isDesigner ? currentBreakpoint : undefined),
709
714
  [sections, isDesigner, currentBreakpoint]
710
715
  );
711
- const visibilityContext = useVisibilityContext(currentBreakpoint);
716
+ const visibilityContext = useVisibilityContext(currentBreakpoint, {
717
+ pageContext: resolvedPageContext,
718
+ isDesigner
719
+ });
712
720
  const renderedSections = useMemo(
713
721
  () => applyVisibilityRulesToSections(sections, visibilityContext),
714
722
  [sections, visibilityContext]
715
723
  );
716
724
 
717
725
  return (
726
+ // Blocks read commerce data through useThemePageContext(); the provider
727
+ // has to sit above the rendered sections or every data-driven block
728
+ // falls back to its empty state.
729
+ <ThemePageContextProvider value={resolvedPageContext}>
718
730
  <ThemeSettingsProvider value={themeSettings}>
731
+ <PartStatePreview isDesigner={isDesigner} />
719
732
  {dynamicCSS && <style dangerouslySetInnerHTML={{ __html: dynamicCSS }} />}
720
733
  <div
721
734
  data-placeholder={slug}
@@ -783,5 +796,6 @@ export default function ThemePlaceholderWrapper({
783
796
  ))}
784
797
  </div>
785
798
  </ThemeSettingsProvider>
799
+ </ThemePageContextProvider>
786
800
  );
787
801
  }
@@ -1,10 +1,56 @@
1
1
  import 'server-only';
2
2
 
3
- import {
4
- getCollectionWidgetData,
5
- getWidgetData,
6
- getWidgetSchemaData
7
- } from '@akinon/next/data/server';
3
+ import * as serverData from '@akinon/next/data/server';
4
+ import appFetch from '@akinon/next/utils/app-fetch';
5
+ import { ServerVariables } from '@akinon/next/utils/server-variables';
6
+
7
+ const { getWidgetData } = serverData;
8
+
9
+ /**
10
+ * v1 cores ship a smaller server data layer: only `getWidgetData` exists,
11
+ * `getWidgetSchemaData` and `getCollectionWidgetData` arrived with v2.
12
+ *
13
+ * The endpoints are identical on both backends — v1 just never wrapped them.
14
+ * Degrading to "no data" would render every published section *without its
15
+ * blocks* (a section's block tree lives in the widget schema, not in the
16
+ * widget), so on v1 we call the REST endpoints directly. This is what keeps
17
+ * composed pages rendering on the brands still running v1.
18
+ */
19
+ type ServerDataFn = (<T>(args: never) => Promise<T | null>) | undefined;
20
+
21
+ const optionalServerFn = (name: string): ServerDataFn =>
22
+ (serverData as unknown as Record<string, ServerDataFn>)[name];
23
+
24
+ const directFetch = async <T,>(url: string): Promise<T | null> => {
25
+ try {
26
+ const result = await appFetch<T>({
27
+ url,
28
+ locale: ServerVariables.locale,
29
+ currency: ServerVariables.currency
30
+ });
31
+ return result ?? null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ };
36
+
37
+ const getCollectionWidgetData = async <T,>(args: {
38
+ slug: string;
39
+ }): Promise<T | null> => {
40
+ const fn = optionalServerFn('getCollectionWidgetData');
41
+ if (fn) return (await fn<T>(args as never)) ?? null;
42
+ if (!args?.slug) return null;
43
+ return directFetch<T>(`/product_collection_widgets/${args.slug}/`);
44
+ };
45
+
46
+ const getWidgetSchemaData = async <T,>(args: {
47
+ widgetSlug: string;
48
+ }): Promise<T | null> => {
49
+ const fn = optionalServerFn('getWidgetSchemaData');
50
+ if (fn) return (await fn<T>(args as never)) ?? null;
51
+ if (!args?.widgetSlug) return null;
52
+ return directFetch<T>(`/widgets/${args.widgetSlug}/schema/`);
53
+ };
8
54
  import ThemePlaceholderWrapper from './theme-placeholder-wrapper';
9
55
  import { Section } from './theme-section';
10
56
  import { generateThemeCSS } from './utils';
@@ -9,9 +9,12 @@ import SectionWrapper from './sections/section-wrapper';
9
9
  import sectionRendererRegistry from './sections/section-renderer-registry';
10
10
  import {
11
11
  buildIteratorBlock,
12
- resolveBlockBindings
12
+ resolveBlockBindings,
13
+ setBindingLocale
13
14
  } from './utils/iterator-utils';
15
+ import { useLocalization } from '@akinon/next/hooks';
14
16
  import { devWarn } from './utils/dev-warn';
17
+ import { isNarrowBreakpoint } from './utils/breakpoints';
15
18
  import { safeDeepClone } from './utils/safe-clone';
16
19
 
17
20
  import { isTrustedEditorEvent, postToEditor } from './editor-bridge';
@@ -117,6 +120,13 @@ export default function ThemeSection({
117
120
 
118
121
  const sectionRef = useRef<HTMLElement>(null);
119
122
 
123
+ // Bound prices are formatted deep inside the binding walk, which has no
124
+ // React context to read. Publishing the storefront's locale before the
125
+ // bindings resolve is what stops an en-GB brand rendering Turkish number
126
+ // grouping in every repeated row.
127
+ const { locale } = useLocalization();
128
+ setBindingLocale(locale);
129
+
120
130
  useEffect(() => {
121
131
  if (!isDesigner) return;
122
132
 
@@ -615,9 +625,15 @@ export default function ThemeSection({
615
625
  )
616
626
  )
617
627
  );
618
- const tabletCount = Math.max(1, Math.min(desktopCount, 2));
619
- const activeCount =
620
- currentBreakpoint === 'mobile' ? mobileCount : desktopCount;
628
+ // This section offers a desktop count and a mobile count and no tablet
629
+ // control, so a tablet inherits the mobile count the same way every
630
+ // other unauthored value does. Synthesising `min(desktopCount, 2)` here
631
+ // put FEWER columns at 900px than the phone shows whenever the mobile
632
+ // count was above two — a regression on compositions nobody had edited.
633
+ const tabletCount = mobileCount;
634
+ const activeCount = isNarrowBreakpoint(currentBreakpoint)
635
+ ? mobileCount
636
+ : desktopCount;
621
637
  return {
622
638
  desktop: desktopCount,
623
639
  tablet: tabletCount,