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 +3 -1
- package/package.json +1 -1
- package/templates/orionplace/COMPONENTS.md +2 -0
- package/templates/src/lib/components/orionplace/blog/BlogArticle.README.md +2 -0
- package/templates/src/lib/components/orionplace/blog/BlogArticle.svelte +90 -1
- package/templates/src/lib/components/orionplace/legal/CookieNotice.README.md +23 -0
- package/templates/src/lib/components/orionplace/legal/CookieNotice.svelte +91 -0
- package/templates/src/lib/components/orionplace/legal/StorefrontPrivacy.README.md +12 -0
- package/templates/src/lib/components/orionplace/legal/StorefrontPrivacy.svelte +117 -0
- package/templates/src/lib/components/orionplace/products/ProductReviewImageViewer.README.md +3 -1
- package/templates/src/lib/components/orionplace/products/ProductReviewImageViewer.svelte +96 -11
- package/templates/src/lib/components/orionplace/storefront/StorefrontFooter.svelte +12 -0
- package/templates/src/lib/layouts/StorefrontLayout.svelte +11 -0
- package/templates/src/lib/utils/orionplace/blogImageViewer.ts +84 -0
- package/templates/src/lib/utils/orionplace/blogLabels.ts +4 -0
- package/templates/src/lib/utils/orionplace/footerLabels.ts +2 -0
- package/templates/src/lib/utils/orionplace/pageSeo.ts +10 -0
- package/templates/src/lib/utils/orionplace/privacyLabels.ts +198 -0
- package/templates/src/lib/utils/orionplace/reviewLabels.ts +4 -0
- package/templates/src/routes/privacy/+page.server.ts +7 -0
- package/templates/src/routes/privacy/+page.svelte +23 -0
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 **
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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>
|