orionplace-react 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +13 -2
  2. package/bin/orionplace-react.mjs +7 -0
  3. package/package.json +1 -1
  4. package/runtime/marketplace.d.mts +85 -0
  5. package/runtime/marketplace.mjs +490 -3
  6. package/src/project-readme.mjs +17 -2
  7. package/src/setup.mjs +1 -0
  8. package/src/sitemap.mjs +93 -0
  9. package/templates/app/StorefrontShell.tsx +8 -0
  10. package/templates/app/_storefront/PrivacyPage.tsx +15 -0
  11. package/templates/app/_storefront/storefrontRoutes.tsx +3 -0
  12. package/templates/app/privacy/page.tsx +9 -0
  13. package/templates/app/robots.txt/route.ts +11 -0
  14. package/templates/app/sitemap.xml/route.ts +12 -0
  15. package/templates/components/orionplace/account/ProfileDetailsForm.README.md +4 -0
  16. package/templates/components/orionplace/account/ProfileDetailsForm.tsx +7 -2
  17. package/templates/components/orionplace/blog/BlogArticle.README.md +16 -0
  18. package/templates/components/orionplace/blog/BlogArticle.module.css +5 -0
  19. package/templates/components/orionplace/blog/BlogArticle.tsx +60 -1
  20. package/templates/components/orionplace/cart/CartCheckoutDetails.README.md +4 -0
  21. package/templates/components/orionplace/cart/CartCheckoutDetails.tsx +20 -9
  22. package/templates/components/orionplace/legal/CookieNotice.README.md +42 -0
  23. package/templates/components/orionplace/legal/CookieNotice.module.css +48 -0
  24. package/templates/components/orionplace/legal/CookieNotice.tsx +82 -0
  25. package/templates/components/orionplace/legal/StorefrontPrivacy.README.md +49 -0
  26. package/templates/components/orionplace/legal/StorefrontPrivacy.module.css +166 -0
  27. package/templates/components/orionplace/legal/StorefrontPrivacy.tsx +83 -0
  28. package/templates/components/orionplace/products/ProductReviewImageViewer.README.md +13 -2
  29. package/templates/components/orionplace/products/ProductReviewImageViewer.module.css +10 -1
  30. package/templates/components/orionplace/products/ProductReviewImageViewer.tsx +61 -9
  31. package/templates/components/orionplace/storefront/StorefrontFooter.tsx +3 -0
  32. package/templates/interfaces/orionplace/accounts/IProfileData.ts +2 -0
  33. package/templates/interfaces/orionplace/appBase/IApploadUserProfile.ts +2 -0
  34. package/templates/lib/orionplace/blogImageViewer.ts +84 -0
  35. package/templates/lib/orionplace/blogLabels.ts +4 -0
  36. package/templates/lib/orionplace/cartLabels.ts +2 -0
  37. package/templates/lib/orionplace/footerLabels.ts +2 -0
  38. package/templates/lib/orionplace/pageMetadata.ts +1 -0
  39. package/templates/lib/orionplace/pageSeo.ts +10 -0
  40. package/templates/lib/orionplace/privacyLabels.ts +198 -0
  41. package/templates/lib/orionplace/profile.ts +12 -0
  42. package/templates/lib/orionplace/profileLabels.ts +2 -0
  43. package/templates/lib/orionplace/reviewLabels.ts +4 -0
  44. package/templates/proxy.ts +3 -2
@@ -89,6 +89,7 @@ and Next.js configuration are preserved.
89
89
  | \`../${base}app/_storefront/\` | Route controllers that connect components, navigation and API actions. |
90
90
  | \`../${base}app/api/[...orionplace]/route.ts\` | The route handler serving \`/api/...\` from your project. |
91
91
  | \`../${base}app/ps3/blog/[...orionplace]/route.ts\` | The route handler serving guarded blog images and videos under \`/ps3/blog/...\`. |
92
+ | \`../${base}app/robots.txt/route.ts\`, \`../${base}app/sitemap.xml/route.ts\` | \`/robots.txt\` and the storefront sitemap. |
92
93
  | \`../${base}proxy.ts\` | Marks personalized pages private and answers HTTP 503 when Application Load fails. |
93
94
  | \`../sitemaps/${frontend}.cfg\` | The backend URL used for this frontend domain. |
94
95
  | \`../.env\` | Local MIMIC setting; keep secrets out of version control. |
@@ -389,8 +390,22 @@ location / {
389
390
 
390
391
  Here localhost is your frontend process on your VPS. That process selects the
391
392
  remote personal API from the domain file. The proxy must overwrite those headers.
392
- An unknown domain never falls back to another tenant. This setup does not generate
393
- sitemap XML or add analytics.
393
+ An unknown domain never falls back to another tenant. \`/robots.txt\` points
394
+ crawlers to \`/sitemap.xml\`, which lists the public pages, categories, products
395
+ on sale and journal publications in every storefront language (each publication
396
+ only in the languages it is written in). The sitemap is built from the backend
397
+ and kept for an hour in \`node_modules/.cache/orionplace/sitemaps/\`
398
+ (\`ORIONPLACE_CACHE_DIR\` moves it). After you publish or change content, rebuild
399
+ it at once from the project folder; the running storefront serves the new
400
+ sitemap without a restart:
401
+
402
+ \`\`\`bash
403
+ npm run orionplace:sitemap # every frontend domain
404
+ npx orionplace-react sitemap rebuild shop.example.com # one domain
405
+ npx orionplace-react sitemap status # when each was built
406
+ \`\`\`
407
+
408
+ This setup does not add analytics.
394
409
 
395
410
  ## Android app
396
411
 
package/src/setup.mjs CHANGED
@@ -187,6 +187,7 @@ export async function setupProject({ cwd = process.cwd(), action = 'set', field,
187
187
  pkg.scripts['orionplace:remove-frontend'] ||= 'orionplace-react remove frontend';
188
188
  pkg.scripts['orionplace:mimic'] ||= 'orionplace-react set mimic';
189
189
  pkg.scripts['orionplace:help'] ||= 'orionplace-react help';
190
+ pkg.scripts['orionplace:sitemap'] ||= 'orionplace-react sitemap rebuild';
190
191
  pkg.scripts['orionplace:update'] ||= 'orionplace-react update';
191
192
  pkg.dependencies ||= {};
192
193
  pkg.dependencies.zustand ||= dependencies.zustand || '^5.0.15';
@@ -0,0 +1,93 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { relative, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ const metadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
7
+ const cli = metadata.name;
8
+
9
+ /**
10
+ * The project's own gateway runtime (orionplace/runtime/marketplace.mjs): the
11
+ * sitemap is built with the code the running storefront uses.
12
+ */
13
+ async function projectRuntime(root) {
14
+ const path = resolve(root, 'orionplace/runtime/marketplace.mjs');
15
+ if (!existsSync(path)) {
16
+ throw new Error(`No orionplace/runtime/marketplace.mjs in ${root}. Run this in the storefront project (or pass --cwd).`);
17
+ }
18
+ const runtime = await import(pathToFileURL(path).href);
19
+ if (typeof runtime.rebuildMarketplaceSitemap !== 'function') {
20
+ throw new Error(`This project's runtime has no sitemap support. Run npx ${cli} update first.`);
21
+ }
22
+ return runtime;
23
+ }
24
+
25
+ /**
26
+ * The storefront reads SITEMAPS_DIR and ORIONPLACE_CACHE_DIR from the
27
+ * project's .env; the command uses the same folders. Variables already set
28
+ * in the shell win.
29
+ */
30
+ async function projectEnvironment(root) {
31
+ let source = '';
32
+ try { source = await readFile(resolve(root, '.env'), 'utf8'); }
33
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
34
+ for (const line of source.split(/\r?\n/)) {
35
+ const match = /^\s*(?:export\s+)?(SITEMAPS_DIR|ORIONPLACE_CACHE_DIR)\s*=\s*(.*?)\s*$/.exec(line);
36
+ if (!match || process.env[match[1]] !== undefined) continue;
37
+ const value = match[2].replace(/^(['"])(.*)\1$/, '$2');
38
+ if (value) process.env[match[1]] = resolve(root, value);
39
+ }
40
+ }
41
+
42
+ async function domainsFor(runtime, root, domain) {
43
+ if (domain) {
44
+ const normalized = runtime.normalizeFrontDomain(domain);
45
+ if (!normalized) throw new Error(`Invalid frontend domain: ${domain}`);
46
+ return [normalized];
47
+ }
48
+ const domains = await runtime.marketplaceDomains(root);
49
+ if (!domains.length) throw new Error(`No frontend domains yet. Add one: npx ${cli} add frontend <shop.example.com>`);
50
+ return domains;
51
+ }
52
+
53
+ function age(built, now = Date.now()) {
54
+ const minutes = Math.max(0, Math.round((now - Date.parse(built)) / 60_000));
55
+ return minutes < 60 ? `${minutes} min ago` : `${Math.floor(minutes / 60)} h ${minutes % 60} min ago`;
56
+ }
57
+
58
+ /**
59
+ * `sitemap rebuild [domain]`: build the sitemap of every frontend domain (or
60
+ * one) now. `sitemap status [domain]`: show when each was built.
61
+ * @param {{cwd: string, action: string, domain?: string, log?: (line: string) => void}} options
62
+ * @returns {Promise<number>} exit code
63
+ */
64
+ export async function sitemapCommand({ cwd, action, domain, log = console.log }) {
65
+ if (!['rebuild', 'status'].includes(action)) {
66
+ throw new Error(`Use: npx ${cli} sitemap rebuild [domain] | sitemap status [domain]`);
67
+ }
68
+ const root = resolve(cwd);
69
+ await projectEnvironment(root);
70
+ const runtime = await projectRuntime(root);
71
+ const domains = await domainsFor(runtime, root, domain);
72
+ let failed = 0;
73
+ for (const name of domains) {
74
+ if (action === 'status') {
75
+ const sitemap = await runtime.readSitemapCache(name, root);
76
+ const stale = sitemap && Date.now() - Date.parse(sitemap.built) >= runtime.SITEMAP_LIFETIME;
77
+ log(sitemap
78
+ ? `${name}: built ${sitemap.built} (${age(sitemap.built)}), ${runtime.sitemapUrlCount(sitemap)} URLs${stale ? '; rebuilt at the next crawl' : ''}`
79
+ : `${name}: not built yet; built at the first /sitemap.xml request or with: npx ${cli} sitemap rebuild`);
80
+ continue;
81
+ }
82
+ const started = Date.now();
83
+ try {
84
+ const done = await runtime.rebuildMarketplaceSitemap(name, { root });
85
+ log(`${name}: ${done.urls} URLs (${done.pages} pages, ${done.publications} journal publications) in ${((Date.now() - started) / 1000).toFixed(1)} s`);
86
+ log(` saved ${relative(root, done.path)}; the running storefront serves it now at https://${name}/sitemap.xml`);
87
+ } catch (error) {
88
+ failed++;
89
+ log(`${name}: not rebuilt: ${error?.message ?? error}`);
90
+ }
91
+ }
92
+ return failed ? 1 : 0;
93
+ }
@@ -20,6 +20,7 @@ import {
20
20
  import { OrionplaceThemeProvider } from '@/components/orionplace/theme/OrionplaceThemeProvider';
21
21
  import { StorefrontHeader } from '@/components/orionplace/storefront/StorefrontHeader';
22
22
  import { StorefrontFooter } from '@/components/orionplace/storefront/StorefrontFooter';
23
+ import { CookieNotice } from '@/components/orionplace/legal/CookieNotice';
23
24
  import { CartPreview } from '@/components/orionplace/cart/CartPreview';
24
25
  import { useOrionplaceApi } from '@/hooks/useOrionplaceApi';
25
26
  import { useOrionplaceAppLoad } from '@/hooks/useOrionplaceAppLoad';
@@ -354,6 +355,13 @@ function ShellContent({ children }: { children: ReactNode }) {
354
355
  />
355
356
  <div className={styles.content}>{children}</div>
356
357
  <StorefrontFooter onNavigate={route} />
358
+ {/* The notice is for the website; the Android app links its policy from the store listing. */}
359
+ {APP_ORIGIN ? null : (
360
+ <CookieNotice
361
+ href={`${localized('/privacy')}#cookies`}
362
+ onNavigate={route}
363
+ />
364
+ )}
357
365
  </div>
358
366
  );
359
367
  }
@@ -0,0 +1,15 @@
1
+ 'use client';
2
+
3
+ import { StorefrontPrivacy } from '@/components/orionplace/legal/StorefrontPrivacy';
4
+ import styles from '@/app/storefront.module.css';
5
+
6
+ export function PrivacyPage() {
7
+ return (
8
+ <main
9
+ className={styles.page}
10
+ data-storefront-page="privacy"
11
+ >
12
+ <StorefrontPrivacy />
13
+ </main>
14
+ );
15
+ }
@@ -13,6 +13,7 @@ import { ManufacturersPage } from '@/app/_storefront/ManufacturersPage';
13
13
  import { OrdersPage } from '@/app/_storefront/OrdersPage';
14
14
  import { ProductPage } from '@/app/_storefront/ProductPage';
15
15
  import { ProfilePage } from '@/app/_storefront/ProfilePage';
16
+ import { PrivacyPage } from '@/app/_storefront/PrivacyPage';
16
17
  import { PromocodesPage } from '@/app/_storefront/PromocodesPage';
17
18
  import { PromoPage } from '@/app/_storefront/PromoPage';
18
19
  import { SearchPage } from '@/app/_storefront/SearchPage';
@@ -451,6 +452,8 @@ const routes: Record<string, Loader> = {
451
452
  };
452
453
  },
453
454
 
455
+ '/privacy': async () => ({ page: <PrivacyPage /> }),
456
+
454
457
  '/profile': async () => ({ page: <ProfilePage /> }),
455
458
 
456
459
  '/promo/[id]': async (context) => {
@@ -0,0 +1,9 @@
1
+ import {
2
+ renderStorefrontRoute,
3
+ type StorefrontPageProps,
4
+ } from '@/lib/orionplace/serverRoute';
5
+
6
+ // The page and its first data: app/_storefront/storefrontRoutes.tsx.
7
+ export default function Page(props: StorefrontPageProps) {
8
+ return renderStorefrontRoute('/privacy', props);
9
+ }
@@ -0,0 +1,11 @@
1
+ // robots.txt of the storefront domain; it points crawlers to the sitemap.
2
+ import { marketplaceRobotsResponse } from '@orionplace-runtime/marketplace.mjs';
3
+
4
+ export const runtime = 'nodejs';
5
+ export const dynamic = 'force-dynamic';
6
+
7
+ export function GET(request: Request): Promise<Response> {
8
+ return marketplaceRobotsResponse(request, {
9
+ mimicFrontDomain: process.env.MIMIC_FRONT_DOMAIN ?? '',
10
+ });
11
+ }
@@ -0,0 +1,12 @@
1
+ // The storefront sitemap: public pages, categories, products and journal
2
+ // publications in every language, built from the backend for an hour.
3
+ import { marketplaceSitemapResponse } from '@orionplace-runtime/marketplace.mjs';
4
+
5
+ export const runtime = 'nodejs';
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ export function GET(request: Request): Promise<Response> {
9
+ return marketplaceSitemapResponse(request, {
10
+ mimicFrontDomain: process.env.MIMIC_FRONT_DOMAIN ?? '',
11
+ });
12
+ }
@@ -7,6 +7,8 @@ date of birth. Application Load supplies the initial values. Dates in
7
7
  `day.month.year` or `day/month/year` form are converted to the ISO value required
8
8
  by the native date field and the request. The sign-in phone number is read-only.
9
9
 
10
+ Under the phone the hint names its country: `user.origin_country` from the application load, shown with the browser's country name («Its country, Russia, decides which payment methods you can use»). Without a country the plain hint stays.
11
+
10
12
  Submit is enabled only after an editable value changes and `state.loaded` is
11
13
  true with no pending request. Submission trims the two names and calls
12
14
  `onRequest` with `{ action: 'details', data: { first_name, last_name, dob } }`.
@@ -28,6 +30,8 @@ the latest Application Load profile values. Field messages come from
28
30
  `день.месяц.год` или `день/месяц/год` преобразуется в ISO-значение для системного
29
31
  поля даты и запроса. Номер телефона для входа доступен только для чтения.
30
32
 
33
+ Под телефоном подсказка называет его страну: `user.origin_country` из загрузки приложения с названием страны от браузера («Его страна, Россия, определяет доступные способы оплаты»). Без страны остаётся обычная подсказка.
34
+
31
35
  Кнопка сохранения становится доступной только после изменения редактируемого
32
36
  значения, когда `state.loaded` равен `true` и нет выполняемого запроса. При
33
37
  отправке внешние пробелы в имени и фамилии удаляются, затем `onRequest` получает
@@ -11,7 +11,7 @@ import type {
11
11
  IStorefrontProfileState,
12
12
  } from '@/interfaces/orionplace/accounts/IStorefrontProfile';
13
13
  import { profileLabels } from '@/lib/orionplace/profileLabels';
14
- import { profileDate } from '@/lib/orionplace/profile';
14
+ import { profileCountryName, profileDate } from '@/lib/orionplace/profile';
15
15
  import styles from './ProfileDetailsForm.module.css';
16
16
 
17
17
  export interface ProfileDetailsFormProps {
@@ -26,6 +26,11 @@ export function ProfileDetailsForm({
26
26
  const profile = useOrionplace(selectProfile);
27
27
  const locale = useOrionplaceSelections((value) => value.localeCode);
28
28
  const labels = profileLabels(locale);
29
+ // The phone country decides which payment methods the customer gets at checkout.
30
+ const country = profileCountryName(profile?.origin_country, locale);
31
+ const phoneHint = country
32
+ ? labels.phoneCountryHint.replace('{country}', country)
33
+ : labels.phoneHint;
29
34
  const busy = Boolean(state.pending) || !state.loaded;
30
35
  const synchronized = {
31
36
  first_name: profile?.first_name ?? '',
@@ -148,7 +153,7 @@ export function ProfileDetailsForm({
148
153
  autoComplete="tel"
149
154
  readOnly
150
155
  />
151
- <small>{labels.phoneHint}</small>
156
+ <small>{phoneHint}</small>
152
157
  </label>
153
158
  <label>
154
159
  <span>{labels.dob}</span>
@@ -21,6 +21,14 @@ the image fails. Article videos accept HTTP or HTTPS addresses and the
21
21
  backend's `/ps3/...` paths. In an Android app build those paths (cover, videos,
22
22
  images and videos inside the text) load from the storefront address.
23
23
 
24
+ The cover and the images of the text open larger: a click, Enter or Space on
25
+ one opens `ProductReviewImageViewer` at that image, with the article's other
26
+ images a step away and each image's caption (its `figcaption`, else its
27
+ `title`, else its `alt`) under it. The images get `role="button"`, a keyboard
28
+ stop and an «Open image» name; a zoom-in cursor marks them. Images inside a
29
+ link, a button or a product placement keep their own action. The helper is
30
+ `lib/orionplace/blogImageViewer.ts`.
31
+
24
32
  During server rendering, editor HTML is converted to escaped text paragraphs.
25
33
  After hydration, DOMPurify replaces those paragraphs with sanitized formatting.
26
34
  The allowlist retains headings, text formatting, links, lists, quotes, code,
@@ -91,6 +99,14 @@ ID тарифов, ссылка также передаёт их в `ids`. Дл
91
99
  `preview_image_urls`. Область сохраняет пропорции 3:2. После ошибки загрузки
92
100
  обложка исчезает. Компонент выводит только видео с адресами HTTP или HTTPS.
93
101
 
102
+ Обложка и изображения текста открываются крупно: нажатие, Enter или пробел на
103
+ изображении открывает `ProductReviewImageViewer` на нём, остальные изображения
104
+ статьи — в одном шаге, а под каждым — его подпись (`figcaption`, иначе `title`,
105
+ иначе `alt`). Изображения получают `role="button"`, остановку клавиатуры и
106
+ название «Открыть изображение»; их отмечает курсор с лупой. Изображения внутри
107
+ ссылки, кнопки или размещения товара сохраняют своё действие. Вспомогательный
108
+ модуль — `lib/orionplace/blogImageViewer.ts`.
109
+
94
110
  При серверном рендеринге HTML редактора превращается в экранированные
95
111
  текстовые абзацы. После гидратации DOMPurify заменяет их очищенным
96
112
  форматированным HTML. Разрешены заголовки, оформление текста, ссылки, списки,
@@ -302,3 +302,8 @@
302
302
  @media (prefers-reduced-motion: reduce) {
303
303
  .skeleton { animation: none; }
304
304
  }
305
+ /* Images that open in the viewer. */
306
+ .cover[data-blog-image-viewer],
307
+ .prose img[data-blog-image-viewer] { cursor: zoom-in; }
308
+ .cover[data-blog-image-viewer]:focus-visible,
309
+ .prose img[data-blog-image-viewer]:focus-visible { outline: 2px solid var(--orionplace-accent); outline-offset: 3px; }
@@ -6,6 +6,7 @@ import {
6
6
  useMemo,
7
7
  useRef,
8
8
  useState,
9
+ type SyntheticEvent,
9
10
  } from 'react';
10
11
  import {
11
12
  useOrionplace,
@@ -41,6 +42,16 @@ import {
41
42
  BlogComments,
42
43
  type BlogCommentsProps,
43
44
  } from './BlogComments';
45
+ import {
46
+ blogViewerImage,
47
+ blogViewerImages,
48
+ blogViewerIndex,
49
+ blogViewerPhotos,
50
+ isBlogViewerOpenKey,
51
+ markBlogViewerImages,
52
+ type IBlogViewerImage,
53
+ } from '@/lib/orionplace/blogImageViewer';
54
+ import { ProductReviewImageViewer } from '@/components/orionplace/products/ProductReviewImageViewer';
44
55
  import styles from './BlogArticle.module.css';
45
56
 
46
57
  export interface BlogArticleProps extends ProductGroupEvents {
@@ -92,6 +103,9 @@ export function BlogArticle({
92
103
  const [mounted, setMounted] = useState(false);
93
104
  const [coverFailed, setCoverFailed] = useState(false);
94
105
  const articleRef = useRef<HTMLDivElement>(null);
106
+ const coverRef = useRef<HTMLImageElement>(null);
107
+ // The cover and the text images open larger in the viewer.
108
+ const [viewer, setViewer] = useState<{ images: IBlogViewerImage[]; index: number }>();
95
109
  const requestRef = useRef<AbortController | undefined>(undefined);
96
110
  const previousAuthentication = useRef(authenticated);
97
111
  const wasShown = useRef(Boolean(preloaded));
@@ -177,6 +191,30 @@ export function BlogArticle({
177
191
  setCoverFailed(false);
178
192
  }, [cover]);
179
193
 
194
+ // The text is set as HTML: its images become buttons after each render.
195
+ useEffect(() => {
196
+ markBlogViewerImages(
197
+ blogViewerImages([coverRef.current, articleRef.current]),
198
+ labels.imageOpen,
199
+ );
200
+ }, [html, cover, coverFailed, mounted, labels.imageOpen, post?.id]);
201
+
202
+ const openViewer = (event: SyntheticEvent) => {
203
+ const images = blogViewerImages([coverRef.current, articleRef.current]);
204
+ const index = blogViewerIndex(event.target, images);
205
+ if (index < 0) {
206
+ return;
207
+ }
208
+ event.preventDefault();
209
+ setViewer({
210
+ index,
211
+ images: images.map((image) => {
212
+ const shown = blogViewerImage(image);
213
+ return image === coverRef.current ? { ...shown, alt: shown.alt || title } : shown;
214
+ }),
215
+ });
216
+ };
217
+
180
218
  useEffect(() => {
181
219
  requestRef.current?.abort();
182
220
  setAccessChanged(false);
@@ -240,7 +278,16 @@ export function BlogArticle({
240
278
  ) : null}
241
279
 
242
280
  {post ? (
243
- <article className={styles.article} data-blog-article={post.id}>
281
+ <article
282
+ className={styles.article}
283
+ data-blog-article={post.id}
284
+ onClick={openViewer}
285
+ onKeyDown={(event) => {
286
+ if (isBlogViewerOpenKey(event.nativeEvent)) {
287
+ openViewer(event);
288
+ }
289
+ }}
290
+ >
244
291
  <header className={styles.header}>
245
292
  <h1>{title}</h1>
246
293
  <div className={styles.meta}>
@@ -252,6 +299,7 @@ export function BlogArticle({
252
299
  </div>
253
300
  {cover && !coverFailed ? (
254
301
  <img
302
+ ref={coverRef}
255
303
  src={cover}
256
304
  alt=""
257
305
  width={3}
@@ -344,6 +392,17 @@ export function BlogArticle({
344
392
  ) : settled && !pending && !error ? (
345
393
  <OrionplaceEmptyState kind="journal" message={labels.unavailable} />
346
394
  ) : null}
395
+ {viewer && viewer.images.length ? (
396
+ <ProductReviewImageViewer
397
+ images={blogViewerPhotos(viewer.images)}
398
+ index={viewer.index}
399
+ label={labels.imageViewer}
400
+ alt={title || labels.imageViewer}
401
+ captions={viewer.images.map((image) => image.caption || image.alt)}
402
+ wide
403
+ onClose={() => setViewer(undefined)}
404
+ />
405
+ ) : null}
347
406
  </section>
348
407
  );
349
408
  }
@@ -30,6 +30,8 @@ The comment field takes up to 1024 characters; its own Save action sends
30
30
  `cart_customer_commentary_set` with `cart_commentary`. New cart data replaces the local recipient and comment
31
31
  drafts.
32
32
 
33
+ When `payment_methods` is empty, the payment block shows «No payment method is available for this order…» instead of a select. The backend offers a method limited by country only to customers whose phone number is from one of its countries (`origin_country`), not by the delivery address.
34
+
33
35
  On the Russian storefront a phone number typed without «+» the way it is written in Russia — «8 900 555-01-72» or «900 555-01-72» — becomes «+79005550172» when the field loses focus and before it is sent: the backend accepts only international numbers. A number typed with «+», other numbers and other languages are sent as typed (`phoneWithCountryCode()`).
34
36
 
35
37
  ### Props
@@ -77,6 +79,8 @@ On the Russian storefront a phone number typed without «+» the way it is writt
77
79
  `cart_commentary`. Новые данные корзины
78
80
  заменяют локальные значения получателя и комментария.
79
81
 
82
+ Когда `payment_methods` пуст, вместо списка показывается «Для этого заказа нет доступных способов оплаты…». Способ, ограниченный по странам, бэкенд предлагает только покупателям с номером телефона одной из этих стран (`origin_country`), а не по адресу доставки.
83
+
80
84
  В русской витрине номер без «+», набранный по-российски — «8 900 555-01-72» или «900 555-01-72», — при выходе из поля и перед отправкой становится «+79005550172»: бэкенд принимает только международный формат. Номер с «+», другие номера и другие языки отправляются как набраны (`phoneWithCountryCode()`).
81
85
 
82
86
  ### Свойства
@@ -269,15 +269,26 @@ export function CartCheckoutDetails({
269
269
  <>
270
270
  <section className={styles.panel}>
271
271
  <h2>{labels.payment}</h2>
272
- <OrionplaceSelect
273
- value={String(summary?.selected_payment ?? 0)}
274
- options={payments}
275
- icons={paymentIcons}
276
- label={labels.payment}
277
- disabled={disabled}
278
- required
279
- onValueChange={selectPayment}
280
- />
272
+ {/* Methods depend on the order and on the customer's phone country:
273
+ an empty list is said, not shown as a select with only the prompt. */}
274
+ {methods.length ? (
275
+ <OrionplaceSelect
276
+ value={String(summary?.selected_payment ?? 0)}
277
+ options={payments}
278
+ icons={paymentIcons}
279
+ label={labels.payment}
280
+ disabled={disabled}
281
+ required
282
+ onValueChange={selectPayment}
283
+ />
284
+ ) : (
285
+ <p
286
+ className={styles.muted}
287
+ role="status"
288
+ >
289
+ {labels.noPayment}
290
+ </p>
291
+ )}
281
292
  </section>
282
293
 
283
294
  <section className={styles.panel}>
@@ -0,0 +1,42 @@
1
+ # CookieNotice · React
2
+
3
+ ## English
4
+
5
+ `CookieNotice` tells visitors that the site uses only the cookies it needs and
6
+ links the cookies section of the policy. It renders nothing in server HTML;
7
+ after mounting it appears at the bottom of the screen until the visitor presses
8
+ OK, and the choice is kept in local storage under
9
+ `orionplace-cookie-notice-seen` (`COOKIE_NOTICE_STORAGE_KEY` in
10
+ `lib/orionplace/privacyLabels.ts`). The storefront sets only necessary cookies,
11
+ so the notice asks for no consent; a store that adds analytics or advertising
12
+ cookies needs a consent form instead. The supplied shell does not show it in
13
+ the Android app build.
14
+
15
+ Source is maintained in `components/orionplace/legal/CookieNotice.tsx`.
16
+
17
+ ### Props
18
+
19
+ | Prop | Meaning |
20
+ | --------------------------------- | --------------------------------------------------------------------------------------------------------- |
21
+ | `href?: string` | Address of the cookies section with the language, for example `/privacy?locale=ru#cookies`. Default: `/privacy#cookies`. |
22
+ | `onNavigate?: (path: string) => void` | Called on an ordinary click of the link with `href`; the host navigates. Without it the link opens normally. |
23
+
24
+ ## Русский
25
+
26
+ `CookieNotice` сообщает, что сайт использует только необходимые cookie, и
27
+ ведёт в раздел о cookie политики. В серверном HTML его нет; после монтирования
28
+ он показывается внизу экрана, пока посетитель не нажмёт «Понятно», и выбор
29
+ хранится в локальном хранилище под ключом `orionplace-cookie-notice-seen`
30
+ (`COOKIE_NOTICE_STORAGE_KEY` в `lib/orionplace/privacyLabels.ts`). Витрина
31
+ ставит только необходимые cookie, поэтому уведомление не запрашивает согласие;
32
+ магазину, который добавит аналитические или рекламные cookie, нужна форма
33
+ согласия. В сборке приложения для Android оболочка его не показывает.
34
+
35
+ Поддерживаемый исходный файл: `components/orionplace/legal/CookieNotice.tsx`.
36
+
37
+ ### Свойства
38
+
39
+ | Свойство | Назначение |
40
+ | --------------------------------- | ---------------------------------------------------------------------------------------------------------- |
41
+ | `href?: string` | Адрес раздела о cookie с языком, например `/privacy?locale=ru#cookies`. По умолчанию `/privacy#cookies`. |
42
+ | `onNavigate?: (path: string) => void` | Вызывается при обычном щелчке по ссылке с `href`; переход выполняет приложение. Без него ссылка открывается как обычно. |
@@ -0,0 +1,48 @@
1
+ .root {
2
+ position: fixed;
3
+ right: 0.75rem;
4
+ bottom: 0.75rem;
5
+ left: 0.75rem;
6
+ z-index: 40;
7
+ display: flex;
8
+ flex-direction: column;
9
+ align-items: stretch;
10
+ gap: 0.75rem;
11
+ box-sizing: border-box;
12
+ padding: 1rem;
13
+ border: 1px solid var(--orionplace-border);
14
+ color: var(--orionplace-text);
15
+ background: var(--orionplace-surface);
16
+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 10%), 0 4px 6px -4px rgb(0 0 0 / 10%);
17
+ font-size: 0.875rem;
18
+ line-height: 1.5rem;
19
+ }
20
+
21
+ .text {
22
+ margin: 0;
23
+ color: var(--orionplace-muted);
24
+ }
25
+
26
+ .text a {
27
+ color: var(--orionplace-text);
28
+ text-decoration: underline;
29
+ text-underline-offset: 2px;
30
+ white-space: nowrap;
31
+ }
32
+
33
+ .button {
34
+ composes: primary from '../shared/features.module.css';
35
+ flex-shrink: 0;
36
+ }
37
+
38
+ @media (min-width: 640px) {
39
+ .root {
40
+ right: 1.25rem;
41
+ bottom: 1.25rem;
42
+ left: auto;
43
+ max-width: 560px;
44
+ flex-direction: row;
45
+ align-items: center;
46
+ gap: 1rem;
47
+ }
48
+ }
@@ -0,0 +1,82 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState, type MouseEvent } from 'react';
4
+ import { useOrionplaceSelections } from '@/providers/OrionplaceProvider';
5
+ import {
6
+ COOKIE_NOTICE_STORAGE_KEY,
7
+ privacyLabels,
8
+ } from '@/lib/orionplace/privacyLabels';
9
+ import styles from './CookieNotice.module.css';
10
+
11
+ export interface CookieNoticeProps {
12
+ /** Address of the cookies section, with the language and `#cookies`. */
13
+ href?: string;
14
+ /** An ordinary click on the link; the host navigates. */
15
+ onNavigate?: (path: string) => void;
16
+ }
17
+
18
+ // The storefront sets only necessary cookies, so this is a notice, not a
19
+ // consent form. It appears after mounting and stays away once dismissed.
20
+ export function CookieNotice({
21
+ href = '/privacy#cookies',
22
+ onNavigate,
23
+ }: CookieNoticeProps) {
24
+ const locale = useOrionplaceSelections((state) => state.localeCode);
25
+ const labels = privacyLabels(locale).cookie;
26
+ const [visible, setVisible] = useState(false);
27
+
28
+ useEffect(() => {
29
+ try {
30
+ setVisible(localStorage.getItem(COOKIE_NOTICE_STORAGE_KEY) !== '1');
31
+ } catch {
32
+ setVisible(true);
33
+ }
34
+ }, []);
35
+
36
+ function dismiss() {
37
+ setVisible(false);
38
+ try {
39
+ localStorage.setItem(COOKIE_NOTICE_STORAGE_KEY, '1');
40
+ } catch {
41
+ // Without storage the notice comes back on the next visit.
42
+ }
43
+ }
44
+
45
+ function open(event: MouseEvent<HTMLAnchorElement>) {
46
+ if (
47
+ !onNavigate ||
48
+ event.ctrlKey ||
49
+ event.metaKey ||
50
+ event.shiftKey ||
51
+ event.altKey ||
52
+ event.button !== 0
53
+ ) {
54
+ return;
55
+ }
56
+ event.preventDefault();
57
+ onNavigate(href);
58
+ }
59
+
60
+ if (!visible) {
61
+ return null;
62
+ }
63
+
64
+ return (
65
+ <div
66
+ data-feature-part="CookieNotice"
67
+ role="region"
68
+ aria-label={labels.label}
69
+ className={styles.root}
70
+ >
71
+ <p className={styles.text}>
72
+ {labels.text}{' '}
73
+ <a href={href} onClick={open}>
74
+ {labels.link}
75
+ </a>
76
+ </p>
77
+ <button type="button" className={styles.button} onClick={dismiss}>
78
+ {labels.ok}
79
+ </button>
80
+ </div>
81
+ );
82
+ }