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 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 **124 storefront components**, copied into
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.4.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",
@@ -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 its copyright line.
126
- Company contact and registration details appear beside the message form on
127
- \`/contact\`. The home banner spans the available viewport
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)] max-w-[960px]
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)_auto]
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
- <OrionplaceImage
294
+ <!-- At full size the image scrolls inside the stage; the arrows stay in place. -->
295
+ <div
243
296
  v-if="current"
244
- :key="current.id"
245
- :src="current.image"
246
- :alt="photoLabel(active)"
247
- class="block h-full max-h-full w-full object-contain"
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` |