orionplace-react 0.3.1 → 0.5.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 (42) hide show
  1. package/README.md +39 -4
  2. package/bin/orionplace-react.mjs +38 -7
  3. package/package.json +1 -1
  4. package/runtime/marketplace.mjs +12 -0
  5. package/src/install.mjs +5 -4
  6. package/src/project-readme.mjs +3 -0
  7. package/src/service.mjs +186 -0
  8. package/templates/app/StorefrontShell.tsx +8 -0
  9. package/templates/app/_storefront/PrivacyPage.tsx +15 -0
  10. package/templates/app/_storefront/storefrontRoutes.tsx +3 -0
  11. package/templates/app/privacy/page.tsx +9 -0
  12. package/templates/components/orionplace/blog/BlogArticle.README.md +16 -0
  13. package/templates/components/orionplace/blog/BlogArticle.module.css +5 -0
  14. package/templates/components/orionplace/blog/BlogArticle.tsx +60 -1
  15. package/templates/components/orionplace/legal/CookieNotice.README.md +42 -0
  16. package/templates/components/orionplace/legal/CookieNotice.module.css +48 -0
  17. package/templates/components/orionplace/legal/CookieNotice.tsx +82 -0
  18. package/templates/components/orionplace/legal/StorefrontPrivacy.README.md +49 -0
  19. package/templates/components/orionplace/legal/StorefrontPrivacy.module.css +166 -0
  20. package/templates/components/orionplace/legal/StorefrontPrivacy.tsx +83 -0
  21. package/templates/components/orionplace/products/ProductReviewImageViewer.README.md +13 -2
  22. package/templates/components/orionplace/products/ProductReviewImageViewer.module.css +10 -1
  23. package/templates/components/orionplace/products/ProductReviewImageViewer.tsx +61 -9
  24. package/templates/components/orionplace/storefront/StorefrontFooter.tsx +3 -0
  25. package/templates/components/orionplace/subscriptions/StorefrontTariff.README.md +13 -0
  26. package/templates/components/orionplace/subscriptions/StorefrontTariff.tsx +17 -6
  27. package/templates/components/orionplace/subscriptions/StorefrontTariffs.README.md +13 -0
  28. package/templates/components/orionplace/subscriptions/StorefrontTariffs.tsx +19 -2
  29. package/templates/components/orionplace/tariffs/TariffCard.tsx +8 -1
  30. package/templates/components/orionplace/tariffs/TariffCardPrices.README.md +10 -2
  31. package/templates/components/orionplace/tariffs/TariffCardPrices.tsx +12 -2
  32. package/templates/hooks/useOrionplaceTariffPurchase.ts +60 -0
  33. package/templates/lib/orionplace/blogImageViewer.ts +84 -0
  34. package/templates/lib/orionplace/blogLabels.ts +4 -0
  35. package/templates/lib/orionplace/footerLabels.ts +2 -0
  36. package/templates/lib/orionplace/pageMetadata.ts +1 -0
  37. package/templates/lib/orionplace/pageSeo.ts +10 -0
  38. package/templates/lib/orionplace/privacyLabels.ts +198 -0
  39. package/templates/lib/orionplace/reviewLabels.ts +4 -0
  40. package/templates/lib/orionplace/tariffLabels.ts +2 -0
  41. package/templates/lib/orionplace/tariffs.ts +49 -0
  42. package/templates/stores/orionplaceCatalog.ts +18 -0
@@ -154,6 +154,9 @@ export function StorefrontFooter({
154
154
  <a href={route('/contact')} className={styles.navigationLink}>
155
155
  {labels.contact}
156
156
  </a>
157
+ <a href={route('/privacy')} className={styles.navigationLink}>
158
+ {labels.privacy}
159
+ </a>
157
160
  </nav>
158
161
  </div>
159
162
  <p className={styles.copyright}>
@@ -30,6 +30,12 @@ the callbacks from `TariffPurchaseActions`. A failed required refresh hides the
30
30
  dialog, blocks another purchase and exposes Refresh without discarding the
31
31
  dialog fields.
32
32
 
33
+ For a signed-in customer the component also reads `subscriptions_get` once
34
+ through `onLoadSubscriptions`: plans of a subscription the customer already
35
+ had offer no trial, and a resumed trial opens only after that read, for a
36
+ customer new to the subscription. A failed read leaves the trial shown; the
37
+ backend still refuses a repeated trial.
38
+
33
39
  The component makes requests only through its callbacks. Source is maintained
34
40
  in `components/orionplace/subscriptions/StorefrontTariff.tsx`; the plan contract
35
41
  is `interfaces/orionplace/subscribeTariff/ISubscribeTariff.ts`.
@@ -85,6 +91,13 @@ is `interfaces/orionplace/subscribeTariff/ISubscribeTariff.ts`.
85
91
  обновление не удалось, окно скрывается, новое подключение блокируется и
86
92
  появляется кнопка обновления, а заполненные поля окна сохраняются.
87
93
 
94
+ Для авторизованного покупателя компонент также один раз читает
95
+ `subscriptions_get` через `onLoadSubscriptions`: тарифы подписки, которая у
96
+ покупателя уже была, не предлагают пробный период, а восстановленный пробный
97
+ выбор открывается только после этого чтения и только для нового покупателя
98
+ подписки. При ошибке чтения пробный период остаётся видимым; повторный пробный
99
+ период всё равно отклоняет сервер.
100
+
88
101
  Компонент отправляет запросы только через переданные функции. Исходный файл:
89
102
  `components/orionplace/subscriptions/StorefrontTariff.tsx`; контракт тарифа:
90
103
  `interfaces/orionplace/subscribeTariff/ISubscribeTariff.ts`.
@@ -11,7 +11,12 @@ import {
11
11
  useOrionplaceCatalogStore,
12
12
  useOrionplaceSelections,
13
13
  } from '@/providers/OrionplaceProvider';
14
- import { selectIsAuthenticated } from '@/stores/orionplace';
14
+ import {
15
+ selectIsAuthenticated,
16
+ selectProfile,
17
+ } from '@/stores/orionplace';
18
+ import { selectUsedSubscriptions } from '@/stores/orionplaceCatalog';
19
+ import { tariffResumable } from '@/lib/orionplace/tariffs';
15
20
  import type { IBreadcrumb } from '@/interfaces/orionplace/shared/IBreadcrumb';
16
21
  import type { ISubscribeTariff } from '@/interfaces/orionplace/subscribeTariff/ISubscribeTariff';
17
22
  import type { ITariffSelection } from '@/interfaces/orionplace/subscribeTariff/ITariffSelection';
@@ -89,6 +94,8 @@ export function StorefrontTariff({
89
94
  state.tariffs?.data.find((entry) => entry.id === tariffId),
90
95
  );
91
96
  const catalogStore = useOrionplaceCatalogStore();
97
+ const profile = useOrionplace((state) => selectProfile(state)?.id);
98
+ const used = useOrionplaceCatalog(selectUsedSubscriptions(profile));
92
99
  const locale = useOrionplaceSelections((state) => state.localeCode);
93
100
  const copy = tariffLabels(locale);
94
101
  const common = featureLabels(locale);
@@ -273,22 +280,26 @@ export function StorefrontTariff({
273
280
  }
274
281
  }, [tariff]);
275
282
 
283
+ // A trial chosen before sign-in opens again only for a customer new to
284
+ // the subscription record (see `tariffResumable`).
285
+ const resumable = tariffResumable(resume, tariff, used);
286
+
276
287
  useEffect(() => {
277
288
  if (
278
- !resume ||
279
- resume.tariff !== tariffId ||
289
+ !resumable ||
290
+ resumable.tariff !== tariffId ||
280
291
  !authenticated ||
281
292
  !tariff
282
293
  ) {
283
294
  return;
284
295
  }
285
- const key = `${resume.tariff}:${resume.price ?? 'trial'}`;
296
+ const key = `${resumable.tariff}:${resumable.price ?? 'trial'}`;
286
297
  if (opened.current === key) {
287
298
  return;
288
299
  }
289
300
  opened.current = key;
290
- choose(resume);
291
- }, [resume?.tariff, resume?.price, authenticated, tariff]);
301
+ choose(resumable);
302
+ }, [resumable?.tariff, resumable?.price, authenticated, tariff]);
292
303
 
293
304
  return (
294
305
  <section
@@ -27,6 +27,12 @@ required refresh fails, another purchase remains blocked and Refresh repeats
27
27
  both reads. Repeating the already confirmed request is ignored; a different
28
28
  plan can still be purchased.
29
29
 
30
+ For a signed-in customer the component also reads `subscriptions_get` once
31
+ through `onLoadSubscriptions`: plans of a subscription the customer already
32
+ had offer no trial, and a resumed trial opens only after that read, for a
33
+ customer new to the subscription. A failed read leaves the trial shown; the
34
+ backend still refuses a repeated trial.
35
+
30
36
  The component makes requests only through those callbacks. Source is
31
37
  maintained in
32
38
  `components/orionplace/subscriptions/StorefrontTariffs.tsx`; the response
@@ -78,6 +84,13 @@ contract is `interfaces/orionplace/subscribes/ISubscriptionApi.ts`.
78
84
  обновления повторяет оба чтения. Повтор уже подтверждённого запроса
79
85
  игнорируется; другой тариф можно подключить.
80
86
 
87
+ Для авторизованного покупателя компонент также один раз читает
88
+ `subscriptions_get` через `onLoadSubscriptions`: тарифы подписки, которая у
89
+ покупателя уже была, не предлагают пробный период, а восстановленный пробный
90
+ выбор открывается только после этого чтения и только для нового покупателя
91
+ подписки. При ошибке чтения пробный период остаётся видимым; повторный пробный
92
+ период всё равно отклоняет сервер.
93
+
81
94
  Компонент отправляет запросы только через эти функции. Исходный файл:
82
95
  `components/orionplace/subscriptions/StorefrontTariffs.tsx`; контракт ответа:
83
96
  `interfaces/orionplace/subscribes/ISubscriptionApi.ts`.
@@ -7,7 +7,12 @@ import {
7
7
  useOrionplaceCatalogStore,
8
8
  useOrionplaceSelections,
9
9
  } from '@/providers/OrionplaceProvider';
10
- import { selectIsAuthenticated } from '@/stores/orionplace';
10
+ import {
11
+ selectIsAuthenticated,
12
+ selectProfile,
13
+ } from '@/stores/orionplace';
14
+ import { selectUsedSubscriptions } from '@/stores/orionplaceCatalog';
15
+ import { tariffResumable } from '@/lib/orionplace/tariffs';
11
16
  import type { ISubscriptionTariffsResponse } from '@/interfaces/orionplace/subscribes/ISubscriptionApi';
12
17
  import type { ITariffSelection } from '@/interfaces/orionplace/subscribeTariff/ITariffSelection';
13
18
  import {
@@ -69,6 +74,12 @@ export function StorefrontTariffs({
69
74
  (state) => state.tariffsSnapshot !== undefined,
70
75
  );
71
76
  const catalogStore = useOrionplaceCatalogStore();
77
+ const profile = useOrionplace(
78
+ (state) => selectProfile(state)?.id,
79
+ );
80
+ const used = useOrionplaceCatalog(
81
+ selectUsedSubscriptions(profile),
82
+ );
72
83
  const locale = useOrionplaceSelections((state) => state.localeCode);
73
84
  const labels = subscriptionLabels(locale);
74
85
  const common = featureLabels(locale);
@@ -331,7 +342,13 @@ export function StorefrontTariffs({
331
342
  subscriptionId={group.id}
332
343
  tariffIds={ids}
333
344
  guest={!authenticated}
334
- open={authenticated ? resume : undefined}
345
+ open={authenticated
346
+ ? tariffResumable(
347
+ resume,
348
+ resume && tariffs?.data.find((entry) => entry.id === resume.tariff),
349
+ used,
350
+ )
351
+ : undefined}
335
352
  disabled={busy || purchase.needsRefresh}
336
353
  pending={purchase.pending}
337
354
  suspended={purchase.needsRefresh}
@@ -7,9 +7,12 @@ import {
7
7
  type ReactNode,
8
8
  } from 'react';
9
9
  import {
10
+ useOrionplace,
10
11
  useOrionplaceCatalog,
11
12
  useOrionplaceSelections,
12
13
  } from '@/providers/OrionplaceProvider';
14
+ import { selectProfile } from '@/stores/orionplace';
15
+ import { selectUsedSubscriptions } from '@/stores/orionplaceCatalog';
13
16
  import type { ISubscribeTariff } from '@/interfaces/orionplace/subscribeTariff/ISubscribeTariff';
14
17
  import type {
15
18
  ITariffSelection,
@@ -18,6 +21,7 @@ import type {
18
21
  import {
19
22
  tariffIsOpen,
20
23
  tariffSubscribeRequest,
24
+ tariffTrialOpen,
21
25
  } from '@/lib/orionplace/tariffs';
22
26
  import { tariffPageHref } from '@/lib/orionplace/tariffPage';
23
27
  import {
@@ -82,13 +86,16 @@ export function TariffCard({
82
86
  }: TariffCardProps) {
83
87
  const tariffs = useOrionplaceCatalog((state) => state.tariffs);
84
88
  const locale = useOrionplaceSelections((state) => state.localeCode);
89
+ const profile = useOrionplace((state) => selectProfile(state)?.id);
90
+ const used = useOrionplaceCatalog(selectUsedSubscriptions(profile));
85
91
  const tariff = tariffs?.data.find((entry) => entry.id === tariffId);
86
92
  const [selection, setSelection] = useState<ITariffSelection>();
87
93
  const [period, setPeriod] = useState<number>();
88
94
  const [option, setOption] = useState<number>();
89
95
  const opened = useRef('');
90
96
  const labels = tariffLabels(locale);
91
- const trialBadge = tariff && tariff.trial_days > 0
97
+ // No trial badge for a customer who already had this subscription record.
98
+ const trialBadge = tariff && tariffTrialOpen(tariff, used)
92
99
  ? tariffText(labels.trialBadge, {
93
100
  days: tariffDays(tariff.trial_days, locale),
94
101
  })
@@ -7,7 +7,11 @@ currency from the isolated stores. Paid periods are ordered by duration. When
7
7
  several are available, choosing one calls `onPeriodChange(tariff_cost_id)`.
8
8
  `selectedPeriod` selects that period; a missing or unavailable value falls back
9
9
  to the shortest returned paid period. Each amount uses the selected currency's
10
- left and right symbols. A positive `trial_days` adds the trial action.
10
+ left and right symbols. A positive `trial_days` adds the trial action, unless
11
+ the signed-in customer already had the plan's subscription: the backend gives a
12
+ trial once per subscription. Such a trial-only plan shows that note instead.
13
+ The plan pages load the customer's records with `subscriptions_get` through
14
+ `onLoadSubscriptions`; until they arrive, the trial stays shown.
11
15
 
12
16
  The plan and its parent subscription must both allow customer subscriptions.
13
17
  An administrator-only or closed plan shows its localized reason instead of
@@ -43,7 +47,11 @@ The component makes no requests. Source is maintained in
43
47
  `onPeriodChange(tariff_cost_id)`. `selectedPeriod` задаёт период; отсутствующее
44
48
  или недоступное значение заменяется самым коротким полученным платным периодом.
45
49
  Сумма использует левый и правый символы выбранной валюты. Положительное
46
- `trial_days` добавляет действие пробного периода.
50
+ `trial_days` добавляет действие пробного периода, если у авторизованного
51
+ покупателя ещё не было подписки этого тарифа: пробный период даётся один раз на
52
+ подписку. Тариф только с пробным периодом тогда показывает это пояснение.
53
+ Страницы тарифов загружают подписки покупателя через `subscriptions_get`
54
+ (`onLoadSubscriptions`); до их получения пробный период показывается.
47
55
 
48
56
  Тариф и его родительская подписка должны разрешать самостоятельное
49
57
  подключение. Для закрытого тарифа или тарифа администратора вместо действий
@@ -2,9 +2,12 @@
2
2
 
3
3
  import { useId } from 'react';
4
4
  import {
5
+ useOrionplace,
5
6
  useOrionplaceCatalog,
6
7
  useOrionplaceSelections,
7
8
  } from '@/providers/OrionplaceProvider';
9
+ import { selectProfile } from '@/stores/orionplace';
10
+ import { selectUsedSubscriptions } from '@/stores/orionplaceCatalog';
8
11
  import type { ITariffSelection } from '@/interfaces/orionplace/subscribeTariff/ITariffSelection';
9
12
  import { getAvailableNestedValueFromObject } from '@/lib/orionplace/presentation';
10
13
  import {
@@ -13,6 +16,7 @@ import {
13
16
  tariffMoney,
14
17
  tariffPaidPeriods,
15
18
  tariffReturnPath,
19
+ tariffTrialOpen,
16
20
  } from '@/lib/orionplace/tariffs';
17
21
  import {
18
22
  tariffDays,
@@ -45,6 +49,8 @@ export function TariffCardPrices({
45
49
  onRequireSignIn,
46
50
  }: TariffCardPricesProps) {
47
51
  const tariffs = useOrionplaceCatalog((state) => state.tariffs);
52
+ const profile = useOrionplace((state) => selectProfile(state)?.id);
53
+ const used = useOrionplaceCatalog(selectUsedSubscriptions(profile));
48
54
  const locale = useOrionplaceSelections((state) => state.localeCode);
49
55
  const currencyId = useOrionplaceSelections((state) => state.currencyId);
50
56
  const currency = useOrionplaceSelections(
@@ -73,7 +79,9 @@ export function TariffCardPrices({
73
79
  }));
74
80
  const selected = paidPeriods.find((entry) => entry.price === selectedPeriod) ??
75
81
  paidPeriods[0];
76
- const trialDuration = tariff.trial_days > 0
82
+ // A customer who already had this subscription record gets no trial.
83
+ const trialUsed = tariff.trial_days > 0 && !tariffTrialOpen(tariff, used);
84
+ const trialDuration = tariff.trial_days > 0 && !trialUsed
77
85
  ? duration(tariff.trial_days)
78
86
  : '';
79
87
  const name = getAvailableNestedValueFromObject(
@@ -87,7 +95,9 @@ export function TariffCardPrices({
87
95
  ? labels.closed
88
96
  : selected || trialDuration
89
97
  ? ''
90
- : labels.noPeriods;
98
+ : trialUsed
99
+ ? labels.trialUsed
100
+ : labels.noPeriods;
91
101
 
92
102
  function choose(price: number | null) {
93
103
  if (disabled || availability !== 'open') {
@@ -5,6 +5,16 @@ import type { IProfileResponse } from '@/interfaces/orionplace/accounts/IProfile
5
5
  import type { ITariffSubscribeRequest } from '@/interfaces/orionplace/subscribeTariff/ITariffSelection';
6
6
  import type { ICustomerSubscriptionsResponse } from '@/interfaces/orionplace/subscribes/ISubscriptionApi';
7
7
  import { apiErrorMessage } from '@/lib/orionplace/api';
8
+ import { tariffUsedSubscriptions } from '@/lib/orionplace/tariffs';
9
+ import {
10
+ useOrionplace,
11
+ useOrionplaceCatalogStore,
12
+ } from '@/providers/OrionplaceProvider';
13
+ import {
14
+ selectIsAuthenticated,
15
+ selectProfile,
16
+ } from '@/stores/orionplace';
17
+ import { selectUsedSubscriptions } from '@/stores/orionplaceCatalog';
8
18
 
9
19
  type SubscriptionsPage = NonNullable<
10
20
  ICustomerSubscriptionsResponse['page_data']
@@ -55,13 +65,28 @@ export function useOrionplaceTariffPurchase(
55
65
  const [error, setError] = useState('');
56
66
  const [completed, setCompleted] = useState(false);
57
67
  const [needsRefresh, setNeedsRefresh] = useState(false);
68
+ const catalogStore = useOrionplaceCatalogStore();
69
+ const authenticated = useOrionplace(selectIsAuthenticated);
70
+ const profileId = useOrionplace((state) => selectProfile(state)?.id);
71
+
72
+ function rememberUsed(owner: number | undefined, page: SubscriptionsPage) {
73
+ if (owner !== undefined) {
74
+ catalogStore
75
+ .getState()
76
+ .setUsedSubscriptions(owner, tariffUsedSubscriptions(page));
77
+ }
78
+ }
58
79
 
59
80
  async function loadAccount(controller: AbortController) {
81
+ const owner = profileId;
60
82
  const subscriptions = await callbacks.current.onLoadSubscriptions({
61
83
  endpoint: 'subscriptions_get',
62
84
  request: {},
63
85
  signal: controller.signal,
64
86
  });
87
+ if (!controller.signal.aborted) {
88
+ rememberUsed(owner, subscriptions);
89
+ }
65
90
  const profile = await callbacks.current.onLoadProfile({
66
91
  endpoint: 'profile_get',
67
92
  request: { filters: {} },
@@ -138,6 +163,41 @@ export function useOrionplaceTariffPurchase(
138
163
  }
139
164
  }
140
165
 
166
+ /*
167
+ * The plan pages hide the trial of a subscription record the customer
168
+ * already had (the backend refuses it). They read the records once per
169
+ * profile; a failed read leaves the trial shown, as for an unknown customer.
170
+ */
171
+ useEffect(() => {
172
+ if (
173
+ !authenticated ||
174
+ profileId === undefined ||
175
+ selectUsedSubscriptions(profileId)(catalogStore.getState())
176
+ ) {
177
+ return;
178
+ }
179
+ const controller = new AbortController();
180
+ Promise.resolve(
181
+ callbacks.current.onLoadSubscriptions({
182
+ endpoint: 'subscriptions_get',
183
+ request: {},
184
+ signal: controller.signal,
185
+ }),
186
+ ).then(
187
+ (page) => {
188
+ if (!controller.signal.aborted) {
189
+ rememberUsed(profileId, page);
190
+ }
191
+ },
192
+ () => {
193
+ if (!controller.signal.aborted) {
194
+ catalogStore.getState().setUsedSubscriptions(profileId, []);
195
+ }
196
+ },
197
+ );
198
+ return () => controller.abort();
199
+ }, [authenticated, profileId, catalogStore]);
200
+
141
201
  useEffect(() => () => active.current?.abort(), []);
142
202
 
143
203
  return {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The images of a blog article a reader can open larger: the cover and the
3
+ * pictures of the article text. Images inside a link, a button or a product
4
+ * placement keep their own click.
5
+ */
6
+ export interface IBlogViewerImage {
7
+ src: string;
8
+ alt: string;
9
+ caption: string;
10
+ }
11
+
12
+ /** The images in the shape the photo viewer takes: one entry per image, numbered in order. */
13
+ export function blogViewerPhotos(images: IBlogViewerImage[]) {
14
+ return images.map((image, index) => ({ id: index + 1, image: image.src }));
15
+ }
16
+
17
+ const SKIPPED = 'a, button, [data-product-id], [data-product-ids], [data-blog-image-viewer-skip]';
18
+
19
+ /** The openable images under the given roots, in reading order. */
20
+ export function blogViewerImages(
21
+ roots: (ParentNode | null | undefined)[],
22
+ ): HTMLImageElement[] {
23
+ const images: HTMLImageElement[] = [];
24
+ for (const root of roots) {
25
+ if (!root || typeof root.querySelectorAll !== 'function') {
26
+ continue;
27
+ }
28
+ const found = isImage(root) ? [root] : [...root.querySelectorAll('img')];
29
+ for (const image of found) {
30
+ if (typeof image.closest === 'function' && !image.closest(SKIPPED) && imageSource(image)) {
31
+ images.push(image);
32
+ }
33
+ }
34
+ }
35
+ return images;
36
+ }
37
+
38
+ /** What the viewer shows for one image: its address, text and caption. */
39
+ export function blogViewerImage(image: HTMLImageElement): IBlogViewerImage {
40
+ const caption = image.closest('figure')?.querySelector('figcaption')?.textContent?.trim() ?? '';
41
+
42
+ return {
43
+ src: imageSource(image),
44
+ alt: image.alt.trim(),
45
+ caption: caption || image.title.trim(),
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Makes each image a button for keyboard and screen reader users.
51
+ * `label` names the action, for example «Open image».
52
+ */
53
+ export function markBlogViewerImages(images: HTMLImageElement[], label: string) {
54
+ for (const image of images) {
55
+ image.dataset.blogImageViewer = '';
56
+ image.tabIndex = 0;
57
+ image.setAttribute('role', 'button');
58
+ const name = image.alt.trim();
59
+ image.setAttribute('aria-label', name ? `${label}: ${name}` : label);
60
+ }
61
+ }
62
+
63
+ /** The position of the clicked or focused image in the list, or -1. */
64
+ export function blogViewerIndex(target: EventTarget | null, images: HTMLImageElement[]) {
65
+ const image = target && typeof (target as Element).closest === 'function'
66
+ ? (target as Element).closest('img')
67
+ : null;
68
+
69
+ return image ? images.indexOf(image) : -1;
70
+ }
71
+
72
+ /** A key that opens the focused image. */
73
+ export function isBlogViewerOpenKey(event: KeyboardEvent) {
74
+ return event.key === 'Enter' || event.key === ' ';
75
+ }
76
+
77
+ // No `instanceof HTMLImageElement`: the helper also loads where there is no DOM.
78
+ function isImage(node: ParentNode): node is HTMLImageElement {
79
+ return (node as Element).tagName === 'IMG';
80
+ }
81
+
82
+ function imageSource(image: HTMLImageElement) {
83
+ return image.currentSrc || image.getAttribute('src') || '';
84
+ }
@@ -40,6 +40,8 @@ const en = {
40
40
  articleCount: (count: number) =>
41
41
  `${count} ${count === 1 ? 'article' : 'articles'}`,
42
42
  noFiltered: 'No articles match the selected filters.',
43
+ imageOpen: 'Open image',
44
+ imageViewer: 'Image viewer',
43
45
  };
44
46
 
45
47
  function russianArticleCount(count: number) {
@@ -101,6 +103,8 @@ const ru: typeof en = {
101
103
  resetHint: 'Сбросить фильтры статей',
102
104
  articleCount: russianArticleCount,
103
105
  noFiltered: 'Нет статей по выбранным фильтрам.',
106
+ imageOpen: 'Открыть изображение',
107
+ imageViewer: 'Просмотр изображения',
104
108
  };
105
109
 
106
110
  export function blogLabels(locale: string) {
@@ -7,6 +7,7 @@ export function footerLabels(locale: string) {
7
7
  manufacturers: 'Производители',
8
8
  sections: 'Покупателям',
9
9
  contact: 'Контакты',
10
+ privacy: 'Политика конфиденциальности',
10
11
  home: 'На главную',
11
12
  profile: 'Личный кабинет',
12
13
  journal: 'Журнал',
@@ -23,6 +24,7 @@ export function footerLabels(locale: string) {
23
24
  manufacturers: 'Manufacturers',
24
25
  sections: 'Discover',
25
26
  contact: 'Contact',
27
+ privacy: 'Privacy policy',
26
28
  home: 'Home',
27
29
  profile: 'My account',
28
30
  journal: 'Journal',
@@ -72,6 +72,7 @@ export function storefrontSeoPage(pathname: string): StorefrontSeoPage {
72
72
  'sellers',
73
73
  'stores',
74
74
  'contact',
75
+ 'privacy',
75
76
  'tariffs',
76
77
  ...privatePages,
77
78
  ]);
@@ -44,6 +44,11 @@ const pageCopy = {
44
44
  'Контакты компании и обратная связь с магазином.',
45
45
  'контакты, сообщение',
46
46
  ],
47
+ privacy: [
48
+ 'Политика обработки персональных данных',
49
+ 'Какие данные получает магазин, зачем, кому передаёт, и какие cookie использует сайт.',
50
+ 'персональные данные, политика, cookie',
51
+ ],
47
52
  unsubscribe: [
48
53
  'Настройки рассылки',
49
54
  'Отписка от писем магазина.',
@@ -166,6 +171,11 @@ const pageCopy = {
166
171
  'Company contact details and a message form for the store.',
167
172
  'contact, message',
168
173
  ],
174
+ privacy: [
175
+ 'Privacy policy',
176
+ 'Which data the store receives, why, who receives it, and which cookies the site uses.',
177
+ 'personal data, privacy, cookies',
178
+ ],
169
179
  unsubscribe: [
170
180
  'Email preferences',
171
181
  'Unsubscribe from store emails.',