orionplace-react 0.4.0 → 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.
- package/README.md +3 -1
- package/package.json +1 -1
- package/templates/app/StorefrontShell.tsx +8 -0
- package/templates/app/_storefront/PrivacyPage.tsx +15 -0
- package/templates/app/_storefront/storefrontRoutes.tsx +3 -0
- package/templates/app/privacy/page.tsx +9 -0
- package/templates/components/orionplace/blog/BlogArticle.README.md +16 -0
- package/templates/components/orionplace/blog/BlogArticle.module.css +5 -0
- package/templates/components/orionplace/blog/BlogArticle.tsx +60 -1
- package/templates/components/orionplace/legal/CookieNotice.README.md +42 -0
- package/templates/components/orionplace/legal/CookieNotice.module.css +48 -0
- package/templates/components/orionplace/legal/CookieNotice.tsx +82 -0
- package/templates/components/orionplace/legal/StorefrontPrivacy.README.md +49 -0
- package/templates/components/orionplace/legal/StorefrontPrivacy.module.css +166 -0
- package/templates/components/orionplace/legal/StorefrontPrivacy.tsx +83 -0
- package/templates/components/orionplace/products/ProductReviewImageViewer.README.md +13 -2
- package/templates/components/orionplace/products/ProductReviewImageViewer.module.css +10 -1
- package/templates/components/orionplace/products/ProductReviewImageViewer.tsx +61 -9
- package/templates/components/orionplace/storefront/StorefrontFooter.tsx +3 -0
- package/templates/lib/orionplace/blogImageViewer.ts +84 -0
- package/templates/lib/orionplace/blogLabels.ts +4 -0
- package/templates/lib/orionplace/footerLabels.ts +2 -0
- package/templates/lib/orionplace/pageMetadata.ts +1 -0
- package/templates/lib/orionplace/pageSeo.ts +10 -0
- package/templates/lib/orionplace/privacyLabels.ts +198 -0
- package/templates/lib/orionplace/reviewLabels.ts +4 -0
package/README.md
CHANGED
|
@@ -127,7 +127,7 @@ starting the application. Imports then work at any component directory depth.
|
|
|
127
127
|
<!-- components:start (generated by scripts/generate-package-readme-components.mjs) -->
|
|
128
128
|
## Components
|
|
129
129
|
|
|
130
|
-
The starter includes **
|
|
130
|
+
The starter includes **126 storefront components**, copied into
|
|
131
131
|
`components/orionplace/` of your project. Each one has a README in English and Russian
|
|
132
132
|
next to its source that describes its inputs, data and actions.
|
|
133
133
|
|
|
@@ -292,6 +292,8 @@ Journal content and customer contact flows.
|
|
|
292
292
|
| **Contact details** | `AboutPage` | Display the store’s published company and contact information. |
|
|
293
293
|
| **Contact form** | `ContactForm` | Sending a question to the store. |
|
|
294
294
|
| **Unsubscribe from emails** | `EmailUnsubscribe` | Confirming an unsubscribe request from an email link. |
|
|
295
|
+
| **Privacy policy page** | `StorefrontPrivacy` | The store’s personal data policy with a cookies section and the operator’s details. |
|
|
296
|
+
| **Cookie notice** | `CookieNotice` | A small notice about the necessary cookies with a link to the cookies section of the policy. |
|
|
295
297
|
|
|
296
298
|
### Shared interface
|
|
297
299
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orionplace-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Next.js 16 storefront for your Orionplace marketplace: setup CLI, server API gateway, typed store and ready-made catalog, cart, checkout and account components in English and Russian, plus an Android app build.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { OrionplaceThemeProvider } from '@/components/orionplace/theme/OrionplaceThemeProvider';
|
|
21
21
|
import { StorefrontHeader } from '@/components/orionplace/storefront/StorefrontHeader';
|
|
22
22
|
import { StorefrontFooter } from '@/components/orionplace/storefront/StorefrontFooter';
|
|
23
|
+
import { CookieNotice } from '@/components/orionplace/legal/CookieNotice';
|
|
23
24
|
import { CartPreview } from '@/components/orionplace/cart/CartPreview';
|
|
24
25
|
import { useOrionplaceApi } from '@/hooks/useOrionplaceApi';
|
|
25
26
|
import { useOrionplaceAppLoad } from '@/hooks/useOrionplaceAppLoad';
|
|
@@ -354,6 +355,13 @@ function ShellContent({ children }: { children: ReactNode }) {
|
|
|
354
355
|
/>
|
|
355
356
|
<div className={styles.content}>{children}</div>
|
|
356
357
|
<StorefrontFooter onNavigate={route} />
|
|
358
|
+
{/* The notice is for the website; the Android app links its policy from the store listing. */}
|
|
359
|
+
{APP_ORIGIN ? null : (
|
|
360
|
+
<CookieNotice
|
|
361
|
+
href={`${localized('/privacy')}#cookies`}
|
|
362
|
+
onNavigate={route}
|
|
363
|
+
/>
|
|
364
|
+
)}
|
|
357
365
|
</div>
|
|
358
366
|
);
|
|
359
367
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { StorefrontPrivacy } from '@/components/orionplace/legal/StorefrontPrivacy';
|
|
4
|
+
import styles from '@/app/storefront.module.css';
|
|
5
|
+
|
|
6
|
+
export function PrivacyPage() {
|
|
7
|
+
return (
|
|
8
|
+
<main
|
|
9
|
+
className={styles.page}
|
|
10
|
+
data-storefront-page="privacy"
|
|
11
|
+
>
|
|
12
|
+
<StorefrontPrivacy />
|
|
13
|
+
</main>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -13,6 +13,7 @@ import { ManufacturersPage } from '@/app/_storefront/ManufacturersPage';
|
|
|
13
13
|
import { OrdersPage } from '@/app/_storefront/OrdersPage';
|
|
14
14
|
import { ProductPage } from '@/app/_storefront/ProductPage';
|
|
15
15
|
import { ProfilePage } from '@/app/_storefront/ProfilePage';
|
|
16
|
+
import { PrivacyPage } from '@/app/_storefront/PrivacyPage';
|
|
16
17
|
import { PromocodesPage } from '@/app/_storefront/PromocodesPage';
|
|
17
18
|
import { PromoPage } from '@/app/_storefront/PromoPage';
|
|
18
19
|
import { SearchPage } from '@/app/_storefront/SearchPage';
|
|
@@ -451,6 +452,8 @@ const routes: Record<string, Loader> = {
|
|
|
451
452
|
};
|
|
452
453
|
},
|
|
453
454
|
|
|
455
|
+
'/privacy': async () => ({ page: <PrivacyPage /> }),
|
|
456
|
+
|
|
454
457
|
'/profile': async () => ({ page: <ProfilePage /> }),
|
|
455
458
|
|
|
456
459
|
'/promo/[id]': async (context) => {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
renderStorefrontRoute,
|
|
3
|
+
type StorefrontPageProps,
|
|
4
|
+
} from '@/lib/orionplace/serverRoute';
|
|
5
|
+
|
|
6
|
+
// The page and its first data: app/_storefront/storefrontRoutes.tsx.
|
|
7
|
+
export default function Page(props: StorefrontPageProps) {
|
|
8
|
+
return renderStorefrontRoute('/privacy', props);
|
|
9
|
+
}
|
|
@@ -21,6 +21,14 @@ the image fails. Article videos accept HTTP or HTTPS addresses and the
|
|
|
21
21
|
backend's `/ps3/...` paths. In an Android app build those paths (cover, videos,
|
|
22
22
|
images and videos inside the text) load from the storefront address.
|
|
23
23
|
|
|
24
|
+
The cover and the images of the text open larger: a click, Enter or Space on
|
|
25
|
+
one opens `ProductReviewImageViewer` at that image, with the article's other
|
|
26
|
+
images a step away and each image's caption (its `figcaption`, else its
|
|
27
|
+
`title`, else its `alt`) under it. The images get `role="button"`, a keyboard
|
|
28
|
+
stop and an «Open image» name; a zoom-in cursor marks them. Images inside a
|
|
29
|
+
link, a button or a product placement keep their own action. The helper is
|
|
30
|
+
`lib/orionplace/blogImageViewer.ts`.
|
|
31
|
+
|
|
24
32
|
During server rendering, editor HTML is converted to escaped text paragraphs.
|
|
25
33
|
After hydration, DOMPurify replaces those paragraphs with sanitized formatting.
|
|
26
34
|
The allowlist retains headings, text formatting, links, lists, quotes, code,
|
|
@@ -91,6 +99,14 @@ ID тарифов, ссылка также передаёт их в `ids`. Дл
|
|
|
91
99
|
`preview_image_urls`. Область сохраняет пропорции 3:2. После ошибки загрузки
|
|
92
100
|
обложка исчезает. Компонент выводит только видео с адресами HTTP или HTTPS.
|
|
93
101
|
|
|
102
|
+
Обложка и изображения текста открываются крупно: нажатие, Enter или пробел на
|
|
103
|
+
изображении открывает `ProductReviewImageViewer` на нём, остальные изображения
|
|
104
|
+
статьи — в одном шаге, а под каждым — его подпись (`figcaption`, иначе `title`,
|
|
105
|
+
иначе `alt`). Изображения получают `role="button"`, остановку клавиатуры и
|
|
106
|
+
название «Открыть изображение»; их отмечает курсор с лупой. Изображения внутри
|
|
107
|
+
ссылки, кнопки или размещения товара сохраняют своё действие. Вспомогательный
|
|
108
|
+
модуль — `lib/orionplace/blogImageViewer.ts`.
|
|
109
|
+
|
|
94
110
|
При серверном рендеринге HTML редактора превращается в экранированные
|
|
95
111
|
текстовые абзацы. После гидратации DOMPurify заменяет их очищенным
|
|
96
112
|
форматированным HTML. Разрешены заголовки, оформление текста, ссылки, списки,
|
|
@@ -302,3 +302,8 @@
|
|
|
302
302
|
@media (prefers-reduced-motion: reduce) {
|
|
303
303
|
.skeleton { animation: none; }
|
|
304
304
|
}
|
|
305
|
+
/* Images that open in the viewer. */
|
|
306
|
+
.cover[data-blog-image-viewer],
|
|
307
|
+
.prose img[data-blog-image-viewer] { cursor: zoom-in; }
|
|
308
|
+
.cover[data-blog-image-viewer]:focus-visible,
|
|
309
|
+
.prose img[data-blog-image-viewer]:focus-visible { outline: 2px solid var(--orionplace-accent); outline-offset: 3px; }
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
useMemo,
|
|
7
7
|
useRef,
|
|
8
8
|
useState,
|
|
9
|
+
type SyntheticEvent,
|
|
9
10
|
} from 'react';
|
|
10
11
|
import {
|
|
11
12
|
useOrionplace,
|
|
@@ -41,6 +42,16 @@ import {
|
|
|
41
42
|
BlogComments,
|
|
42
43
|
type BlogCommentsProps,
|
|
43
44
|
} from './BlogComments';
|
|
45
|
+
import {
|
|
46
|
+
blogViewerImage,
|
|
47
|
+
blogViewerImages,
|
|
48
|
+
blogViewerIndex,
|
|
49
|
+
blogViewerPhotos,
|
|
50
|
+
isBlogViewerOpenKey,
|
|
51
|
+
markBlogViewerImages,
|
|
52
|
+
type IBlogViewerImage,
|
|
53
|
+
} from '@/lib/orionplace/blogImageViewer';
|
|
54
|
+
import { ProductReviewImageViewer } from '@/components/orionplace/products/ProductReviewImageViewer';
|
|
44
55
|
import styles from './BlogArticle.module.css';
|
|
45
56
|
|
|
46
57
|
export interface BlogArticleProps extends ProductGroupEvents {
|
|
@@ -92,6 +103,9 @@ export function BlogArticle({
|
|
|
92
103
|
const [mounted, setMounted] = useState(false);
|
|
93
104
|
const [coverFailed, setCoverFailed] = useState(false);
|
|
94
105
|
const articleRef = useRef<HTMLDivElement>(null);
|
|
106
|
+
const coverRef = useRef<HTMLImageElement>(null);
|
|
107
|
+
// The cover and the text images open larger in the viewer.
|
|
108
|
+
const [viewer, setViewer] = useState<{ images: IBlogViewerImage[]; index: number }>();
|
|
95
109
|
const requestRef = useRef<AbortController | undefined>(undefined);
|
|
96
110
|
const previousAuthentication = useRef(authenticated);
|
|
97
111
|
const wasShown = useRef(Boolean(preloaded));
|
|
@@ -177,6 +191,30 @@ export function BlogArticle({
|
|
|
177
191
|
setCoverFailed(false);
|
|
178
192
|
}, [cover]);
|
|
179
193
|
|
|
194
|
+
// The text is set as HTML: its images become buttons after each render.
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
markBlogViewerImages(
|
|
197
|
+
blogViewerImages([coverRef.current, articleRef.current]),
|
|
198
|
+
labels.imageOpen,
|
|
199
|
+
);
|
|
200
|
+
}, [html, cover, coverFailed, mounted, labels.imageOpen, post?.id]);
|
|
201
|
+
|
|
202
|
+
const openViewer = (event: SyntheticEvent) => {
|
|
203
|
+
const images = blogViewerImages([coverRef.current, articleRef.current]);
|
|
204
|
+
const index = blogViewerIndex(event.target, images);
|
|
205
|
+
if (index < 0) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
event.preventDefault();
|
|
209
|
+
setViewer({
|
|
210
|
+
index,
|
|
211
|
+
images: images.map((image) => {
|
|
212
|
+
const shown = blogViewerImage(image);
|
|
213
|
+
return image === coverRef.current ? { ...shown, alt: shown.alt || title } : shown;
|
|
214
|
+
}),
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
|
|
180
218
|
useEffect(() => {
|
|
181
219
|
requestRef.current?.abort();
|
|
182
220
|
setAccessChanged(false);
|
|
@@ -240,7 +278,16 @@ export function BlogArticle({
|
|
|
240
278
|
) : null}
|
|
241
279
|
|
|
242
280
|
{post ? (
|
|
243
|
-
<article
|
|
281
|
+
<article
|
|
282
|
+
className={styles.article}
|
|
283
|
+
data-blog-article={post.id}
|
|
284
|
+
onClick={openViewer}
|
|
285
|
+
onKeyDown={(event) => {
|
|
286
|
+
if (isBlogViewerOpenKey(event.nativeEvent)) {
|
|
287
|
+
openViewer(event);
|
|
288
|
+
}
|
|
289
|
+
}}
|
|
290
|
+
>
|
|
244
291
|
<header className={styles.header}>
|
|
245
292
|
<h1>{title}</h1>
|
|
246
293
|
<div className={styles.meta}>
|
|
@@ -252,6 +299,7 @@ export function BlogArticle({
|
|
|
252
299
|
</div>
|
|
253
300
|
{cover && !coverFailed ? (
|
|
254
301
|
<img
|
|
302
|
+
ref={coverRef}
|
|
255
303
|
src={cover}
|
|
256
304
|
alt=""
|
|
257
305
|
width={3}
|
|
@@ -344,6 +392,17 @@ export function BlogArticle({
|
|
|
344
392
|
) : settled && !pending && !error ? (
|
|
345
393
|
<OrionplaceEmptyState kind="journal" message={labels.unavailable} />
|
|
346
394
|
) : null}
|
|
395
|
+
{viewer && viewer.images.length ? (
|
|
396
|
+
<ProductReviewImageViewer
|
|
397
|
+
images={blogViewerPhotos(viewer.images)}
|
|
398
|
+
index={viewer.index}
|
|
399
|
+
label={labels.imageViewer}
|
|
400
|
+
alt={title || labels.imageViewer}
|
|
401
|
+
captions={viewer.images.map((image) => image.caption || image.alt)}
|
|
402
|
+
wide
|
|
403
|
+
onClose={() => setViewer(undefined)}
|
|
404
|
+
/>
|
|
405
|
+
) : null}
|
|
347
406
|
</section>
|
|
348
407
|
);
|
|
349
408
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# CookieNotice · React
|
|
2
|
+
|
|
3
|
+
## English
|
|
4
|
+
|
|
5
|
+
`CookieNotice` tells visitors that the site uses only the cookies it needs and
|
|
6
|
+
links the cookies section of the policy. It renders nothing in server HTML;
|
|
7
|
+
after mounting it appears at the bottom of the screen until the visitor presses
|
|
8
|
+
OK, and the choice is kept in local storage under
|
|
9
|
+
`orionplace-cookie-notice-seen` (`COOKIE_NOTICE_STORAGE_KEY` in
|
|
10
|
+
`lib/orionplace/privacyLabels.ts`). The storefront sets only necessary cookies,
|
|
11
|
+
so the notice asks for no consent; a store that adds analytics or advertising
|
|
12
|
+
cookies needs a consent form instead. The supplied shell does not show it in
|
|
13
|
+
the Android app build.
|
|
14
|
+
|
|
15
|
+
Source is maintained in `components/orionplace/legal/CookieNotice.tsx`.
|
|
16
|
+
|
|
17
|
+
### Props
|
|
18
|
+
|
|
19
|
+
| Prop | Meaning |
|
|
20
|
+
| --------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
21
|
+
| `href?: string` | Address of the cookies section with the language, for example `/privacy?locale=ru#cookies`. Default: `/privacy#cookies`. |
|
|
22
|
+
| `onNavigate?: (path: string) => void` | Called on an ordinary click of the link with `href`; the host navigates. Without it the link opens normally. |
|
|
23
|
+
|
|
24
|
+
## Русский
|
|
25
|
+
|
|
26
|
+
`CookieNotice` сообщает, что сайт использует только необходимые cookie, и
|
|
27
|
+
ведёт в раздел о cookie политики. В серверном HTML его нет; после монтирования
|
|
28
|
+
он показывается внизу экрана, пока посетитель не нажмёт «Понятно», и выбор
|
|
29
|
+
хранится в локальном хранилище под ключом `orionplace-cookie-notice-seen`
|
|
30
|
+
(`COOKIE_NOTICE_STORAGE_KEY` в `lib/orionplace/privacyLabels.ts`). Витрина
|
|
31
|
+
ставит только необходимые cookie, поэтому уведомление не запрашивает согласие;
|
|
32
|
+
магазину, который добавит аналитические или рекламные cookie, нужна форма
|
|
33
|
+
согласия. В сборке приложения для Android оболочка его не показывает.
|
|
34
|
+
|
|
35
|
+
Поддерживаемый исходный файл: `components/orionplace/legal/CookieNotice.tsx`.
|
|
36
|
+
|
|
37
|
+
### Свойства
|
|
38
|
+
|
|
39
|
+
| Свойство | Назначение |
|
|
40
|
+
| --------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
41
|
+
| `href?: string` | Адрес раздела о cookie с языком, например `/privacy?locale=ru#cookies`. По умолчанию `/privacy#cookies`. |
|
|
42
|
+
| `onNavigate?: (path: string) => void` | Вызывается при обычном щелчке по ссылке с `href`; переход выполняет приложение. Без него ссылка открывается как обычно. |
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
position: fixed;
|
|
3
|
+
right: 0.75rem;
|
|
4
|
+
bottom: 0.75rem;
|
|
5
|
+
left: 0.75rem;
|
|
6
|
+
z-index: 40;
|
|
7
|
+
display: flex;
|
|
8
|
+
flex-direction: column;
|
|
9
|
+
align-items: stretch;
|
|
10
|
+
gap: 0.75rem;
|
|
11
|
+
box-sizing: border-box;
|
|
12
|
+
padding: 1rem;
|
|
13
|
+
border: 1px solid var(--orionplace-border);
|
|
14
|
+
color: var(--orionplace-text);
|
|
15
|
+
background: var(--orionplace-surface);
|
|
16
|
+
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 10%), 0 4px 6px -4px rgb(0 0 0 / 10%);
|
|
17
|
+
font-size: 0.875rem;
|
|
18
|
+
line-height: 1.5rem;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.text {
|
|
22
|
+
margin: 0;
|
|
23
|
+
color: var(--orionplace-muted);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.text a {
|
|
27
|
+
color: var(--orionplace-text);
|
|
28
|
+
text-decoration: underline;
|
|
29
|
+
text-underline-offset: 2px;
|
|
30
|
+
white-space: nowrap;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.button {
|
|
34
|
+
composes: primary from '../shared/features.module.css';
|
|
35
|
+
flex-shrink: 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@media (min-width: 640px) {
|
|
39
|
+
.root {
|
|
40
|
+
right: 1.25rem;
|
|
41
|
+
bottom: 1.25rem;
|
|
42
|
+
left: auto;
|
|
43
|
+
max-width: 560px;
|
|
44
|
+
flex-direction: row;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: 1rem;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, type MouseEvent } from 'react';
|
|
4
|
+
import { useOrionplaceSelections } from '@/providers/OrionplaceProvider';
|
|
5
|
+
import {
|
|
6
|
+
COOKIE_NOTICE_STORAGE_KEY,
|
|
7
|
+
privacyLabels,
|
|
8
|
+
} from '@/lib/orionplace/privacyLabels';
|
|
9
|
+
import styles from './CookieNotice.module.css';
|
|
10
|
+
|
|
11
|
+
export interface CookieNoticeProps {
|
|
12
|
+
/** Address of the cookies section, with the language and `#cookies`. */
|
|
13
|
+
href?: string;
|
|
14
|
+
/** An ordinary click on the link; the host navigates. */
|
|
15
|
+
onNavigate?: (path: string) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The storefront sets only necessary cookies, so this is a notice, not a
|
|
19
|
+
// consent form. It appears after mounting and stays away once dismissed.
|
|
20
|
+
export function CookieNotice({
|
|
21
|
+
href = '/privacy#cookies',
|
|
22
|
+
onNavigate,
|
|
23
|
+
}: CookieNoticeProps) {
|
|
24
|
+
const locale = useOrionplaceSelections((state) => state.localeCode);
|
|
25
|
+
const labels = privacyLabels(locale).cookie;
|
|
26
|
+
const [visible, setVisible] = useState(false);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
try {
|
|
30
|
+
setVisible(localStorage.getItem(COOKIE_NOTICE_STORAGE_KEY) !== '1');
|
|
31
|
+
} catch {
|
|
32
|
+
setVisible(true);
|
|
33
|
+
}
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
function dismiss() {
|
|
37
|
+
setVisible(false);
|
|
38
|
+
try {
|
|
39
|
+
localStorage.setItem(COOKIE_NOTICE_STORAGE_KEY, '1');
|
|
40
|
+
} catch {
|
|
41
|
+
// Without storage the notice comes back on the next visit.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function open(event: MouseEvent<HTMLAnchorElement>) {
|
|
46
|
+
if (
|
|
47
|
+
!onNavigate ||
|
|
48
|
+
event.ctrlKey ||
|
|
49
|
+
event.metaKey ||
|
|
50
|
+
event.shiftKey ||
|
|
51
|
+
event.altKey ||
|
|
52
|
+
event.button !== 0
|
|
53
|
+
) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
onNavigate(href);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!visible) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div
|
|
66
|
+
data-feature-part="CookieNotice"
|
|
67
|
+
role="region"
|
|
68
|
+
aria-label={labels.label}
|
|
69
|
+
className={styles.root}
|
|
70
|
+
>
|
|
71
|
+
<p className={styles.text}>
|
|
72
|
+
{labels.text}{' '}
|
|
73
|
+
<a href={href} onClick={open}>
|
|
74
|
+
{labels.link}
|
|
75
|
+
</a>
|
|
76
|
+
</p>
|
|
77
|
+
<button type="button" className={styles.button} onClick={dismiss}>
|
|
78
|
+
{labels.ok}
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# StorefrontPrivacy · React
|
|
2
|
+
|
|
3
|
+
## English
|
|
4
|
+
|
|
5
|
+
`StorefrontPrivacy` is the store's personal data policy at `/privacy`. It has
|
|
6
|
+
no inputs except `className` and sends no requests. It lists which data the
|
|
7
|
+
store receives, why, who receives it, how long it is kept, the customer's
|
|
8
|
+
rights and the cookies the site sets; a contents list links every section, and
|
|
9
|
+
the cookies section has the anchor `#cookies`. The operator block shows the
|
|
10
|
+
company name, tax ID, registration number, address and phone from the
|
|
11
|
+
Application Load contact details; empty values are left out.
|
|
12
|
+
|
|
13
|
+
The texts are in `lib/orionplace/privacyLabels.ts`, in Russian and English. A
|
|
14
|
+
store that adds analytics, chat widgets or other cookies lists them in the
|
|
15
|
+
cookies section.
|
|
16
|
+
|
|
17
|
+
Source is maintained in
|
|
18
|
+
`components/orionplace/legal/StorefrontPrivacy.tsx`; the contact interface is
|
|
19
|
+
`interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonContact.ts`.
|
|
20
|
+
|
|
21
|
+
### Props
|
|
22
|
+
|
|
23
|
+
| Prop | Meaning |
|
|
24
|
+
| -------------------- | ---------------------------------------- |
|
|
25
|
+
| `className?: string` | Extra class names for the page section. |
|
|
26
|
+
|
|
27
|
+
## Русский
|
|
28
|
+
|
|
29
|
+
`StorefrontPrivacy` — политика обработки персональных данных магазина на
|
|
30
|
+
странице `/privacy`. Кроме `className`, свойств нет; запросов компонент не
|
|
31
|
+
отправляет. Он описывает, какие данные получает магазин, зачем, кому
|
|
32
|
+
передаёт, сколько хранит, права покупателя и cookie сайта; оглавление ведёт к
|
|
33
|
+
каждому разделу, у раздела о cookie якорь `#cookies`. Блок оператора
|
|
34
|
+
показывает название компании, ИНН, регистрационный номер, адрес и телефон из
|
|
35
|
+
контактов загрузки приложения; пустые значения не выводятся.
|
|
36
|
+
|
|
37
|
+
Тексты — в `lib/orionplace/privacyLabels.ts`, на русском и английском.
|
|
38
|
+
Магазин, который добавит аналитику, чат или другие cookie, перечисляет их в
|
|
39
|
+
разделе о cookie.
|
|
40
|
+
|
|
41
|
+
Поддерживаемый исходный файл:
|
|
42
|
+
`components/orionplace/legal/StorefrontPrivacy.tsx`; интерфейс контактов —
|
|
43
|
+
`interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonContact.ts`.
|
|
44
|
+
|
|
45
|
+
### Свойства
|
|
46
|
+
|
|
47
|
+
| Свойство | Назначение |
|
|
48
|
+
| -------------------- | ------------------------------------------- |
|
|
49
|
+
| `className?: string` | Дополнительные классы для секции страницы. |
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
display: grid;
|
|
3
|
+
width: 100%;
|
|
4
|
+
min-width: 0;
|
|
5
|
+
color: var(--orionplace-text);
|
|
6
|
+
font: inherit;
|
|
7
|
+
/* The page's base text, as `op-feature` in the other packages. */
|
|
8
|
+
font-size: 0.875rem;
|
|
9
|
+
line-height: 1.5rem;
|
|
10
|
+
container-type: inline-size;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.header {
|
|
14
|
+
display: grid;
|
|
15
|
+
gap: 0.75rem;
|
|
16
|
+
margin-bottom: 2rem;
|
|
17
|
+
padding-bottom: 1.5rem;
|
|
18
|
+
border-bottom: 1px solid var(--orionplace-border);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.header h1 {
|
|
22
|
+
margin: 0;
|
|
23
|
+
font-size: 1.5rem;
|
|
24
|
+
font-weight: 500;
|
|
25
|
+
line-height: 1.25;
|
|
26
|
+
text-transform: uppercase;
|
|
27
|
+
letter-spacing: 0.04em;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.header p {
|
|
31
|
+
max-width: 42rem;
|
|
32
|
+
margin: 0;
|
|
33
|
+
color: var(--orionplace-muted);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.layout {
|
|
37
|
+
display: grid;
|
|
38
|
+
align-items: start;
|
|
39
|
+
gap: 2.5rem;
|
|
40
|
+
min-width: 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.contents,
|
|
44
|
+
.body,
|
|
45
|
+
.section,
|
|
46
|
+
.operator {
|
|
47
|
+
display: grid;
|
|
48
|
+
min-width: 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.contents {
|
|
52
|
+
gap: 0.75rem;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.contents h2,
|
|
56
|
+
.section h2,
|
|
57
|
+
.operator h2 {
|
|
58
|
+
margin: 0;
|
|
59
|
+
font-size: 0.875rem;
|
|
60
|
+
font-weight: 500;
|
|
61
|
+
line-height: 1.25rem;
|
|
62
|
+
text-transform: uppercase;
|
|
63
|
+
letter-spacing: 0.08em;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.contents h2 {
|
|
67
|
+
color: var(--orionplace-muted);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.contents ol {
|
|
71
|
+
display: grid;
|
|
72
|
+
gap: 0.5rem;
|
|
73
|
+
margin: 0;
|
|
74
|
+
padding: 0;
|
|
75
|
+
list-style: none;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.contents a {
|
|
79
|
+
color: var(--orionplace-text);
|
|
80
|
+
text-decoration: none;
|
|
81
|
+
outline: none;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.contents a:focus-visible {
|
|
85
|
+
text-decoration: underline;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@media (hover: hover) {
|
|
89
|
+
.contents a:hover {
|
|
90
|
+
text-decoration: underline;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.body {
|
|
95
|
+
max-width: 48rem;
|
|
96
|
+
gap: 2rem;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.section {
|
|
100
|
+
gap: 0.75rem;
|
|
101
|
+
scroll-margin-top: 1.5rem;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.section p,
|
|
105
|
+
.operator p {
|
|
106
|
+
margin: 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.operator {
|
|
110
|
+
gap: 0.75rem;
|
|
111
|
+
padding: 1.25rem;
|
|
112
|
+
border: 1px solid var(--orionplace-border);
|
|
113
|
+
background: var(--orionplace-surface);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.operator p {
|
|
117
|
+
color: var(--orionplace-muted);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.operator dl {
|
|
121
|
+
display: grid;
|
|
122
|
+
gap: 0.5rem;
|
|
123
|
+
margin: 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.detail {
|
|
127
|
+
display: grid;
|
|
128
|
+
gap: 0 1.5rem;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.detail dt {
|
|
132
|
+
color: var(--orionplace-muted);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.detail dd {
|
|
136
|
+
margin: 0;
|
|
137
|
+
overflow-wrap: anywhere;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* The header's margin spaces the page, so it follows the page's own width. */
|
|
141
|
+
@container (min-width: 592px) {
|
|
142
|
+
.header {
|
|
143
|
+
margin-bottom: 3rem;
|
|
144
|
+
padding-bottom: 2rem;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.header h1 {
|
|
148
|
+
font-size: 1.875rem;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.detail {
|
|
152
|
+
grid-template-columns: 12rem minmax(0, 1fr);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@container (min-width: 960px) {
|
|
157
|
+
.layout {
|
|
158
|
+
grid-template-columns: minmax(0, 3fr) minmax(0, 9fr);
|
|
159
|
+
gap: 4rem;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.contents {
|
|
163
|
+
position: sticky;
|
|
164
|
+
top: 1.5rem;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useOrionplace, useOrionplaceSelections } from '@/providers/OrionplaceProvider';
|
|
4
|
+
import { selectInit } from '@/stores/orionplace';
|
|
5
|
+
import { contactLabels } from '@/lib/orionplace/contactLabels';
|
|
6
|
+
import { privacyLabels } from '@/lib/orionplace/privacyLabels';
|
|
7
|
+
import styles from './StorefrontPrivacy.module.css';
|
|
8
|
+
|
|
9
|
+
export interface StorefrontPrivacyProps {
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function StorefrontPrivacy({ className = '' }: StorefrontPrivacyProps) {
|
|
14
|
+
const init = useOrionplace(selectInit);
|
|
15
|
+
const locale = useOrionplaceSelections((state) => state.localeCode);
|
|
16
|
+
const contact = init?.contact;
|
|
17
|
+
const company =
|
|
18
|
+
contact?.company_legal_name?.trim() || contact?.company_name?.trim() || '';
|
|
19
|
+
const labels = privacyLabels(locale, company);
|
|
20
|
+
const fields = contactLabels(locale);
|
|
21
|
+
// The operator's details as the platform sends them; empty rows are left out.
|
|
22
|
+
const details = [
|
|
23
|
+
[fields.legalName, company],
|
|
24
|
+
[fields.taxId, contact?.company_tax_id],
|
|
25
|
+
[fields.registrationNumber, contact?.company_organization_id],
|
|
26
|
+
[fields.address, contact?.company_contact_address],
|
|
27
|
+
[fields.phone, contact?.company_contact_phone],
|
|
28
|
+
]
|
|
29
|
+
.map(([label, value]) => ({ label: label ?? '', value: value?.trim() ?? '' }))
|
|
30
|
+
.filter((row) => row.value);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<section
|
|
34
|
+
data-feature-part="StorefrontPrivacy"
|
|
35
|
+
className={`${styles.root} ${className}`.trim()}
|
|
36
|
+
>
|
|
37
|
+
<header className={styles.header}>
|
|
38
|
+
<h1>{labels.title}</h1>
|
|
39
|
+
<p>{labels.lead}</p>
|
|
40
|
+
</header>
|
|
41
|
+
|
|
42
|
+
<div className={styles.layout}>
|
|
43
|
+
<nav aria-label={labels.contents} className={styles.contents}>
|
|
44
|
+
<h2>{labels.contents}</h2>
|
|
45
|
+
<ol>
|
|
46
|
+
{labels.sections.map((section) => (
|
|
47
|
+
<li key={section.id}>
|
|
48
|
+
<a href={`#${section.id}`}>{section.title}</a>
|
|
49
|
+
</li>
|
|
50
|
+
))}
|
|
51
|
+
</ol>
|
|
52
|
+
</nav>
|
|
53
|
+
|
|
54
|
+
<article className={styles.body}>
|
|
55
|
+
{labels.sections.map((section) => (
|
|
56
|
+
<section key={section.id} id={section.id} className={styles.section}>
|
|
57
|
+
<h2>{section.title}</h2>
|
|
58
|
+
{section.text.map((line, index) => (
|
|
59
|
+
<p key={index}>{line}</p>
|
|
60
|
+
))}
|
|
61
|
+
</section>
|
|
62
|
+
))}
|
|
63
|
+
|
|
64
|
+
<section id="operator-details" className={styles.operator}>
|
|
65
|
+
<h2>{labels.operator}</h2>
|
|
66
|
+
{details.length ? (
|
|
67
|
+
<dl>
|
|
68
|
+
{details.map((row) => (
|
|
69
|
+
<div key={row.label} className={styles.detail}>
|
|
70
|
+
<dt>{row.label}</dt>
|
|
71
|
+
<dd>{row.value}</dd>
|
|
72
|
+
</div>
|
|
73
|
+
))}
|
|
74
|
+
</dl>
|
|
75
|
+
) : (
|
|
76
|
+
<p>{labels.operatorUnknown}</p>
|
|
77
|
+
)}
|
|
78
|
+
</section>
|
|
79
|
+
</article>
|
|
80
|
+
</div>
|
|
81
|
+
</section>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -7,7 +7,11 @@ and public image URLs. `index` chooses the first displayed photo and resets
|
|
|
7
7
|
the viewer when it changes. Previous and Next, arrow keys, Home, End and
|
|
8
8
|
thumbnail buttons change the photo, and so does a sideways swipe on the large
|
|
9
9
|
photo; `onChange` receives its ID and position. `StorefrontProduct` opens the
|
|
10
|
-
product's own photos here with `label` and `alt`.
|
|
10
|
+
product's own photos here with `label` and `alt`. `BlogArticle` opens the
|
|
11
|
+
images of an article with `captions`, a caption under each photo, and `wide`,
|
|
12
|
+
which lets the viewer use up to 1600px of the screen width instead of 960px
|
|
13
|
+
and adds full-size zoom: the magnifier button or a click on the image shows it
|
|
14
|
+
at its own size, scrolling inside the viewer.
|
|
11
15
|
`onClose` handles the close button, Escape and a click outside the dialog.
|
|
12
16
|
The component returns keyboard focus to the element that opened it. The
|
|
13
17
|
dialog's images are mounted when it opens, so an unopened viewer starts no
|
|
@@ -21,6 +25,8 @@ image requests.
|
|
|
21
25
|
| `index?: number` | Zero-based index of the current item in its collection. |
|
|
22
26
|
| `label?: string` | Dialog name instead of «Review photos». |
|
|
23
27
|
| `alt?: string` | Photo text before the photo number instead of «Review photo». |
|
|
28
|
+
| `captions?: string[]` | Caption under each photo, in the order of `images`. |
|
|
29
|
+
| `wide?: boolean` | Up to 1600px wide instead of 960px, with full-size zoom, for detailed images. |
|
|
24
30
|
| `onChange?: (photo: { id: number; index: number; }) => void` | Called when the visitor confirms a new value. The callback payload is shown in its type. |
|
|
25
31
|
| `onClose?: () => void` | Called when the visitor closes the panel or dialog. The callback has no payload. |
|
|
26
32
|
|
|
@@ -32,7 +38,10 @@ image requests.
|
|
|
32
38
|
клавиши стрелок, Home, End, миниатюры и смахивание по большой фотографии
|
|
33
39
|
меняют фотографию; `StorefrontProduct` открывает здесь фотографии товара с
|
|
34
40
|
`label` и `alt`; `onChange`
|
|
35
|
-
получает её ID и номер в списке. `
|
|
41
|
+
получает её ID и номер в списке. `BlogArticle` открывает изображения статьи с
|
|
42
|
+
`captions` — подписью под каждой фотографией — и `wide`, при котором
|
|
43
|
+
просмотрщик занимает до 1600px ширины экрана вместо 960px, а кнопка с лупой или
|
|
44
|
+
нажатие на изображение показывает его в полный размер с прокруткой. `onClose` обрабатывает кнопку закрытия,
|
|
36
45
|
Escape и щелчок вне диалога. При закрытии фокус возвращается на элемент,
|
|
37
46
|
который открыл просмотрщик. Изображения появляются при открытии диалога,
|
|
38
47
|
поэтому закрытый просмотрщик не начинает их загрузку.
|
|
@@ -44,6 +53,8 @@ Escape и щелчок вне диалога. При закрытии фокус
|
|
|
44
53
|
| `images: IProductFeedbackImage[]` | Упорядоченные изображения галереи. |
|
|
45
54
|
| `label?: string` | Название диалога вместо «Фотографии к отзыву». |
|
|
46
55
|
| `alt?: string` | Текст фотографии перед её номером вместо «Фотография к отзыву». |
|
|
56
|
+
| `captions?: string[]` | Подпись под каждой фотографией в порядке `images`. |
|
|
57
|
+
| `wide?: boolean` | До 1600px ширины вместо 960px и показ в полный размер — для подробных изображений. |
|
|
47
58
|
| `index?: number` | Индекс текущего элемента в коллекции, начиная с нуля. |
|
|
48
59
|
| `onChange?: (photo: { id: number; index: number; }) => void` | Вызывается, когда покупатель подтверждает новое значение. Данные обработчика указаны в его типе. |
|
|
49
60
|
| `onClose?: () => void` | Вызывается, когда покупатель закрывает панель или диалог. Обработчик не получает данные. |
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
color: var(--orionplace-text);
|
|
13
13
|
font-family: inherit;
|
|
14
14
|
}
|
|
15
|
+
.wide { max-width: 1600px; }
|
|
15
16
|
.viewer::backdrop { background: rgb(0 0 0 / 35%); backdrop-filter: blur(2px); }
|
|
16
|
-
.content { display: grid; height: 100%; grid-template-rows: auto minmax(0, 1fr) auto; container: review-image-viewer / inline-size; }
|
|
17
|
+
.content { display: grid; height: 100%; grid-template-rows: auto minmax(0, 1fr) auto auto; container: review-image-viewer / inline-size; }
|
|
17
18
|
.header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 8px 8px 16px; border-bottom: 1px solid var(--orionplace-border); }
|
|
18
19
|
.header p { margin: 0; color: var(--orionplace-muted); font-size: 12px; line-height: 1rem; letter-spacing: 0.08em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
|
|
19
20
|
.header button { display: grid; flex-shrink: 0; place-items: center; width: 40px; height: 40px; padding: 0; border: 0; outline: none; background: transparent; color: inherit; font-size: 24px; line-height: 2rem; cursor: pointer; transition: background-color 150ms; }
|
|
@@ -48,3 +49,11 @@
|
|
|
48
49
|
}
|
|
49
50
|
}
|
|
50
51
|
.thumbnails img { width: 100%; height: 100%; object-fit: contain; }
|
|
52
|
+
.caption { margin: 0; padding: 4px 16px 12px; color: var(--orionplace-muted); font-size: 14px; line-height: 1.5rem; text-align: center; }
|
|
53
|
+
.zoom { margin-left: auto; }
|
|
54
|
+
.zoom svg { width: 1.25rem; height: 1.25rem; }
|
|
55
|
+
.fitted { display: contents; }
|
|
56
|
+
.zoomed { position: absolute; inset: 0; overflow: auto; }
|
|
57
|
+
.fullImage { display: block; width: auto; height: auto; max-width: none !important; max-height: none !important; margin: auto; }
|
|
58
|
+
.zoomIn { cursor: zoom-in; }
|
|
59
|
+
.zoomOut { cursor: zoom-out; }
|
|
@@ -15,11 +15,15 @@ import { OrionplaceImage } from '@/components/orionplace/shared/OrionplaceImage'
|
|
|
15
15
|
import styles from './ProductReviewImageViewer.module.css';
|
|
16
16
|
|
|
17
17
|
// `label` and `alt` name the product's own photos on the product page.
|
|
18
|
+
// `captions` are shown under each image (blog article images have them);
|
|
19
|
+
// `wide` lets screenshots and other detailed images use more of the screen.
|
|
18
20
|
export interface ProductReviewImageViewerProps {
|
|
19
21
|
images: IProductFeedbackImage[];
|
|
20
22
|
index?: number;
|
|
21
23
|
label?: string;
|
|
22
24
|
alt?: string;
|
|
25
|
+
captions?: string[];
|
|
26
|
+
wide?: boolean;
|
|
23
27
|
onChange?: (photo: { id: number; index: number }) => void;
|
|
24
28
|
onClose?: () => void;
|
|
25
29
|
}
|
|
@@ -29,6 +33,8 @@ export function ProductReviewImageViewer({
|
|
|
29
33
|
index = 0,
|
|
30
34
|
label,
|
|
31
35
|
alt,
|
|
36
|
+
captions,
|
|
37
|
+
wide = false,
|
|
32
38
|
onChange,
|
|
33
39
|
onClose,
|
|
34
40
|
}: ProductReviewImageViewerProps) {
|
|
@@ -41,6 +47,10 @@ export function ProductReviewImageViewer({
|
|
|
41
47
|
const [active, setActive] = useState(() => validIndex(index, images.length));
|
|
42
48
|
const current = images[active];
|
|
43
49
|
const several = images.length > 1;
|
|
50
|
+
const caption = captions?.[active]?.trim() ?? '';
|
|
51
|
+
// A wide viewer can show the image at its own size; the stage then scrolls.
|
|
52
|
+
const [zoomed, setZoomed] = useState(false);
|
|
53
|
+
const toggleZoom = () => setZoomed((value) => wide && !value);
|
|
44
54
|
|
|
45
55
|
useEffect(() => {
|
|
46
56
|
setMounted(true);
|
|
@@ -50,6 +60,11 @@ export function ProductReviewImageViewer({
|
|
|
50
60
|
setActive(validIndex(index, images.length));
|
|
51
61
|
}, [images, index]);
|
|
52
62
|
|
|
63
|
+
// Another photo opens fitted to the screen.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
setZoomed(false);
|
|
66
|
+
}, [active]);
|
|
67
|
+
|
|
53
68
|
useEffect(() => {
|
|
54
69
|
const element = dialog.current;
|
|
55
70
|
if (!mounted || !element) {
|
|
@@ -101,7 +116,7 @@ export function ProductReviewImageViewer({
|
|
|
101
116
|
const touch = event.changedTouches[0];
|
|
102
117
|
const start = swipeStart.current;
|
|
103
118
|
swipeStart.current = undefined;
|
|
104
|
-
if (!start || !touch || !several) {
|
|
119
|
+
if (!start || !touch || !several || zoomed) {
|
|
105
120
|
return;
|
|
106
121
|
}
|
|
107
122
|
select(
|
|
@@ -150,7 +165,7 @@ export function ProductReviewImageViewer({
|
|
|
150
165
|
<dialog
|
|
151
166
|
data-stable-modal="fixed"
|
|
152
167
|
ref={dialog}
|
|
153
|
-
className={styles.viewer}
|
|
168
|
+
className={wide ? `${styles.viewer} ${styles.wide}` : styles.viewer}
|
|
154
169
|
aria-label={label || labels.photoViewer}
|
|
155
170
|
data-feature-part="ProductReviewImageViewer"
|
|
156
171
|
data-review-image-viewer
|
|
@@ -166,6 +181,28 @@ export function ProductReviewImageViewer({
|
|
|
166
181
|
<p aria-live="polite" data-review-image-position>
|
|
167
182
|
{active + 1} / {images.length}
|
|
168
183
|
</p>
|
|
184
|
+
{wide ? (
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
className={styles.zoom}
|
|
188
|
+
aria-label={zoomed ? labels.zoomOut : labels.zoomIn}
|
|
189
|
+
aria-pressed={zoomed}
|
|
190
|
+
data-review-image-zoom
|
|
191
|
+
onClick={toggleZoom}
|
|
192
|
+
>
|
|
193
|
+
<svg
|
|
194
|
+
viewBox="0 0 24 24"
|
|
195
|
+
fill="none"
|
|
196
|
+
stroke="currentColor"
|
|
197
|
+
strokeWidth="1.5"
|
|
198
|
+
aria-hidden="true"
|
|
199
|
+
>
|
|
200
|
+
<circle cx="10.5" cy="10.5" r="6.5" />
|
|
201
|
+
<path d="m15.5 15.5 5 5M7.5 10.5h6" />
|
|
202
|
+
{zoomed ? null : <path d="M10.5 7.5v6" />}
|
|
203
|
+
</svg>
|
|
204
|
+
</button>
|
|
205
|
+
) : null}
|
|
169
206
|
<button
|
|
170
207
|
type="button"
|
|
171
208
|
aria-label={common.close}
|
|
@@ -176,18 +213,28 @@ export function ProductReviewImageViewer({
|
|
|
176
213
|
</button>
|
|
177
214
|
</header>
|
|
178
215
|
<div
|
|
179
|
-
className={several ? `${styles.stage} ${styles.swipe}` : styles.stage}
|
|
216
|
+
className={several && !zoomed ? `${styles.stage} ${styles.swipe}` : styles.stage}
|
|
180
217
|
data-review-image-stage
|
|
181
218
|
onTouchStart={startSwipe}
|
|
182
219
|
onTouchEnd={endSwipe}
|
|
183
220
|
>
|
|
221
|
+
{/* At full size the image scrolls inside the stage; the arrows stay in place. */}
|
|
184
222
|
{current && (
|
|
185
|
-
<
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
223
|
+
<div
|
|
224
|
+
className={zoomed ? styles.zoomed : styles.fitted}
|
|
225
|
+
data-review-image-zoomed={zoomed || undefined}
|
|
226
|
+
>
|
|
227
|
+
<OrionplaceImage
|
|
228
|
+
key={current.id}
|
|
229
|
+
src={current.image}
|
|
230
|
+
alt={photoLabel(active)}
|
|
231
|
+
className={[
|
|
232
|
+
zoomed ? styles.fullImage : styles.mainImage,
|
|
233
|
+
wide ? (zoomed ? styles.zoomOut : styles.zoomIn) : '',
|
|
234
|
+
].join(' ').trim()}
|
|
235
|
+
onClick={toggleZoom}
|
|
236
|
+
/>
|
|
237
|
+
</div>
|
|
191
238
|
)}
|
|
192
239
|
{several && (
|
|
193
240
|
<>
|
|
@@ -228,6 +275,11 @@ export function ProductReviewImageViewer({
|
|
|
228
275
|
</>
|
|
229
276
|
)}
|
|
230
277
|
</div>
|
|
278
|
+
{caption ? (
|
|
279
|
+
<p className={styles.caption} data-review-image-caption>
|
|
280
|
+
{caption}
|
|
281
|
+
</p>
|
|
282
|
+
) : null}
|
|
231
283
|
{several && (
|
|
232
284
|
<ol className={styles.thumbnails}>
|
|
233
285
|
{images.map((photo, position) => (
|
|
@@ -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}>
|
|
@@ -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',
|
|
@@ -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.',
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Texts of the privacy page (/privacy) and the cookie notice.
|
|
3
|
+
*
|
|
4
|
+
* The page describes what the storefront itself does: the account, orders,
|
|
5
|
+
* the two platform cookies and the notice's own storage mark. A store that
|
|
6
|
+
* adds analytics, chat widgets or other cookies lists them in the cookies
|
|
7
|
+
* section here. The operator is the store's company from the platform's
|
|
8
|
+
* contact details; `{company}` in a text is replaced with its name.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface PrivacySection {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
text: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PrivacyLabels {
|
|
18
|
+
title: string;
|
|
19
|
+
lead: string;
|
|
20
|
+
contents: string;
|
|
21
|
+
operator: string;
|
|
22
|
+
operatorUnknown: string;
|
|
23
|
+
sections: PrivacySection[];
|
|
24
|
+
cookie: {
|
|
25
|
+
label: string;
|
|
26
|
+
text: string;
|
|
27
|
+
link: string;
|
|
28
|
+
ok: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Local storage mark: the visitor has dismissed the cookie notice. */
|
|
33
|
+
export const COOKIE_NOTICE_STORAGE_KEY = 'orionplace-cookie-notice-seen';
|
|
34
|
+
|
|
35
|
+
const ru: PrivacyLabels = {
|
|
36
|
+
title: 'Политика обработки персональных данных',
|
|
37
|
+
lead: 'Какие данные магазин получает, зачем, кому их передаёт и как долго хранит.',
|
|
38
|
+
contents: 'Содержание',
|
|
39
|
+
operator: 'Оператор персональных данных',
|
|
40
|
+
operatorUnknown: 'Реквизиты оператора указаны на странице «Контакты».',
|
|
41
|
+
sections: [
|
|
42
|
+
{
|
|
43
|
+
id: 'operator',
|
|
44
|
+
title: '1. Кто обрабатывает данные',
|
|
45
|
+
text: [
|
|
46
|
+
'Оператор персональных данных — {company} (далее — Магазин). Реквизиты Магазина указаны в конце страницы.',
|
|
47
|
+
'Обработка ведётся в соответствии с Федеральным законом № 152-ФЗ «О персональных данных».',
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'data',
|
|
52
|
+
title: '2. Какие данные',
|
|
53
|
+
text: [
|
|
54
|
+
'Аккаунт: номер телефона, а если вы их укажете — имя, адрес электронной почты, дата рождения и фото.',
|
|
55
|
+
'Заказы: товары, адреса доставки, имя и телефон получателя, суммы, способ и статус оплаты, возвраты. Данные карты вводятся на странице платёжного провайдера; Магазин номера карт не получает.',
|
|
56
|
+
'Обращения, отзывы и комментарии, избранное, подписки на товары и история поиска.',
|
|
57
|
+
'История входов: дата и время, IP-адрес, браузер и тип устройства.',
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'purposes',
|
|
62
|
+
title: '3. Зачем',
|
|
63
|
+
text: [
|
|
64
|
+
'Чтобы вы могли войти, оформить, оплатить, получить и вернуть заказ, получить ответ на обращение и опубликовать отзыв; чтобы защищать аккаунт и выполнять требования закона. Письма о новинках и акциях приходят, только если вы их включили.',
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'recipients',
|
|
69
|
+
title: '4. Кому передаются данные',
|
|
70
|
+
text: [
|
|
71
|
+
'Только тем, без кого заказ не выполнить, и в нужном объёме: службам доставки, платёжным провайдерам, операторам связи (для кодов подтверждения) и платформе, на серверах которой работает Магазин. Данные не продаются.',
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'storage',
|
|
76
|
+
title: '5. Сколько хранятся',
|
|
77
|
+
text: [
|
|
78
|
+
'Пока существует аккаунт. Записи о заказах и платежах хранятся столько, сколько требует закон о бухгалтерском учёте.',
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'rights',
|
|
83
|
+
title: '6. Ваши права',
|
|
84
|
+
text: [
|
|
85
|
+
'В профиле можно изменить свои данные, отключить письма и удалить аккаунт. Также можно узнать, какие данные обрабатываются, потребовать их уточнить или удалить и отозвать согласие: напишите в поддержку или на странице «Контакты».',
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'cookies',
|
|
90
|
+
title: '7. Cookie и локальное хранилище',
|
|
91
|
+
text: [
|
|
92
|
+
'Сайт использует только необходимые для работы cookie: sessionid сохраняет вход в аккаунт, csrftoken защищает формы от подделки запросов.',
|
|
93
|
+
'В локальном хранилище браузера сохраняется отметка о том, что вы видели уведомление о cookie. Рекламных и аналитических cookie нет. Если запретить cookie, вход и оформление заказа не будут работать.',
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: 'changes',
|
|
98
|
+
title: '8. Изменение политики',
|
|
99
|
+
text: [
|
|
100
|
+
'Новая редакция действует с момента публикации на этой странице.',
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
cookie: {
|
|
105
|
+
label: 'Уведомление о cookie',
|
|
106
|
+
text: 'Сайт использует только необходимые cookie: для входа в аккаунт и защиты форм.',
|
|
107
|
+
link: 'Подробнее',
|
|
108
|
+
ok: 'Понятно',
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const en: PrivacyLabels = {
|
|
113
|
+
title: 'Privacy policy',
|
|
114
|
+
lead: 'Which data the store receives, why, who it shares it with and how long it keeps it.',
|
|
115
|
+
contents: 'Contents',
|
|
116
|
+
operator: 'Data controller',
|
|
117
|
+
operatorUnknown: 'The controller’s details are on the Contact page.',
|
|
118
|
+
sections: [
|
|
119
|
+
{
|
|
120
|
+
id: 'operator',
|
|
121
|
+
title: '1. Who processes the data',
|
|
122
|
+
text: [
|
|
123
|
+
'The data controller is {company} (the Store). The Store’s details are at the end of this page.',
|
|
124
|
+
'Data is processed under Russian Federal Law No. 152-FZ “On Personal Data”.',
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: 'data',
|
|
129
|
+
title: '2. Which data',
|
|
130
|
+
text: [
|
|
131
|
+
'Account: phone number and, if you add them, name, email address, date of birth and photo.',
|
|
132
|
+
'Orders: products, delivery addresses, recipient name and phone, amounts, payment method and status, refunds. Card details are entered on the payment provider’s page; the Store does not receive card numbers.',
|
|
133
|
+
'Support requests, reviews and comments, wishlist, product subscriptions and search history.',
|
|
134
|
+
'Sign-in history: date and time, IP address, browser and device type.',
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: 'purposes',
|
|
139
|
+
title: '3. Why',
|
|
140
|
+
text: [
|
|
141
|
+
'To let you sign in, place, pay for, receive and return orders, get answers to requests and publish reviews; to protect the account and meet legal duties. News and promotion emails come only if you turned them on.',
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: 'recipients',
|
|
146
|
+
title: '4. Who receives the data',
|
|
147
|
+
text: [
|
|
148
|
+
'Only those needed to fulfil an order, and only as much as they need: delivery services, payment providers, telecom operators (for confirmation codes) and the platform whose servers run the Store. The data is not sold.',
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: 'storage',
|
|
153
|
+
title: '5. How long it is kept',
|
|
154
|
+
text: [
|
|
155
|
+
'While the account exists. Order and payment records are kept as long as accounting law requires.',
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: 'rights',
|
|
160
|
+
title: '6. Your rights',
|
|
161
|
+
text: [
|
|
162
|
+
'In the profile you can change your details, turn emails off and delete the account. You can also ask which data is processed, ask to correct or delete it and withdraw consent: write to support or use the Contact page.',
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
id: 'cookies',
|
|
167
|
+
title: '7. Cookies and local storage',
|
|
168
|
+
text: [
|
|
169
|
+
'The site uses only the cookies it needs to work: sessionid keeps you signed in, csrftoken protects forms from request forgery.',
|
|
170
|
+
'The browser’s local storage keeps the fact that you have seen the cookie notice. There are no advertising or analytics cookies. If you block cookies, signing in and checking out do not work.',
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: 'changes',
|
|
175
|
+
title: '8. Changes to this policy',
|
|
176
|
+
text: ['A new version applies from its publication on this page.'],
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
cookie: {
|
|
180
|
+
label: 'Cookie notice',
|
|
181
|
+
text: 'The site uses only the cookies it needs: to sign you in and to protect forms.',
|
|
182
|
+
link: 'Learn more',
|
|
183
|
+
ok: 'OK',
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export function privacyLabels(locale: string, company = ''): PrivacyLabels {
|
|
188
|
+
const copy = locale.toLowerCase().startsWith('ru') ? ru : en;
|
|
189
|
+
const name = company || (copy === ru ? 'Магазин' : 'the Store');
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
...copy,
|
|
193
|
+
sections: copy.sections.map((section) => ({
|
|
194
|
+
...section,
|
|
195
|
+
text: section.text.map((line) => line.replaceAll('{company}', name)),
|
|
196
|
+
})),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -47,6 +47,8 @@ const en = {
|
|
|
47
47
|
less: 'Less',
|
|
48
48
|
gallery: 'Product images',
|
|
49
49
|
previousImage: 'Previous image',
|
|
50
|
+
zoomIn: 'Show at full size',
|
|
51
|
+
zoomOut: 'Fit to screen',
|
|
50
52
|
nextImage: 'Next image',
|
|
51
53
|
thumbnailsBack: 'Show previous thumbnails',
|
|
52
54
|
thumbnailsForward: 'Show next thumbnails',
|
|
@@ -124,6 +126,8 @@ const ru: typeof en = {
|
|
|
124
126
|
less: 'Свернуть',
|
|
125
127
|
gallery: 'Фотографии товара',
|
|
126
128
|
previousImage: 'Предыдущая фотография',
|
|
129
|
+
zoomIn: 'Показать в полный размер',
|
|
130
|
+
zoomOut: 'Вписать в экран',
|
|
127
131
|
nextImage: 'Следующая фотография',
|
|
128
132
|
thumbnailsBack: 'Показать предыдущие миниатюры',
|
|
129
133
|
thumbnailsForward: 'Показать следующие миниатюры',
|