orionplace-svelte 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
@@ -134,7 +134,7 @@ imports such as `$app`, `$env` and generated `./$types` retain their own names.
134
134
  <!-- components:start (generated by scripts/generate-package-readme-components.mjs) -->
135
135
  ## Components
136
136
 
137
- The starter includes **124 storefront components**, copied into
137
+ The starter includes **126 storefront components**, copied into
138
138
  `src/lib/components/orionplace/` of your project. Each one has a README in English and Russian
139
139
  next to its source that describes its inputs, data and actions.
140
140
 
@@ -299,6 +299,8 @@ Journal content and customer contact flows.
299
299
  | **Contact details** | `AboutPage` | Display the store’s published company and contact information. |
300
300
  | **Contact form** | `ContactForm` | Sending a question to the store. |
301
301
  | **Unsubscribe from emails** | `EmailUnsubscribe` | Confirming an unsubscribe request from an email link. |
302
+ | **Privacy policy page** | `StorefrontPrivacy` | The store’s personal data policy with a cookies section and the operator’s details. |
303
+ | **Cookie notice** | `CookieNotice` | A small notice about the necessary cookies with a link to the cookies section of the policy. |
302
304
 
303
305
  ### Shared interface
304
306
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionplace-svelte",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "SvelteKit 2 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",
@@ -45,6 +45,8 @@ The storefront uses native Svelte 5 components and SvelteKit server loads.
45
45
  | [Currency selector](../src/lib/components/orionplace/currencies/MainCurrencySwitcher.svelte) | Selects the current storefront currency from the available options. |
46
46
  | [Event block and promotion enrollment](../src/lib/components/orionplace/events/EventEnrollmentDialog.svelte) | Displaying the event block and opening promotion enrollment from the selected event. |
47
47
  | [Storefront events](../src/lib/components/orionplace/events/EventsBlock.svelte) | A full-width carousel of layout-event images for the current storefront language, with responsive image formats. |
48
+ | [Cookie notice](../src/lib/components/orionplace/legal/CookieNotice.svelte) | A small notice about the necessary cookies with a link to the cookies section of the policy. |
49
+ | [Privacy policy page](../src/lib/components/orionplace/legal/StorefrontPrivacy.svelte) | The store’s personal data policy with a cookies section and the operator’s details. |
48
50
  | [Language selector](../src/lib/components/orionplace/locale/MainLanguageSwitcher.svelte) | Selects the storefront language and its associated currency and measurement units. |
49
51
  | [Manufacturer index](../src/lib/components/orionplace/manufacturers/ManufacturerIndex.svelte) | Manufacturer logos and letter groups. A–Z, А–Я and 0–9 select the alphabet; choosing a name or letter passes the selection to the application. |
50
52
  | [Manufacturers and their products](../src/lib/components/orionplace/manufacturers/StorefrontManufacturers.svelte) | Alphabetical index, letter-based lists and manufacturer details. |
@@ -6,6 +6,8 @@ The article page shows one journal article: its cover, its text, the products at
6
6
 
7
7
  Article videos may come as the backend's own paths (`/ps3/...`). In an Android app build those paths (cover, videos, images and videos inside the text) load from the storefront address.
8
8
 
9
+ 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 `src/lib/utils/orionplace/blogImageViewer.ts`.
10
+
9
11
  Source: `src/lib/components/orionplace/blog/BlogArticle.svelte`.
10
12
 
11
13
  ## Inputs
@@ -2,6 +2,7 @@
2
2
  import { routeHref } from '@/lib/orionplace/navigation.svelte';
3
3
  import {
4
4
  computed,
5
+ nextTick,
5
6
  onMounted,
6
7
  ref,
7
8
  watch,
@@ -39,6 +40,16 @@
39
40
  import ProductGroup from '@/lib/components/orionplace/products/ProductGroup.svelte';
40
41
  import '@/lib/components/orionplace/shared/features.css';
41
42
  import OrionplaceEmptyState from '@/lib/components/orionplace/shared/OrionplaceEmptyState.svelte';
43
+ import ProductReviewImageViewer from '@/lib/components/orionplace/products/ProductReviewImageViewer.svelte';
44
+ import {
45
+ blogViewerImage,
46
+ blogViewerImages,
47
+ blogViewerIndex,
48
+ blogViewerPhotos,
49
+ isBlogViewerOpenKey,
50
+ markBlogViewerImages,
51
+ } from '@/lib/utils/orionplace/blogImageViewer';
52
+ import type { IBlogViewerImage } from '@/lib/utils/orionplace/blogImageViewer';
42
53
 
43
54
  type Props = {
44
55
  postId: number;
@@ -135,6 +146,10 @@
135
146
  const task = useOrionplaceTask();
136
147
  const post = ref<IBlogPost>();
137
148
  const article = ref<HTMLElement>();
149
+ const coverImage = ref<HTMLImageElement>();
150
+ // The cover and the text images open larger in the viewer.
151
+ const viewerImages = ref<IBlogViewerImage[]>([]);
152
+ const viewerStart = ref(-1);
138
153
  const accessChanged = ref(false);
139
154
  const coverFailed = ref(false);
140
155
  const productIds = ref<number[]>([]);
@@ -285,6 +300,51 @@
285
300
  wasShown.value = true;
286
301
  showProducts(inputs.initialPost.related_products);
287
302
  }
303
+ function openableImages() {
304
+ return blogViewerImages([coverImage.value, article.value]);
305
+ }
306
+
307
+ // The text is set with {@html}: its images become buttons after each render.
308
+ watch(
309
+ [
310
+ () => html.value,
311
+ () => cover.value,
312
+ () => coverFailed.value,
313
+ () => mounted.value,
314
+ () => labels.value.imageOpen,
315
+ () => post.value?.id,
316
+ ],
317
+ () => {
318
+ void nextTick(() => markBlogViewerImages(openableImages(), labels.value.imageOpen));
319
+ },
320
+ { immediate: true },
321
+ );
322
+
323
+ function openViewer(event: Event) {
324
+ const images = openableImages();
325
+ const index = blogViewerIndex(event.target, images);
326
+ if (index < 0) {
327
+ return;
328
+ }
329
+ event.preventDefault();
330
+ viewerImages.value = images.map((image) => {
331
+ const shown = blogViewerImage(image);
332
+ return image === coverImage.value ? { ...shown, alt: shown.alt || title.value } : shown;
333
+ });
334
+ viewerStart.value = index;
335
+ }
336
+
337
+ function onArticleKeydown(event: KeyboardEvent) {
338
+ if (isBlogViewerOpenKey(event)) {
339
+ openViewer(event);
340
+ }
341
+ }
342
+
343
+ // The viewer gives the focus back to the image that opened it.
344
+ function closeViewer() {
345
+ viewerStart.value = -1;
346
+ }
347
+
288
348
  onMounted(() => {
289
349
  mounted.value = true;
290
350
  if (!preloaded) {
@@ -337,7 +397,12 @@
337
397
  {/if}
338
398
 
339
399
  {#if post.value}
340
- <article class={['op-feature-stack mx-auto w-full max-w-[720px]']}>
400
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_click_events_have_key_events -->
401
+ <article
402
+ class={['op-feature-stack mx-auto w-full max-w-[720px]']}
403
+ onclick={openViewer}
404
+ onkeydown={onArticleKeydown}
405
+ >
341
406
  <header class={['op-feature-stack']}>
342
407
  <h1
343
408
  class={[
@@ -372,6 +437,7 @@
372
437
 
373
438
  {#if cover.value && !coverFailed.value}
374
439
  <img
440
+ bind:this={coverImage.value}
375
441
  src={cover.value}
376
442
  alt=""
377
443
  width="3"
@@ -575,6 +641,17 @@
575
641
  message={labels.value.unavailable}
576
642
  />
577
643
  {/if}
644
+ {#if viewerStart.value >= 0 && viewerImages.value.length}
645
+ <ProductReviewImageViewer
646
+ images={blogViewerPhotos(viewerImages.value)}
647
+ index={viewerStart.value}
648
+ label={labels.value.imageViewer}
649
+ alt={title.value || labels.value.imageViewer}
650
+ captions={viewerImages.value.map((image) => image.caption || image.alt)}
651
+ wide
652
+ onClose={closeViewer}
653
+ />
654
+ {/if}
578
655
  </section>
579
656
 
580
657
  <style>
@@ -787,6 +864,18 @@
787
864
  vertical-align: bottom;
788
865
  }
789
866
 
867
+ /* Images that open in the viewer. */
868
+ [data-blog-article-cover][data-blog-image-viewer],
869
+ .op-feature-prose :global(img[data-blog-image-viewer]) {
870
+ cursor: zoom-in;
871
+ }
872
+
873
+ [data-blog-article-cover][data-blog-image-viewer]:focus-visible,
874
+ .op-feature-prose :global(img[data-blog-image-viewer]:focus-visible) {
875
+ outline: 2px solid var(--orionplace-accent);
876
+ outline-offset: 3px;
877
+ }
878
+
790
879
  .op-feature-prose :global(:where(img, video)) {
791
880
  display: block;
792
881
  max-width: 100%;
@@ -0,0 +1,23 @@
1
+ # Cookie notice
2
+
3
+ A small notice about the necessary cookies with a link to the cookies section of the policy.
4
+
5
+ After mounting, the notice appears at the bottom of the screen until the visitor presses
6
+ OK; the choice is kept in local storage under `orionplace-cookie-notice-seen`. The
7
+ storefront sets only necessary cookies, so the notice asks for no consent. An ordinary
8
+ click on the link calls `onNavigate` with `href`; without it the link opens normally.
9
+ The supplied layout does not show the notice in the Android app build.
10
+
11
+ Source: `src/lib/components/orionplace/legal/CookieNotice.svelte`.
12
+
13
+ ## Inputs
14
+
15
+ | Input | Type | Default |
16
+ | --- | --- | --- |
17
+ | `href` | `string` | `'/privacy#cookies'` |
18
+
19
+ ## Callbacks
20
+
21
+ | Callback | Arguments |
22
+ | --- | --- |
23
+ | `onNavigate` | `[path: string]` |
@@ -0,0 +1,91 @@
1
+ <script lang="ts">
2
+ import { computed, onMounted, ref } from '@/lib/orionplace/reactivity.svelte';
3
+ import { useOrionplaceSelectionsStore } from '@/lib/stores/orionplaceSelections.svelte';
4
+ import {
5
+ COOKIE_NOTICE_STORAGE_KEY,
6
+ privacyLabels,
7
+ } from '@/lib/utils/orionplace/privacyLabels';
8
+ import '@/lib/components/orionplace/shared/features.css';
9
+
10
+ // The storefront sets only necessary cookies, so this is a notice, not a
11
+ // consent form. It appears after mounting and stays away once dismissed.
12
+ type Props = {
13
+ href?: string;
14
+ onNavigate?: (...args: [path: string]) => unknown;
15
+ [attribute: string]: any;
16
+ };
17
+ let {
18
+ href: input_href = '/privacy#cookies',
19
+ onNavigate: callback_onNavigate,
20
+ ...attributes
21
+ }: Props = $props();
22
+ const selectionStore = useOrionplaceSelectionsStore();
23
+ const labels = computed(() => privacyLabels(selectionStore.localeCode).cookie);
24
+ const visible = ref(false);
25
+
26
+ function dismiss() {
27
+ visible.value = false;
28
+ try {
29
+ localStorage.setItem(COOKIE_NOTICE_STORAGE_KEY, '1');
30
+ } catch {
31
+ // Without storage the notice comes back on the next visit.
32
+ }
33
+ }
34
+
35
+ function open(event: MouseEvent) {
36
+ if (
37
+ !callback_onNavigate ||
38
+ event.ctrlKey ||
39
+ event.metaKey ||
40
+ event.shiftKey ||
41
+ event.altKey ||
42
+ event.button !== 0
43
+ ) {
44
+ return;
45
+ }
46
+ event.preventDefault();
47
+ callback_onNavigate(input_href);
48
+ }
49
+
50
+ onMounted(() => {
51
+ try {
52
+ visible.value = localStorage.getItem(COOKIE_NOTICE_STORAGE_KEY) !== '1';
53
+ } catch {
54
+ visible.value = true;
55
+ }
56
+ });
57
+ </script>
58
+
59
+ {#if visible.value}
60
+ <div
61
+ {...attributes}
62
+ data-feature-part="CookieNotice"
63
+ role="region"
64
+ aria-label={labels.value.label}
65
+ class={[
66
+ `
67
+ op-feature fixed inset-x-3 bottom-3 z-40 flex flex-col items-stretch
68
+ gap-3 border border-solid border-orionplace-border bg-orionplace-surface
69
+ p-4 shadow-lg sm:left-auto sm:right-5 sm:bottom-5 sm:max-w-[560px]
70
+ sm:flex-row sm:items-center sm:gap-4
71
+ `,
72
+ attributes.class,
73
+ ]}
74
+ >
75
+ <p class={['op-feature-copy op-feature-muted']}>
76
+ {labels.value.text}
77
+ <a
78
+ href={input_href}
79
+ class={['whitespace-nowrap text-orionplace-text underline underline-offset-2']}
80
+ onclick={open}>{labels.value.link}</a
81
+ >
82
+ </p>
83
+ <button
84
+ type="button"
85
+ class={['op-feature-button op-feature-primary shrink-0']}
86
+ onclick={dismiss}
87
+ >
88
+ {labels.value.ok}
89
+ </button>
90
+ </div>
91
+ {/if}
@@ -0,0 +1,12 @@
1
+ # Privacy policy page
2
+
3
+ The store's personal data policy with a cookies section and the operator's details.
4
+
5
+ The page has no inputs and sends no requests. It lists which data the store receives,
6
+ why, who receives it, how long it is kept, the customer's rights and the cookies the
7
+ site sets; the cookies section has the anchor `#cookies`. The operator block shows the
8
+ company details from the Application Load contact details; empty values are left out.
9
+ The texts are in `src/lib/utils/orionplace/privacyLabels.ts`: a store that adds
10
+ analytics or other cookies lists them there.
11
+
12
+ Source: `src/lib/components/orionplace/legal/StorefrontPrivacy.svelte`.
@@ -0,0 +1,117 @@
1
+ <script lang="ts">
2
+ import { computed } from '@/lib/orionplace/reactivity.svelte';
3
+ import { useOrionplaceStore } from '@/lib/stores/orionplace.svelte';
4
+ import { useOrionplaceSelectionsStore } from '@/lib/stores/orionplaceSelections.svelte';
5
+ import { contactLabels } from '@/lib/utils/orionplace/contactLabels';
6
+ import { privacyLabels } from '@/lib/utils/orionplace/privacyLabels';
7
+ import '@/lib/components/orionplace/shared/features.css';
8
+
9
+ type Props = {
10
+ [attribute: string]: any;
11
+ };
12
+ let { ...attributes }: Props = $props();
13
+
14
+ const store = useOrionplaceStore();
15
+ const selectionStore = useOrionplaceSelectionsStore();
16
+ const contact = computed(() => store.init?.contact);
17
+ const company = computed(
18
+ () =>
19
+ contact.value?.company_legal_name?.trim() ||
20
+ contact.value?.company_name?.trim() ||
21
+ '',
22
+ );
23
+ const labels = computed(() =>
24
+ privacyLabels(selectionStore.localeCode, company.value),
25
+ );
26
+ const fields = computed(() => contactLabels(selectionStore.localeCode));
27
+ // The operator's details as the platform sends them; empty rows are left out.
28
+ const details = computed(() =>
29
+ [
30
+ [fields.value.legalName, company.value],
31
+ [fields.value.taxId, contact.value?.company_tax_id],
32
+ [fields.value.registrationNumber, contact.value?.company_organization_id],
33
+ [fields.value.address, contact.value?.company_contact_address],
34
+ [fields.value.phone, contact.value?.company_contact_phone],
35
+ ]
36
+ .map(([label, value]) => ({ label: label ?? '', value: value?.trim() ?? '' }))
37
+ .filter((row) => row.value),
38
+ );
39
+ </script>
40
+
41
+ <section
42
+ {...attributes}
43
+ data-feature-part="StorefrontPrivacy"
44
+ class={['op-feature grid w-full @container', attributes.class]}
45
+ >
46
+ <header
47
+ class={[
48
+ `
49
+ grid gap-3 border-0 border-b border-solid border-orionplace-border mb-8
50
+ pb-6 @min-[592px]:mb-12 @min-[592px]:pb-8
51
+ `,
52
+ ]}
53
+ >
54
+ <h1 class={['op-feature-title']}>{labels.value.title}</h1>
55
+ <p class={['op-feature-copy op-feature-muted max-w-2xl']}>{labels.value.lead}</p>
56
+ </header>
57
+
58
+ <div
59
+ class={[
60
+ `
61
+ grid items-start gap-10
62
+ @min-[960px]:grid-cols-[minmax(0,3fr)_minmax(0,9fr)] @min-[960px]:gap-16
63
+ `,
64
+ ]}
65
+ >
66
+ <nav
67
+ aria-label={labels.value.contents}
68
+ class={['grid gap-3 @min-[960px]:sticky @min-[960px]:top-6']}
69
+ >
70
+ <h2 class={['op-feature-subtitle op-feature-muted']}>{labels.value.contents}</h2>
71
+ <ol class={['m-0 grid list-none gap-2 p-0']}>
72
+ {#each labels.value.sections as section (section.id)}
73
+ <li>
74
+ <a
75
+ href={`#${section.id}`}
76
+ class={[
77
+ `
78
+ text-orionplace-text no-underline outline-none
79
+ hover:underline focus-visible:underline
80
+ `,
81
+ ]}>{section.title}</a
82
+ >
83
+ </li>
84
+ {/each}
85
+ </ol>
86
+ </nav>
87
+
88
+ <article class={['grid min-w-0 max-w-3xl gap-8']}>
89
+ {#each labels.value.sections as section (section.id)}
90
+ <section id={section.id} class={['grid scroll-mt-6 gap-3']}>
91
+ <h2 class={['op-feature-subtitle']}>{section.title}</h2>
92
+ {#each section.text as line, index (index)}
93
+ <p class={['op-feature-copy']}>{line}</p>
94
+ {/each}
95
+ </section>
96
+ {/each}
97
+
98
+ <section id="operator-details" class={['op-feature-panel grid gap-3']}>
99
+ <h2 class={['op-feature-subtitle']}>{labels.value.operator}</h2>
100
+ {#if details.value.length}
101
+ <dl
102
+ class={['m-0 grid gap-x-6 gap-y-2 @min-[592px]:grid-cols-[auto_1fr]']}
103
+ >
104
+ {#each details.value as row (row.label)}
105
+ <dt class={['op-feature-muted']}>{row.label}</dt>
106
+ <dd class={['m-0 break-words']}>{row.value}</dd>
107
+ {/each}
108
+ </dl>
109
+ {:else}
110
+ <p class={['op-feature-copy op-feature-muted']}>
111
+ {labels.value.operatorUnknown}
112
+ </p>
113
+ {/if}
114
+ </section>
115
+ </article>
116
+ </div>
117
+ </section>
@@ -4,7 +4,7 @@ The photos of one review, shown large on the same page.
4
4
 
5
5
  The viewer shows the photos of one review large, on the page the reader opened them from. It receives the photos of that review and the position of the chosen one, and sends no request of its own.
6
6
 
7
- It opens as a dialog over the page, so the browser keeps the focus inside it until it is closed. A sideways swipe on the large photo shows the next or previous one. `StorefrontProduct` opens the product's own photos here: `label` replaces the dialog name and `alt` the photo text.
7
+ It opens as a dialog over the page, so the browser keeps the focus inside it until it is closed. A sideways swipe on the large photo shows the next or previous one. `StorefrontProduct` opens the product's own photos here: `label` replaces the dialog name and `alt` the photo text. `BlogArticle` opens the images of an article with `captions`, a caption under each photo, and `wide`, which 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.
8
8
 
9
9
  Source: `src/lib/components/orionplace/products/ProductReviewImageViewer.svelte`.
10
10
 
@@ -16,6 +16,8 @@ Source: `src/lib/components/orionplace/products/ProductReviewImageViewer.svelte`
16
16
  | `index` | `number` | 0 |
17
17
  | `label` | `string` | «Review photos» |
18
18
  | `alt` | `string` | «Review photo» |
19
+ | `captions` | `string[]` | [] |
20
+ | `wide` | `boolean` | false |
19
21
 
20
22
  ## Callbacks
21
23
 
@@ -18,11 +18,15 @@
18
18
  import OrionplaceImage from '@/lib/components/orionplace/shared/OrionplaceImage.svelte';
19
19
 
20
20
  // `label` and `alt` name the product's own photos on the product page.
21
+ // `captions` are shown under each image (blog article images have them);
22
+ // `wide` lets screenshots and other detailed images use more of the screen.
21
23
  type Props = {
22
24
  images: IProductFeedbackImage[];
23
25
  index?: number;
24
26
  label?: string;
25
27
  alt?: string;
28
+ captions?: string[];
29
+ wide?: boolean;
26
30
  onChange?: (...args: [photo: { id: number; index: number }]) => unknown;
27
31
  onClose?: (...args: []) => unknown;
28
32
  [attribute: string]: any;
@@ -32,6 +36,8 @@
32
36
  index: input_index = 0,
33
37
  label: input_label,
34
38
  alt: input_alt,
39
+ captions: input_captions = [],
40
+ wide: input_wide = false,
35
41
  onChange: callback_onChange,
36
42
  onClose: callback_onClose,
37
43
  ...attributes
@@ -49,6 +55,12 @@
49
55
  get alt() {
50
56
  return input_alt;
51
57
  },
58
+ get captions() {
59
+ return input_captions;
60
+ },
61
+ get wide() {
62
+ return input_wide;
63
+ },
52
64
  };
53
65
  const emit = (event: string, ...args: any[]) => {
54
66
  const handlers: Record<string, ((...args: any[]) => unknown) | undefined> =
@@ -69,6 +81,13 @@
69
81
  const active = ref(startIndex());
70
82
  const current = computed(() => inputs.images[active.value]);
71
83
  const several = computed(() => inputs.images.length > 1);
84
+ const caption = computed(() => inputs.captions[active.value]?.trim() ?? '');
85
+ // A wide viewer can show the image at its own size; the stage then scrolls.
86
+ const zoomed = ref(false);
87
+
88
+ function toggleZoom() {
89
+ zoomed.value = inputs.wide && !zoomed.value;
90
+ }
72
91
  // The element that opened the viewer receives the focus back on close.
73
92
  let opener: HTMLElement | undefined;
74
93
 
@@ -119,7 +138,7 @@
119
138
  const start = swipeStart;
120
139
 
121
140
  swipeStart = undefined;
122
- if (!start || !touch || !several.value) {
141
+ if (!start || !touch || !several.value || zoomed.value) {
123
142
  return;
124
143
  }
125
144
  select(
@@ -181,6 +200,14 @@
181
200
  },
182
201
  );
183
202
 
203
+ // Another photo opens fitted to the screen.
204
+ watch(
205
+ () => active.value,
206
+ () => {
207
+ zoomed.value = false;
208
+ },
209
+ );
210
+
184
211
  onMounted(() => {
185
212
  if (typeof document === 'undefined') {
186
213
  return;
@@ -216,16 +243,17 @@
216
243
  class={[
217
244
  `
218
245
  m-auto box-border h-[calc(100dvh-32px)] max-h-none w-[calc(100vw-32px)]
219
- max-w-[960px] overflow-hidden rounded-none p-0 border border-solid
246
+ overflow-hidden rounded-none p-0 border border-solid
220
247
  border-orionplace-border bg-orionplace-surface font-orionplace
221
248
  text-orionplace-text backdrop:bg-black/35 backdrop:backdrop-blur-[2px]
222
249
  `,
250
+ inputs.wide ? 'max-w-[1600px]' : 'max-w-[960px]',
223
251
  attributes.class,
224
252
  ]}
225
253
  >
226
254
  <div
227
255
  class={[
228
- '@container/review-image-viewer grid h-full grid-rows-[auto_minmax(0,1fr)_auto]',
256
+ '@container/review-image-viewer grid h-full grid-rows-[auto_minmax(0,1fr)_auto_auto]',
229
257
  ]}
230
258
  >
231
259
  <header
@@ -245,6 +273,42 @@
245
273
  >
246
274
  {active.value + 1} / {inputs.images.length}
247
275
  </p>
276
+ {#if inputs.wide}
277
+ <button
278
+ type="button"
279
+ aria-label={zoomed.value ? labels.value.zoomOut : labels.value.zoomIn}
280
+ aria-pressed={zoomed.value}
281
+ data-review-image-zoom
282
+ onclick={toggleZoom}
283
+ class={[
284
+ `
285
+ ml-auto grid h-10 w-10 shrink-0 place-items-center cursor-pointer border-0
286
+ bg-transparent p-0 text-orionplace-text outline-none
287
+ transition-colors motion-reduce:transition-none
288
+ hover:bg-orionplace-hover focus-visible:bg-orionplace-hover
289
+ `,
290
+ ]}
291
+ >
292
+ <svg
293
+ viewBox="0 0 24 24"
294
+ class={['h-5 w-5']}
295
+ fill="none"
296
+ stroke="currentColor"
297
+ stroke-width="1.5"
298
+ aria-hidden="true"
299
+ >
300
+ <circle
301
+ cx="10.5"
302
+ cy="10.5"
303
+ r="6.5"
304
+ ></circle>
305
+ <path d="m15.5 15.5 5 5M7.5 10.5h6"></path>
306
+ {#if !zoomed.value}
307
+ <path d="M10.5 7.5v6"></path>
308
+ {/if}
309
+ </svg>
310
+ </button>
311
+ {/if}
248
312
  <button
249
313
  type="button"
250
314
  aria-label={common.value.close}
@@ -269,20 +333,32 @@
269
333
  relative flex min-h-0 items-center justify-center p-4
270
334
  @min-[606px]/review-image-viewer:px-20 select-none
271
335
  `,
272
- { 'touch-pan-y': several.value },
336
+ { 'touch-pan-y': several.value && !zoomed.value },
273
337
  ]}
274
338
  data-review-image-stage
275
339
  ontouchstart={startSwipe}
276
340
  ontouchend={endSwipe}
277
341
  >
342
+ <!-- At full size the image scrolls inside the stage; the arrows stay in place. -->
278
343
  {#if current.value}
279
- {#key current.value.id}
280
- <OrionplaceImage
281
- src={current.value.image}
282
- alt={photoLabel(active.value)}
283
- class={['block h-full max-h-full w-full object-contain']}
284
- />
285
- {/key}
344
+ <div
345
+ class={[zoomed.value ? 'absolute inset-0 overflow-auto' : 'contents']}
346
+ data-review-image-zoomed={zoomed.value || undefined}
347
+ >
348
+ {#key current.value.id}
349
+ <OrionplaceImage
350
+ src={current.value.image}
351
+ alt={photoLabel(active.value)}
352
+ onclick={toggleZoom}
353
+ class={[
354
+ zoomed.value
355
+ ? 'm-auto block h-auto w-auto max-h-none! max-w-none!'
356
+ : 'block h-full max-h-full w-full object-contain',
357
+ inputs.wide && (zoomed.value ? 'cursor-zoom-out' : 'cursor-zoom-in'),
358
+ ]}
359
+ />
360
+ {/key}
361
+ </div>
286
362
  {/if}
287
363
 
288
364
  {#if several.value}
@@ -329,6 +405,15 @@
329
405
  {/if}
330
406
  </div>
331
407
 
408
+ {#if caption.value}
409
+ <p
410
+ class={['m-0 px-4 pt-1 pb-3 text-center text-sm leading-6 text-orionplace-muted']}
411
+ data-review-image-caption
412
+ >
413
+ {caption.value}
414
+ </p>
415
+ {/if}
416
+
332
417
  {#if several.value}
333
418
  <ol
334
419
  class={[
@@ -240,6 +240,18 @@
240
240
  >
241
241
  {copy.value.contact}
242
242
  </a>
243
+ <a
244
+ href={route('/privacy')}
245
+ class={[
246
+ `
247
+ w-fit text-sm leading-6 text-orionplace-inverse-text no-underline
248
+ outline-none hover:text-orionplace-inverse-muted
249
+ focus-visible:text-orionplace-inverse-muted
250
+ `,
251
+ ]}
252
+ >
253
+ {copy.value.privacy}
254
+ </a>
243
255
  </nav>
244
256
  </div>
245
257
  <p
@@ -17,6 +17,8 @@
17
17
  import { productActionLabels } from '@/lib/utils/orionplace/productActionLabels';
18
18
  import { headerLabels } from '@/lib/utils/orionplace/headerLabels';
19
19
  import StorefrontFooter from '@/lib/components/orionplace/storefront/StorefrontFooter.svelte';
20
+ import CookieNotice from '@/lib/components/orionplace/legal/CookieNotice.svelte';
21
+ import { APP_ORIGIN } from '@/lib/utils/orionplace/nativeApp';
20
22
  import StorefrontHeader from '@/lib/components/orionplace/storefront/StorefrontHeader.svelte';
21
23
  import '@/lib/components/orionplace/shared/features.css';
22
24
  import { useOrionplaceCartPreview } from '@/lib/composables/useOrionplaceCartPreview.svelte';
@@ -315,6 +317,15 @@
315
317
  navigateTo($event);
316
318
  }}
317
319
  />
320
+ <!-- The notice is for the website; the Android app links its policy from the store listing. -->
321
+ {#if !APP_ORIGIN}
322
+ <CookieNotice
323
+ href={`/privacy?locale=${encodeURIComponent(store.localeCode)}#cookies`}
324
+ onNavigate={($event) => {
325
+ navigateTo($event);
326
+ }}
327
+ />
328
+ {/if}
318
329
  </div>
319
330
 
320
331
  <style>
@@ -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: 'Показать следующие миниатюры',
@@ -0,0 +1,7 @@
1
+ import { loadStorefrontPage } from '@/lib/orionplace/pageServer';
2
+ import type { PageServerLoad } from './$types';
3
+
4
+ export const load: PageServerLoad = async (event) => {
5
+ const { orionplace } = await event.parent();
6
+ return loadStorefrontPage(event, orionplace);
7
+ };
@@ -0,0 +1,23 @@
1
+ <script lang="ts">
2
+ import { useOrionplaceStore } from '@/lib/stores/orionplace.svelte';
3
+ import StorefrontPrivacy from '@/lib/components/orionplace/legal/StorefrontPrivacy.svelte';
4
+ import { useOrionplacePageSeo } from '@/lib/composables/useOrionplacePageSeo.svelte';
5
+
6
+ type Props = {
7
+ [attribute: string]: any;
8
+ };
9
+ let { ...attributes }: Props = $props();
10
+
11
+ const store = useOrionplaceStore();
12
+ useOrionplacePageSeo('privacy', () => store.localeCode);
13
+ </script>
14
+
15
+ <main
16
+ {...attributes}
17
+ class={[
18
+ 'mx-auto max-w-[1440px] px-4 py-8 sm:px-6 sm:py-10 lg:px-8',
19
+ attributes.class,
20
+ ]}
21
+ >
22
+ <StorefrontPrivacy />
23
+ </main>