orionplace-nuxt 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 +9 -3
- package/package.json +1 -1
- package/src/project-readme.mjs +5 -3
- package/templates/app/components/orionplace/blog/BlogArticle.README.md +1 -1
- package/templates/app/components/orionplace/blog/BlogArticle.vue +78 -1
- package/templates/app/components/orionplace/legal/CookieNotice.README.md +34 -0
- package/templates/app/components/orionplace/legal/CookieNotice.vue +84 -0
- package/templates/app/components/orionplace/legal/StorefrontPrivacy.README.md +35 -0
- package/templates/app/components/orionplace/legal/StorefrontPrivacy.vue +124 -0
- package/templates/app/components/orionplace/products/ProductReviewImageViewer.README.md +3 -2
- package/templates/app/components/orionplace/products/ProductReviewImageViewer.vue +85 -11
- package/templates/app/components/orionplace/storefront/StorefrontFooter.vue +12 -0
- package/templates/app/layouts/default.vue +7 -0
- package/templates/app/pages/privacy.vue +17 -0
- package/templates/app/utils/orionplace/blogImageViewer.ts +84 -0
- package/templates/app/utils/orionplace/blogLabels.ts +4 -0
- package/templates/app/utils/orionplace/footerLabels.ts +2 -0
- package/templates/app/utils/orionplace/pageSeo.ts +10 -0
- package/templates/app/utils/orionplace/privacyLabels.ts +198 -0
- package/templates/app/utils/orionplace/reviewLabels.ts +4 -0
- package/templates/orionplace/COMPONENTS.md +8 -2
package/README.md
CHANGED
|
@@ -129,7 +129,7 @@ Framework imports such as `#imports` keep their own names.
|
|
|
129
129
|
<!-- components:start (generated by scripts/generate-package-readme-components.mjs) -->
|
|
130
130
|
## Components
|
|
131
131
|
|
|
132
|
-
The starter includes **
|
|
132
|
+
The starter includes **126 storefront components**, copied into
|
|
133
133
|
`app/components/orionplace/` of your project. Each one has a README in English and Russian
|
|
134
134
|
next to its source that describes its inputs, data and actions.
|
|
135
135
|
|
|
@@ -294,6 +294,8 @@ Journal content and customer contact flows.
|
|
|
294
294
|
| **Contact details** | `AboutPage` | Display the store’s published company and contact information. |
|
|
295
295
|
| **Contact form** | `ContactForm` | Sending a question to the store. |
|
|
296
296
|
| **Unsubscribe from emails** | `EmailUnsubscribe` | Confirming an unsubscribe request from an email link. |
|
|
297
|
+
| **Privacy policy page** | `StorefrontPrivacy` | The store’s personal data policy with a cookies section and the operator’s details. |
|
|
298
|
+
| **Cookie notice** | `CookieNotice` | A small notice about the necessary cookies with a link to the cookies section of the policy. |
|
|
297
299
|
|
|
298
300
|
### Shared interface
|
|
299
301
|
|
|
@@ -1096,10 +1098,14 @@ red `*` directly after the label text.
|
|
|
1096
1098
|
|
|
1097
1099
|
The default layout renders the [footer](https://orionmarket.place/en/sandbox/components/nuxt/footer)
|
|
1098
1100
|
with real store categories, home-section anchors, profile/journal navigation,
|
|
1099
|
-
a contact-page link and social links. On `/contact`, `StorefrontContact` places
|
|
1101
|
+
a contact-page link, a `/privacy` link and social links. On `/contact`, `StorefrontContact` places
|
|
1100
1102
|
company details (address, `tel:` phone link, registration numbers with copy
|
|
1101
1103
|
buttons and social links) in a column beside the bordered message-form panel;
|
|
1102
|
-
the columns stack on narrow screens and empty details are hidden.
|
|
1104
|
+
the columns stack on narrow screens and empty details are hidden. `/privacy`
|
|
1105
|
+
shows the store's privacy policy (`StorefrontPrivacy`, texts in
|
|
1106
|
+
`app/utils/orionplace/privacyLabels.ts`) with the operator's details; on the
|
|
1107
|
+
website the layout's `CookieNotice` links its `#cookies` section until the
|
|
1108
|
+
visitor dismisses it.
|
|
1103
1109
|
The home banner spans the available viewport
|
|
1104
1110
|
width without a frame. Its media area keeps the same proportions at every width; the image fills the
|
|
1105
1111
|
area without distortion and crops its edges when needed. Event media keeps its
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orionplace-nuxt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Nuxt 4 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",
|
package/src/project-readme.mjs
CHANGED
|
@@ -122,9 +122,11 @@ red \`*\` directly after the label text.
|
|
|
122
122
|
|
|
123
123
|
The default layout renders the [footer](https://orionmarket.place/en/sandbox/components/nuxt/footer)
|
|
124
124
|
with real store categories, home-section anchors, profile/journal navigation,
|
|
125
|
-
a \`/contact\` link, social links and the company name in
|
|
126
|
-
Company contact and registration details appear beside the
|
|
127
|
-
\`/contact\`.
|
|
125
|
+
a \`/contact\` link, a \`/privacy\` link, social links and the company name in
|
|
126
|
+
its copyright line. Company contact and registration details appear beside the
|
|
127
|
+
message form on \`/contact\`. \`/privacy\` shows the store's privacy policy
|
|
128
|
+
(texts in \`app/utils/orionplace/privacyLabels.ts\`); on the website a cookie
|
|
129
|
+
notice links its \`#cookies\` section until the visitor dismisses it. The home banner spans the available viewport
|
|
128
130
|
width without a frame. Its media area keeps the same proportions at every width; the image fills the
|
|
129
131
|
area without distortion and crops its edges when needed. Event media keeps its
|
|
130
132
|
own reserved proportions, which differ on a narrow screen.
|
|
@@ -20,7 +20,7 @@ After client-side navigation, while the first `blog_posts_get` request runs, a p
|
|
|
20
20
|
|
|
21
21
|
When `has_full_access` is false, the article shows its preview and an access panel: the text explains that the full article opens with an active subscription plan that includes it, and the Plans link opens the storefront plans page `/tariffs` with the current `locale`. `premium_tariff_ids` lists the IDs of the plans that include the article; the link passes them as `ids`, so the plans page shows only those plans. Without those IDs the link opens the whole list.
|
|
22
22
|
|
|
23
|
-
The first preview image is shown above the text, in an area that keeps its proportions. A post without a preview image, or with an image that fails to load, shows no cover. Article videos are shown with their poster image; their addresses may be backend paths (`/ps3/...`). In an Android app build the backend's own media paths (`/ps3/...`: cover, article videos, images and videos inside the text) load from the storefront address.
|
|
23
|
+
The first preview image is shown above the text, in an area that keeps its proportions. A post without a preview image, or with an image that fails to load, shows no cover. The cover and the images of the text open larger: a click, `Enter` or `Space` on one opens `ProductReviewImageViewer` at that image, with the article's other images a step away and each image's caption (its `figcaption`, else its `title`, else its `alt`) under it. The images get `role="button"`, a keyboard stop and an «Open image» name; a zoom-in cursor marks them. Images inside a link, a button or a product placement keep their own action. The helper is `app/utils/orionplace/blogImageViewer.ts`. Article videos are shown with their poster image; their addresses may be backend paths (`/ps3/...`). In an Android app build the backend's own media paths (`/ps3/...`: cover, article videos, images and videos inside the text) load from the storefront address.
|
|
24
24
|
|
|
25
25
|
When the first successful `blog_posts_get` response has no article with this ID, the server render of the supplied page answers HTTP 404; after client-side navigation the component emits `missing`. The backend returns an empty list for an unknown ID, an unpublished article and a subscription-only article that the current visitor cannot open. An article returned with `has_full_access = false` keeps its preview and the access panel with the Plans link. An article that disappears after sign-out keeps the “This article is unavailable” state on the page. A failed request is not a missing article: the error notice keeps its Refresh button. The supplied `app/pages/journal/[id].vue` opens the storefront 404 page on `missing` through `useOrionplaceMissingPage()`.
|
|
26
26
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, onMounted, ref, watch } from 'vue';
|
|
2
|
+
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
|
3
3
|
import { useRuntimeConfig } from '#imports';
|
|
4
4
|
import { useOrionplaceStore } from '@/stores/orionplace';
|
|
5
5
|
import { useOrionplaceProductsStore } from '@/stores/orionplaceProducts';
|
|
@@ -25,6 +25,16 @@
|
|
|
25
25
|
nativeAppOrigin,
|
|
26
26
|
} from '@/utils/orionplace/nativeApp';
|
|
27
27
|
import { requestBlogPost } from '@/utils/orionplace/blogLayout';
|
|
28
|
+
import {
|
|
29
|
+
blogViewerImage,
|
|
30
|
+
blogViewerImages,
|
|
31
|
+
blogViewerPhotos,
|
|
32
|
+
blogViewerIndex,
|
|
33
|
+
isBlogViewerOpenKey,
|
|
34
|
+
markBlogViewerImages,
|
|
35
|
+
} from '@/utils/orionplace/blogImageViewer';
|
|
36
|
+
import type { IBlogViewerImage } from '@/utils/orionplace/blogImageViewer';
|
|
37
|
+
import ProductReviewImageViewer from '@/components/orionplace/products/ProductReviewImageViewer.vue';
|
|
28
38
|
import type { IBlogPost } from '@/interfaces/orionplace/blog/IBlogApi';
|
|
29
39
|
import type { IProductSecondaryVariantRequest } from '@/interfaces/orionplace/products/IProductSecondaryVariantRequest';
|
|
30
40
|
import type {
|
|
@@ -75,6 +85,10 @@
|
|
|
75
85
|
const task = useOrionplaceTask();
|
|
76
86
|
const post = ref<IBlogPost>();
|
|
77
87
|
const article = ref<HTMLElement>();
|
|
88
|
+
const coverImage = ref<HTMLImageElement>();
|
|
89
|
+
// The cover and the text images open larger in the viewer.
|
|
90
|
+
const viewerImages = ref<IBlogViewerImage[]>([]);
|
|
91
|
+
const viewerStart = ref(-1);
|
|
78
92
|
const accessChanged = ref(false);
|
|
79
93
|
const coverFailed = ref(false);
|
|
80
94
|
const appOrigin = nativeAppOrigin(
|
|
@@ -230,6 +244,44 @@
|
|
|
230
244
|
wasShown.value = true;
|
|
231
245
|
showProducts(props.initialPost.related_products);
|
|
232
246
|
}
|
|
247
|
+
function openableImages() {
|
|
248
|
+
return blogViewerImages([coverImage.value, article.value]);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// The text is set with v-html: its images become buttons after each render.
|
|
252
|
+
watch(
|
|
253
|
+
[html, cover, mounted, () => labels.value.imageOpen, () => post.value?.id],
|
|
254
|
+
() => {
|
|
255
|
+
void nextTick(() => markBlogViewerImages(openableImages(), labels.value.imageOpen));
|
|
256
|
+
},
|
|
257
|
+
{ flush: 'post' },
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
function openViewer(event: Event) {
|
|
261
|
+
const images = openableImages();
|
|
262
|
+
const index = blogViewerIndex(event.target, images);
|
|
263
|
+
if (index < 0) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
event.preventDefault();
|
|
267
|
+
viewerImages.value = images.map((image) => {
|
|
268
|
+
const shown = blogViewerImage(image);
|
|
269
|
+
return image === coverImage.value ? { ...shown, alt: shown.alt || title.value } : shown;
|
|
270
|
+
});
|
|
271
|
+
viewerStart.value = index;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function onArticleKeydown(event: KeyboardEvent) {
|
|
275
|
+
if (isBlogViewerOpenKey(event)) {
|
|
276
|
+
openViewer(event);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// The viewer gives the focus back to the image that opened it.
|
|
281
|
+
function closeViewer() {
|
|
282
|
+
viewerStart.value = -1;
|
|
283
|
+
}
|
|
284
|
+
|
|
233
285
|
onMounted(() => {
|
|
234
286
|
mounted.value = true;
|
|
235
287
|
if (!preloaded) {
|
|
@@ -277,6 +329,8 @@
|
|
|
277
329
|
<article
|
|
278
330
|
v-if="post"
|
|
279
331
|
class="op-feature-stack mx-auto w-full max-w-[720px]"
|
|
332
|
+
@click="openViewer"
|
|
333
|
+
@keydown="onArticleKeydown"
|
|
280
334
|
>
|
|
281
335
|
<header class="op-feature-stack">
|
|
282
336
|
<h1
|
|
@@ -302,6 +356,7 @@
|
|
|
302
356
|
</div>
|
|
303
357
|
<img
|
|
304
358
|
v-if="cover && !coverFailed"
|
|
359
|
+
ref="coverImage"
|
|
305
360
|
:src="cover"
|
|
306
361
|
alt=""
|
|
307
362
|
width="3"
|
|
@@ -471,6 +526,16 @@
|
|
|
471
526
|
kind="journal"
|
|
472
527
|
:message="labels.unavailable"
|
|
473
528
|
/>
|
|
529
|
+
<ProductReviewImageViewer
|
|
530
|
+
v-if="viewerStart >= 0 && viewerImages.length"
|
|
531
|
+
:images="blogViewerPhotos(viewerImages)"
|
|
532
|
+
:index="viewerStart"
|
|
533
|
+
:label="labels.imageViewer"
|
|
534
|
+
:alt="title || labels.imageViewer"
|
|
535
|
+
:captions="viewerImages.map((image) => image.caption || image.alt)"
|
|
536
|
+
wide
|
|
537
|
+
@close="closeViewer"
|
|
538
|
+
/>
|
|
474
539
|
</section>
|
|
475
540
|
</template>
|
|
476
541
|
|
|
@@ -683,6 +748,18 @@
|
|
|
683
748
|
vertical-align: bottom;
|
|
684
749
|
}
|
|
685
750
|
|
|
751
|
+
/* Images that open in the viewer. */
|
|
752
|
+
[data-blog-article-cover][data-blog-image-viewer],
|
|
753
|
+
.op-feature-prose :deep(img[data-blog-image-viewer]) {
|
|
754
|
+
cursor: zoom-in;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
[data-blog-article-cover][data-blog-image-viewer]:focus-visible,
|
|
758
|
+
.op-feature-prose :deep(img[data-blog-image-viewer]:focus-visible) {
|
|
759
|
+
outline: 2px solid var(--orionplace-accent);
|
|
760
|
+
outline-offset: 3px;
|
|
761
|
+
}
|
|
762
|
+
|
|
686
763
|
.op-feature-prose :deep(:where(img, video)) {
|
|
687
764
|
display: block;
|
|
688
765
|
max-width: 100%;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# CookieNotice · Nuxt
|
|
2
|
+
|
|
3
|
+
A small notice about the necessary cookies with a link to the cookies section of the policy.
|
|
4
|
+
|
|
5
|
+
The supplied layout shows it on every page of the website, not in the Android app build.
|
|
6
|
+
[English guide](https://orionmarket.place/en/sandbox/components/nuxt/cookie-notice) ·
|
|
7
|
+
[Russian guide](https://orionmarket.place/ru/sandbox/components/nuxt/cookie-notice)
|
|
8
|
+
|
|
9
|
+
## Contract and dependencies
|
|
10
|
+
|
|
11
|
+
After mounting, the notice appears at the bottom of the screen until the
|
|
12
|
+
visitor presses OK; the choice is kept in local storage under
|
|
13
|
+
`orionplace-cookie-notice-seen` (`COOKIE_NOTICE_STORAGE_KEY` in
|
|
14
|
+
`app/utils/orionplace/privacyLabels.ts`). Server HTML never contains it. The
|
|
15
|
+
storefront sets only necessary cookies, so the notice asks for no consent; a
|
|
16
|
+
store that adds analytics or advertising cookies needs a consent form instead.
|
|
17
|
+
|
|
18
|
+
`href` is the address of the cookies section with the language, for example
|
|
19
|
+
`/privacy?locale=ru#cookies`; the default is `/privacy#cookies`. An ordinary
|
|
20
|
+
click on the link emits `navigate` with `href`; the layout navigates.
|
|
21
|
+
|
|
22
|
+
Source is maintained in
|
|
23
|
+
`app/components/orionplace/legal/CookieNotice.vue`.
|
|
24
|
+
|
|
25
|
+
The configured Nuxt starter initializes Application Load and its named API client.
|
|
26
|
+
Component-source ZIPs contain imported children, types,
|
|
27
|
+
composables, labels and styles. The included `orionplace/COMPONENT-DOWNLOAD.md`
|
|
28
|
+
describes dependencies, theme and consumer configuration. Standalone file
|
|
29
|
+
copying requires the same dependencies. Links to storefront routes use the
|
|
30
|
+
pages supplied by the full starter.
|
|
31
|
+
|
|
32
|
+
## Endpoint coverage
|
|
33
|
+
|
|
34
|
+
None.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref } from 'vue';
|
|
3
|
+
|
|
4
|
+
import { useOrionplaceSelectionsStore } from '@/stores/orionplaceSelections';
|
|
5
|
+
import {
|
|
6
|
+
COOKIE_NOTICE_STORAGE_KEY,
|
|
7
|
+
privacyLabels,
|
|
8
|
+
} from '@/utils/orionplace/privacyLabels';
|
|
9
|
+
import '@/components/orionplace/shared/features.css';
|
|
10
|
+
|
|
11
|
+
// The storefront sets only necessary cookies, so this is a notice, not a
|
|
12
|
+
// consent form. It appears after mounting and stays away once dismissed.
|
|
13
|
+
const props = withDefaults(defineProps<{ href?: string }>(), {
|
|
14
|
+
href: '/privacy#cookies',
|
|
15
|
+
});
|
|
16
|
+
const emit = defineEmits<{ navigate: [path: string] }>();
|
|
17
|
+
const selectionStore = useOrionplaceSelectionsStore();
|
|
18
|
+
const labels = computed(() => privacyLabels(selectionStore.localeCode).cookie);
|
|
19
|
+
const visible = ref(false);
|
|
20
|
+
|
|
21
|
+
function dismiss() {
|
|
22
|
+
visible.value = false;
|
|
23
|
+
try {
|
|
24
|
+
localStorage.setItem(COOKIE_NOTICE_STORAGE_KEY, '1');
|
|
25
|
+
} catch {
|
|
26
|
+
// Without storage the notice comes back on the next visit.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function open(event: MouseEvent) {
|
|
31
|
+
if (
|
|
32
|
+
event.ctrlKey ||
|
|
33
|
+
event.metaKey ||
|
|
34
|
+
event.shiftKey ||
|
|
35
|
+
event.altKey ||
|
|
36
|
+
event.button !== 0
|
|
37
|
+
) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
emit('navigate', props.href);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
onMounted(() => {
|
|
45
|
+
try {
|
|
46
|
+
visible.value = localStorage.getItem(COOKIE_NOTICE_STORAGE_KEY) !== '1';
|
|
47
|
+
} catch {
|
|
48
|
+
visible.value = true;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<div
|
|
55
|
+
v-if="visible"
|
|
56
|
+
data-feature-part="CookieNotice"
|
|
57
|
+
role="region"
|
|
58
|
+
:aria-label="labels.label"
|
|
59
|
+
class="
|
|
60
|
+
op-feature fixed inset-x-3 bottom-3 z-40
|
|
61
|
+
flex flex-col items-stretch gap-3
|
|
62
|
+
border border-solid border-orionplace-border
|
|
63
|
+
bg-orionplace-surface p-4 shadow-lg
|
|
64
|
+
sm:left-auto sm:right-5 sm:bottom-5 sm:max-w-[560px]
|
|
65
|
+
sm:flex-row sm:items-center sm:gap-4
|
|
66
|
+
"
|
|
67
|
+
>
|
|
68
|
+
<p class="op-feature-copy op-feature-muted">
|
|
69
|
+
{{ labels.text }}
|
|
70
|
+
<a
|
|
71
|
+
:href="props.href"
|
|
72
|
+
class="whitespace-nowrap text-orionplace-text underline underline-offset-2"
|
|
73
|
+
@click="open"
|
|
74
|
+
>{{ labels.link }}</a>
|
|
75
|
+
</p>
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
class="op-feature-button op-feature-primary shrink-0"
|
|
79
|
+
@click="dismiss"
|
|
80
|
+
>
|
|
81
|
+
{{ labels.ok }}
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
</template>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# StorefrontPrivacy · Nuxt
|
|
2
|
+
|
|
3
|
+
The store's personal data policy with a cookies section and the operator's details.
|
|
4
|
+
|
|
5
|
+
The supplied storefront uses this component at `/privacy`; the footer links it.
|
|
6
|
+
[English guide](https://orionmarket.place/en/sandbox/components/nuxt/privacy-page) ·
|
|
7
|
+
[Russian guide](https://orionmarket.place/ru/sandbox/components/nuxt/privacy-page)
|
|
8
|
+
|
|
9
|
+
## Contract and dependencies
|
|
10
|
+
|
|
11
|
+
The page has no inputs and sends no requests. It lists which data the store
|
|
12
|
+
receives, why, who receives it, how long it is kept, the customer's rights and
|
|
13
|
+
the cookies the site sets. A contents list links every section; the cookies
|
|
14
|
+
section has the anchor `#cookies`, which the cookie notice opens. The operator
|
|
15
|
+
block shows the company name, tax ID, registration number, address and phone
|
|
16
|
+
from `store.init.contact`; empty values are left out.
|
|
17
|
+
|
|
18
|
+
The texts are in `app/utils/orionplace/privacyLabels.ts`, in Russian and
|
|
19
|
+
English. They describe the storefront as supplied: the `sessionid` and
|
|
20
|
+
`csrftoken` cookies and the notice's own local storage mark. A store that adds
|
|
21
|
+
analytics, chat widgets or other cookies lists them in the cookies section.
|
|
22
|
+
|
|
23
|
+
Source is maintained in
|
|
24
|
+
`app/components/orionplace/legal/StorefrontPrivacy.vue`; the primary interface is `app/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonContact.ts`.
|
|
25
|
+
|
|
26
|
+
The configured Nuxt starter initializes Application Load and its named API client.
|
|
27
|
+
Component-source ZIPs contain imported children, types,
|
|
28
|
+
composables, labels and styles. The included `orionplace/COMPONENT-DOWNLOAD.md`
|
|
29
|
+
describes dependencies, theme and consumer configuration. Standalone file
|
|
30
|
+
copying requires the same dependencies. Links to storefront routes use the
|
|
31
|
+
pages supplied by the full starter.
|
|
32
|
+
|
|
33
|
+
## Endpoint coverage
|
|
34
|
+
|
|
35
|
+
None: the page reads the Application Load contact details.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
|
|
4
|
+
import { useOrionplaceStore } from '@/stores/orionplace';
|
|
5
|
+
import { useOrionplaceSelectionsStore } from '@/stores/orionplaceSelections';
|
|
6
|
+
import { contactLabels } from '@/utils/orionplace/contactLabels';
|
|
7
|
+
import { privacyLabels } from '@/utils/orionplace/privacyLabels';
|
|
8
|
+
import '@/components/orionplace/shared/features.css';
|
|
9
|
+
|
|
10
|
+
const store = useOrionplaceStore();
|
|
11
|
+
const selectionStore = useOrionplaceSelectionsStore();
|
|
12
|
+
const contact = computed(() => store.init?.contact);
|
|
13
|
+
const company = computed(
|
|
14
|
+
() =>
|
|
15
|
+
contact.value?.company_legal_name?.trim() ||
|
|
16
|
+
contact.value?.company_name?.trim() ||
|
|
17
|
+
'',
|
|
18
|
+
);
|
|
19
|
+
const labels = computed(() =>
|
|
20
|
+
privacyLabels(selectionStore.localeCode, company.value),
|
|
21
|
+
);
|
|
22
|
+
const fields = computed(() => contactLabels(selectionStore.localeCode));
|
|
23
|
+
// The operator's details as the platform sends them; empty rows are left out.
|
|
24
|
+
const details = computed(() =>
|
|
25
|
+
[
|
|
26
|
+
[fields.value.legalName, company.value],
|
|
27
|
+
[fields.value.taxId, contact.value?.company_tax_id],
|
|
28
|
+
[fields.value.registrationNumber, contact.value?.company_organization_id],
|
|
29
|
+
[fields.value.address, contact.value?.company_contact_address],
|
|
30
|
+
[fields.value.phone, contact.value?.company_contact_phone],
|
|
31
|
+
]
|
|
32
|
+
.map(([label, value]) => ({ label, value: value?.trim() ?? '' }))
|
|
33
|
+
.filter((row) => row.value),
|
|
34
|
+
);
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<section
|
|
39
|
+
data-feature-part="StorefrontPrivacy"
|
|
40
|
+
class="op-feature grid w-full @container"
|
|
41
|
+
>
|
|
42
|
+
<header
|
|
43
|
+
class="
|
|
44
|
+
grid gap-3
|
|
45
|
+
border-0 border-b border-solid border-orionplace-border
|
|
46
|
+
mb-8 pb-6 @min-[592px]:mb-12 @min-[592px]:pb-8
|
|
47
|
+
"
|
|
48
|
+
>
|
|
49
|
+
<h1 class="op-feature-title">{{ labels.title }}</h1>
|
|
50
|
+
<p class="op-feature-copy op-feature-muted max-w-2xl">{{ labels.lead }}</p>
|
|
51
|
+
</header>
|
|
52
|
+
|
|
53
|
+
<div
|
|
54
|
+
class="
|
|
55
|
+
grid items-start gap-10
|
|
56
|
+
@min-[960px]:grid-cols-[minmax(0,3fr)_minmax(0,9fr)] @min-[960px]:gap-16
|
|
57
|
+
"
|
|
58
|
+
>
|
|
59
|
+
<nav
|
|
60
|
+
:aria-label="labels.contents"
|
|
61
|
+
class="grid gap-3 @min-[960px]:sticky @min-[960px]:top-6"
|
|
62
|
+
>
|
|
63
|
+
<h2 class="op-feature-subtitle op-feature-muted">{{ labels.contents }}</h2>
|
|
64
|
+
<ol class="m-0 grid list-none gap-2 p-0">
|
|
65
|
+
<li
|
|
66
|
+
v-for="section in labels.sections"
|
|
67
|
+
:key="section.id"
|
|
68
|
+
>
|
|
69
|
+
<a
|
|
70
|
+
:href="`#${section.id}`"
|
|
71
|
+
class="
|
|
72
|
+
text-orionplace-text no-underline outline-none
|
|
73
|
+
hover:underline focus-visible:underline
|
|
74
|
+
"
|
|
75
|
+
>{{ section.title }}</a>
|
|
76
|
+
</li>
|
|
77
|
+
</ol>
|
|
78
|
+
</nav>
|
|
79
|
+
|
|
80
|
+
<article class="grid min-w-0 max-w-3xl gap-8">
|
|
81
|
+
<section
|
|
82
|
+
v-for="section in labels.sections"
|
|
83
|
+
:id="section.id"
|
|
84
|
+
:key="section.id"
|
|
85
|
+
class="grid scroll-mt-6 gap-3"
|
|
86
|
+
>
|
|
87
|
+
<h2 class="op-feature-subtitle">{{ section.title }}</h2>
|
|
88
|
+
<p
|
|
89
|
+
v-for="(line, index) in section.text"
|
|
90
|
+
:key="index"
|
|
91
|
+
class="op-feature-copy"
|
|
92
|
+
>
|
|
93
|
+
{{ line }}
|
|
94
|
+
</p>
|
|
95
|
+
</section>
|
|
96
|
+
|
|
97
|
+
<section
|
|
98
|
+
id="operator-details"
|
|
99
|
+
class="op-feature-panel grid gap-3"
|
|
100
|
+
>
|
|
101
|
+
<h2 class="op-feature-subtitle">{{ labels.operator }}</h2>
|
|
102
|
+
<dl
|
|
103
|
+
v-if="details.length"
|
|
104
|
+
class="m-0 grid gap-x-6 gap-y-2 @min-[592px]:grid-cols-[auto_1fr]"
|
|
105
|
+
>
|
|
106
|
+
<template
|
|
107
|
+
v-for="row in details"
|
|
108
|
+
:key="row.label"
|
|
109
|
+
>
|
|
110
|
+
<dt class="op-feature-muted">{{ row.label }}</dt>
|
|
111
|
+
<dd class="m-0 break-words">{{ row.value }}</dd>
|
|
112
|
+
</template>
|
|
113
|
+
</dl>
|
|
114
|
+
<p
|
|
115
|
+
v-else
|
|
116
|
+
class="op-feature-copy op-feature-muted"
|
|
117
|
+
>
|
|
118
|
+
{{ labels.operatorUnknown }}
|
|
119
|
+
</p>
|
|
120
|
+
</section>
|
|
121
|
+
</article>
|
|
122
|
+
</div>
|
|
123
|
+
</section>
|
|
124
|
+
</template>
|
|
@@ -4,7 +4,8 @@ The photos of one review, shown large on the page they were opened from.
|
|
|
4
4
|
|
|
5
5
|
The supplied storefront uses this component at `/product/:id` and
|
|
6
6
|
`/sellers?seller=<id>`, inside `ProductReviews`, and on `/product/:id` for the
|
|
7
|
-
product's own photos, inside `StorefrontProduct
|
|
7
|
+
product's own photos, inside `StorefrontProduct`, and on `/journal/:id` for the
|
|
8
|
+
images of an article, inside `BlogArticle`.
|
|
8
9
|
[English guide](https://orionmarket.place/en/sandbox/components/nuxt/product-review-image-viewer) ·
|
|
9
10
|
[Russian guide](https://orionmarket.place/ru/sandbox/components/nuxt/product-review-image-viewer)
|
|
10
11
|
|
|
@@ -25,7 +26,7 @@ around the viewer emit `close`; the parent removes the component, which
|
|
|
25
26
|
returns the focus to the photo that opened it. Changing the photo emits
|
|
26
27
|
`change` with its `id` and its position. A sideways swipe on the large photo
|
|
27
28
|
shows the next or previous one. `label` replaces the dialog name and `alt` the
|
|
28
|
-
photo text: the product page passes «Product photos» and the product name.
|
|
29
|
+
photo text: the product page passes «Product photos» and the product name. `captions` puts a caption under each photo and `wide` lets the viewer use up to 1600px of the screen width instead of 960px and adds full-size zoom: the magnifier button or a click on the image shows it at its own size, scrolling inside the viewer, and another photo opens fitted again; `BlogArticle` passes both for the images of an article.
|
|
29
30
|
|
|
30
31
|
While the viewer is open the page behind it does not scroll, and its scrollbar
|
|
31
32
|
space is kept, so opening and closing the viewer does not move the page. The
|
|
@@ -12,14 +12,18 @@
|
|
|
12
12
|
import OrionplaceImage from '@/components/orionplace/shared/OrionplaceImage.vue';
|
|
13
13
|
|
|
14
14
|
// `label` and `alt` name the product's own photos on the product page.
|
|
15
|
+
// `captions` are shown under each image (blog article images have them);
|
|
16
|
+
// `wide` lets screenshots and other detailed images use more of the screen.
|
|
15
17
|
const props = withDefaults(
|
|
16
18
|
defineProps<{
|
|
17
19
|
images: IProductFeedbackImage[];
|
|
18
20
|
index?: number;
|
|
19
21
|
label?: string;
|
|
20
22
|
alt?: string;
|
|
23
|
+
captions?: string[];
|
|
24
|
+
wide?: boolean;
|
|
21
25
|
}>(),
|
|
22
|
-
{ index: 0 },
|
|
26
|
+
{ index: 0, captions: () => [], wide: false },
|
|
23
27
|
);
|
|
24
28
|
const emit = defineEmits<{
|
|
25
29
|
change: [photo: { id: number; index: number }];
|
|
@@ -33,6 +37,13 @@
|
|
|
33
37
|
const active = ref(startIndex());
|
|
34
38
|
const current = computed(() => props.images[active.value]);
|
|
35
39
|
const several = computed(() => props.images.length > 1);
|
|
40
|
+
const caption = computed(() => props.captions[active.value]?.trim() ?? '');
|
|
41
|
+
// A wide viewer can show the image at its own size; the stage then scrolls.
|
|
42
|
+
const zoomed = ref(false);
|
|
43
|
+
|
|
44
|
+
function toggleZoom() {
|
|
45
|
+
zoomed.value = props.wide && !zoomed.value;
|
|
46
|
+
}
|
|
36
47
|
// The element that opened the viewer receives the focus back on close.
|
|
37
48
|
let opener: HTMLElement | undefined;
|
|
38
49
|
|
|
@@ -83,7 +94,7 @@
|
|
|
83
94
|
const start = swipeStart;
|
|
84
95
|
|
|
85
96
|
swipeStart = undefined;
|
|
86
|
-
if (!start || !touch || !several.value) {
|
|
97
|
+
if (!start || !touch || !several.value || zoomed.value) {
|
|
87
98
|
return;
|
|
88
99
|
}
|
|
89
100
|
select(
|
|
@@ -147,6 +158,11 @@
|
|
|
147
158
|
},
|
|
148
159
|
);
|
|
149
160
|
|
|
161
|
+
// Another photo opens fitted to the screen.
|
|
162
|
+
watch(active, () => {
|
|
163
|
+
zoomed.value = false;
|
|
164
|
+
});
|
|
165
|
+
|
|
150
166
|
onMounted(() => {
|
|
151
167
|
if (typeof document === 'undefined') {
|
|
152
168
|
return;
|
|
@@ -172,12 +188,13 @@
|
|
|
172
188
|
ref="dialog"
|
|
173
189
|
class="
|
|
174
190
|
m-auto box-border h-[calc(100dvh-32px)] max-h-none
|
|
175
|
-
w-[calc(100vw-32px)]
|
|
191
|
+
w-[calc(100vw-32px)]
|
|
176
192
|
overflow-hidden rounded-none p-0
|
|
177
193
|
border border-solid border-orionplace-border
|
|
178
194
|
bg-orionplace-surface font-orionplace text-orionplace-text
|
|
179
195
|
backdrop:bg-black/35 backdrop:backdrop-blur-[2px]
|
|
180
196
|
"
|
|
197
|
+
:class="wide ? 'max-w-[1600px]' : 'max-w-[960px]'"
|
|
181
198
|
:aria-label="label || labels.photoViewer"
|
|
182
199
|
data-feature-part="ProductReviewImageViewer"
|
|
183
200
|
data-review-image-viewer
|
|
@@ -192,7 +209,7 @@
|
|
|
192
209
|
<div
|
|
193
210
|
class="
|
|
194
211
|
@container/review-image-viewer
|
|
195
|
-
grid h-full grid-rows-[auto_minmax(0,1fr)
|
|
212
|
+
grid h-full grid-rows-[auto_minmax(0,1fr)_auto_auto]
|
|
196
213
|
"
|
|
197
214
|
>
|
|
198
215
|
<header
|
|
@@ -212,6 +229,41 @@
|
|
|
212
229
|
>
|
|
213
230
|
{{ active + 1 }} / {{ images.length }}
|
|
214
231
|
</p>
|
|
232
|
+
<button
|
|
233
|
+
v-if="wide"
|
|
234
|
+
type="button"
|
|
235
|
+
class="
|
|
236
|
+
ml-auto grid h-10 w-10 shrink-0 place-items-center
|
|
237
|
+
cursor-pointer border-0 bg-transparent p-0
|
|
238
|
+
text-orionplace-text
|
|
239
|
+
outline-none transition-colors motion-reduce:transition-none
|
|
240
|
+
hover:bg-orionplace-hover focus-visible:bg-orionplace-hover
|
|
241
|
+
"
|
|
242
|
+
:aria-label="zoomed ? labels.zoomOut : labels.zoomIn"
|
|
243
|
+
:aria-pressed="zoomed"
|
|
244
|
+
data-review-image-zoom
|
|
245
|
+
@click="toggleZoom"
|
|
246
|
+
>
|
|
247
|
+
<svg
|
|
248
|
+
viewBox="0 0 24 24"
|
|
249
|
+
class="h-5 w-5"
|
|
250
|
+
fill="none"
|
|
251
|
+
stroke="currentColor"
|
|
252
|
+
stroke-width="1.5"
|
|
253
|
+
aria-hidden="true"
|
|
254
|
+
>
|
|
255
|
+
<circle
|
|
256
|
+
cx="10.5"
|
|
257
|
+
cy="10.5"
|
|
258
|
+
r="6.5"
|
|
259
|
+
/>
|
|
260
|
+
<path d="m15.5 15.5 5 5M7.5 10.5h6" />
|
|
261
|
+
<path
|
|
262
|
+
v-if="!zoomed"
|
|
263
|
+
d="M10.5 7.5v6"
|
|
264
|
+
/>
|
|
265
|
+
</svg>
|
|
266
|
+
</button>
|
|
215
267
|
<button
|
|
216
268
|
type="button"
|
|
217
269
|
class="
|
|
@@ -234,18 +286,30 @@
|
|
|
234
286
|
p-4 @min-[606px]/review-image-viewer:px-20
|
|
235
287
|
select-none
|
|
236
288
|
"
|
|
237
|
-
:class="{ 'touch-pan-y': several }"
|
|
289
|
+
:class="{ 'touch-pan-y': several && !zoomed }"
|
|
238
290
|
data-review-image-stage
|
|
239
291
|
@touchstart.passive="startSwipe"
|
|
240
292
|
@touchend="endSwipe"
|
|
241
293
|
>
|
|
242
|
-
|
|
294
|
+
<!-- At full size the image scrolls inside the stage; the arrows stay in place. -->
|
|
295
|
+
<div
|
|
243
296
|
v-if="current"
|
|
244
|
-
:
|
|
245
|
-
:
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
297
|
+
:class="zoomed ? 'absolute inset-0 overflow-auto' : 'contents'"
|
|
298
|
+
:data-review-image-zoomed="zoomed || undefined"
|
|
299
|
+
>
|
|
300
|
+
<OrionplaceImage
|
|
301
|
+
:key="current.id"
|
|
302
|
+
:src="current.image"
|
|
303
|
+
:alt="photoLabel(active)"
|
|
304
|
+
:class="[
|
|
305
|
+
zoomed
|
|
306
|
+
? 'm-auto block h-auto w-auto max-h-none! max-w-none!'
|
|
307
|
+
: 'block h-full max-h-full w-full object-contain',
|
|
308
|
+
wide && (zoomed ? 'cursor-zoom-out' : 'cursor-zoom-in'),
|
|
309
|
+
]"
|
|
310
|
+
@click="toggleZoom"
|
|
311
|
+
/>
|
|
312
|
+
</div>
|
|
249
313
|
<template v-if="several">
|
|
250
314
|
<button
|
|
251
315
|
type="button"
|
|
@@ -285,6 +349,16 @@
|
|
|
285
349
|
</button>
|
|
286
350
|
</template>
|
|
287
351
|
</div>
|
|
352
|
+
<p
|
|
353
|
+
v-if="caption"
|
|
354
|
+
class="
|
|
355
|
+
m-0 px-4 pt-1 pb-3 text-center text-sm leading-6
|
|
356
|
+
text-orionplace-muted
|
|
357
|
+
"
|
|
358
|
+
data-review-image-caption
|
|
359
|
+
>
|
|
360
|
+
{{ caption }}
|
|
361
|
+
</p>
|
|
288
362
|
<ol
|
|
289
363
|
v-if="several"
|
|
290
364
|
class="
|
|
@@ -205,6 +205,18 @@
|
|
|
205
205
|
>
|
|
206
206
|
{{ copy.contact }}
|
|
207
207
|
</a>
|
|
208
|
+
<a
|
|
209
|
+
:href="route('/privacy')"
|
|
210
|
+
class="
|
|
211
|
+
w-fit
|
|
212
|
+
text-sm leading-6 text-orionplace-inverse-text no-underline
|
|
213
|
+
outline-none
|
|
214
|
+
hover:text-orionplace-inverse-muted
|
|
215
|
+
focus-visible:text-orionplace-inverse-muted
|
|
216
|
+
"
|
|
217
|
+
>
|
|
218
|
+
{{ copy.privacy }}
|
|
219
|
+
</a>
|
|
208
220
|
</nav>
|
|
209
221
|
</div>
|
|
210
222
|
<p
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { headerLabels } from '@/utils/orionplace/headerLabels';
|
|
23
23
|
import StorefrontFooter from '@/components/orionplace/storefront/StorefrontFooter.vue';
|
|
24
24
|
import StorefrontHeader from '@/components/orionplace/storefront/StorefrontHeader.vue';
|
|
25
|
+
import CookieNotice from '@/components/orionplace/legal/CookieNotice.vue';
|
|
25
26
|
import '@/components/orionplace/shared/features.css';
|
|
26
27
|
import { useOrionplaceCartPreview } from '@/composables/useOrionplaceCartPreview';
|
|
27
28
|
import { useOrionplaceSignIn } from '@/composables/useOrionplaceSignIn';
|
|
@@ -314,6 +315,12 @@
|
|
|
314
315
|
/>
|
|
315
316
|
<div class="min-w-0 flex-1"><slot /></div>
|
|
316
317
|
<StorefrontFooter @navigate="navigateTo($event)" />
|
|
318
|
+
<!-- The notice is for the website; the Android app links its policy from the store listing. -->
|
|
319
|
+
<CookieNotice
|
|
320
|
+
v-if="!appBuild"
|
|
321
|
+
:href="`/privacy?locale=${encodeURIComponent(store.localeCode)}#cookies`"
|
|
322
|
+
@navigate="navigateTo($event)"
|
|
323
|
+
/>
|
|
317
324
|
</div>
|
|
318
325
|
</template>
|
|
319
326
|
<style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useOrionplaceStore } from '@/stores/orionplace';
|
|
3
|
+
import StorefrontPrivacy from '@/components/orionplace/legal/StorefrontPrivacy.vue';
|
|
4
|
+
import { useOrionplacePageSeo } from '@/composables/useOrionplacePageSeo';
|
|
5
|
+
const store = useOrionplaceStore();
|
|
6
|
+
useOrionplacePageSeo('privacy', () => store.localeCode);
|
|
7
|
+
</script>
|
|
8
|
+
<template>
|
|
9
|
+
<main
|
|
10
|
+
class="
|
|
11
|
+
mx-auto max-w-[1440px]
|
|
12
|
+
px-4 py-8 sm:px-6 sm:py-10 lg:px-8
|
|
13
|
+
"
|
|
14
|
+
>
|
|
15
|
+
<StorefrontPrivacy />
|
|
16
|
+
</main>
|
|
17
|
+
</template>
|
|
@@ -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
|
+
}
|
|
@@ -46,6 +46,8 @@ const en = {
|
|
|
46
46
|
`${count} ${count === 1 ? 'article' : 'articles'}`,
|
|
47
47
|
subscription: 'Subscription',
|
|
48
48
|
noFiltered: 'No articles match the selected filters.',
|
|
49
|
+
imageOpen: 'Open image',
|
|
50
|
+
imageViewer: 'Image viewer',
|
|
49
51
|
};
|
|
50
52
|
|
|
51
53
|
function russianArticles(count: number) {
|
|
@@ -104,6 +106,8 @@ const ru: typeof en = {
|
|
|
104
106
|
articleCount: (count: number) => `${count} ${russianArticles(count)}`,
|
|
105
107
|
subscription: 'Подписка',
|
|
106
108
|
noFiltered: 'Нет статей по выбранным фильтрам.',
|
|
109
|
+
imageOpen: 'Открыть изображение',
|
|
110
|
+
imageViewer: 'Просмотр изображения',
|
|
107
111
|
};
|
|
108
112
|
|
|
109
113
|
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: 'Показать следующие миниатюры',
|
|
@@ -1513,10 +1513,14 @@ red `*` directly after the label text.
|
|
|
1513
1513
|
|
|
1514
1514
|
The default layout renders the [footer](https://orionmarket.place/en/sandbox/components/nuxt/footer)
|
|
1515
1515
|
with real store categories, home-section anchors, profile/journal navigation,
|
|
1516
|
-
a contact-page link and social links. On `/contact`, `StorefrontContact` places
|
|
1516
|
+
a contact-page link, a `/privacy` link and social links. On `/contact`, `StorefrontContact` places
|
|
1517
1517
|
company details (address, `tel:` phone link, registration numbers with copy
|
|
1518
1518
|
buttons and social links) in a column beside the bordered message-form panel;
|
|
1519
|
-
the columns stack on narrow screens and empty details are hidden.
|
|
1519
|
+
the columns stack on narrow screens and empty details are hidden. `/privacy`
|
|
1520
|
+
shows the store's privacy policy (`StorefrontPrivacy`, texts in
|
|
1521
|
+
`app/utils/orionplace/privacyLabels.ts`) with the operator's details; on the
|
|
1522
|
+
website the layout's `CookieNotice` links its `#cookies` section until the
|
|
1523
|
+
visitor dismisses it.
|
|
1520
1524
|
The home banner spans the available viewport
|
|
1521
1525
|
width without a frame. Its media area keeps the same proportions at every width; the image fills the
|
|
1522
1526
|
area without distortion and crops its edges when needed. Event media keeps its
|
|
@@ -1605,6 +1609,8 @@ Package labels and guides are available in English and Russian.
|
|
|
1605
1609
|
| `/contact` | [StorefrontContact](../app/components/orionplace/contact/StorefrontContact.vue) | Contact details and message form |
|
|
1606
1610
|
| `/contact` | [ContactForm](../app/components/orionplace/contact/ContactForm.vue) | `misc_captcha`, `misc_contact` |
|
|
1607
1611
|
| `/unsubscribe` | [EmailUnsubscribe](../app/components/orionplace/contact/EmailUnsubscribe.vue) | `misc_unsubscribe` |
|
|
1612
|
+
| `/privacy` | [StorefrontPrivacy](../app/components/orionplace/legal/StorefrontPrivacy.vue) | None: Application Load contact details |
|
|
1613
|
+
| Every page (layout) | [CookieNotice](../app/components/orionplace/legal/CookieNotice.vue) | None: dismissal kept in local storage |
|
|
1608
1614
|
| `/` | [EventEnrollmentDialog](../app/components/orionplace/events/EventEnrollmentDialog.vue) | `profile_event_subscribe` |
|
|
1609
1615
|
| `/product/:id` | [StorefrontProduct](../app/components/orionplace/products/StorefrontProduct.vue) | `products_search`; `products_preview` when the address contains `?key=` |
|
|
1610
1616
|
| `/product/:id`, `/sellers` | [ProductReviews](../app/components/orionplace/products/ProductReviews.vue) | `products_view_feedbacks_feedback_get`, `products_view_feedbacks_stats_get`, `products_feedbacks_mine_get`, `products_feedbacks_like_or_dislike`, `products_feedbacks_reply_preload` |
|