orionplace-react 0.4.0 → 0.6.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 +13 -2
- package/bin/orionplace-react.mjs +7 -0
- package/package.json +1 -1
- package/runtime/marketplace.d.mts +85 -0
- package/runtime/marketplace.mjs +490 -3
- package/src/project-readme.mjs +17 -2
- package/src/setup.mjs +1 -0
- package/src/sitemap.mjs +93 -0
- 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/app/robots.txt/route.ts +11 -0
- package/templates/app/sitemap.xml/route.ts +12 -0
- package/templates/components/orionplace/account/ProfileDetailsForm.README.md +4 -0
- package/templates/components/orionplace/account/ProfileDetailsForm.tsx +7 -2
- 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/cart/CartCheckoutDetails.README.md +4 -0
- package/templates/components/orionplace/cart/CartCheckoutDetails.tsx +20 -9
- 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/interfaces/orionplace/accounts/IProfileData.ts +2 -0
- package/templates/interfaces/orionplace/appBase/IApploadUserProfile.ts +2 -0
- package/templates/lib/orionplace/blogImageViewer.ts +84 -0
- package/templates/lib/orionplace/blogLabels.ts +4 -0
- package/templates/lib/orionplace/cartLabels.ts +2 -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/profile.ts +12 -0
- package/templates/lib/orionplace/profileLabels.ts +2 -0
- package/templates/lib/orionplace/reviewLabels.ts +4 -0
- package/templates/proxy.ts +3 -2
|
@@ -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}>
|
|
@@ -31,6 +31,8 @@ export interface IApiDateTime {
|
|
|
31
31
|
export interface IProfileData {
|
|
32
32
|
id: number;
|
|
33
33
|
phone: string;
|
|
34
|
+
/** ISO 3166-1 alpha-2 country of the phone number; null when the number has none. */
|
|
35
|
+
origin_country: string | null;
|
|
34
36
|
first_name: string;
|
|
35
37
|
last_name: string;
|
|
36
38
|
email: string;
|
|
@@ -6,6 +6,8 @@ export interface IApploadUserProfile {
|
|
|
6
6
|
last_login: string | null;
|
|
7
7
|
date_joined: string;
|
|
8
8
|
phone: string;
|
|
9
|
+
/** ISO 3166-1 alpha-2 country of the phone number; null when the number has none. */
|
|
10
|
+
origin_country: string | null;
|
|
9
11
|
password: boolean;
|
|
10
12
|
dob: string | null;
|
|
11
13
|
notifications_enabled: boolean;
|
|
@@ -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) {
|
|
@@ -17,6 +17,7 @@ const en = {
|
|
|
17
17
|
'The address is saved, but there is no delivery to this country. Choose another address.',
|
|
18
18
|
payment: 'Payment method',
|
|
19
19
|
choosePayment: 'Choose payment method',
|
|
20
|
+
noPayment: 'No payment method is available for this order. Available methods depend on the order and on the country of your phone number.',
|
|
20
21
|
recipient: 'Recipient',
|
|
21
22
|
myself: 'Myself',
|
|
22
23
|
myselfHint: 'Myself: I will receive the order',
|
|
@@ -143,6 +144,7 @@ const ru: typeof en = {
|
|
|
143
144
|
'Адрес сохранён, но доставки в эту страну нет. Выберите другой адрес.',
|
|
144
145
|
payment: 'Способ оплаты',
|
|
145
146
|
choosePayment: 'Выберите способ оплаты',
|
|
147
|
+
noPayment: 'Для этого заказа нет доступных способов оплаты. Они зависят от заказа и от страны вашего номера телефона.',
|
|
146
148
|
recipient: 'Получатель',
|
|
147
149
|
myself: 'Себе',
|
|
148
150
|
myselfHint: 'Себе: получу заказ самостоятельно',
|
|
@@ -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',
|