orionplace-react 0.5.0 → 0.6.1
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 +10 -1
- package/bin/orionplace-react.mjs +7 -0
- package/package.json +1 -1
- package/runtime/marketplace.d.mts +97 -0
- package/runtime/marketplace.mjs +519 -3
- package/src/project-readme.mjs +23 -2
- package/src/setup.mjs +1 -0
- package/src/sitemap.mjs +93 -0
- package/templates/app/_storefront/CategoryPage.tsx +6 -2
- package/templates/app/_storefront/SearchPage.tsx +7 -2
- package/templates/app/_storefront/SellersPage.tsx +9 -3
- package/templates/app/_storefront/storefrontRoutes.tsx +31 -10
- package/templates/app/_storefront/useProductSearch.ts +97 -26
- package/templates/app/robots.txt/route.ts +11 -0
- package/templates/app/sitemap.xml/route.ts +12 -0
- package/templates/components/orionplace/account/ProfileDetailsForm.README.md +4 -0
- package/templates/components/orionplace/account/ProfileDetailsForm.tsx +7 -2
- package/templates/components/orionplace/cart/CartCheckoutDetails.README.md +4 -0
- package/templates/components/orionplace/cart/CartCheckoutDetails.tsx +20 -9
- package/templates/interfaces/orionplace/accounts/IProfileData.ts +2 -0
- package/templates/interfaces/orionplace/appBase/IApploadUserProfile.ts +2 -0
- package/templates/lib/orionplace/cartLabels.ts +2 -0
- package/templates/lib/orionplace/pageMetadata.ts +24 -4
- package/templates/lib/orionplace/pageSeo.ts +68 -0
- package/templates/lib/orionplace/productListLocation.ts +130 -0
- package/templates/lib/orionplace/productSearch.ts +48 -0
- package/templates/lib/orionplace/profile.ts +12 -0
- package/templates/lib/orionplace/profileLabels.ts +2 -0
- package/templates/lib/orionplace/serverMetadata.ts +7 -0
- package/templates/proxy.ts +8 -2
|
@@ -4,7 +4,8 @@ import { StorefrontCategory } from '@/components/orionplace/categories/Storefron
|
|
|
4
4
|
import { useOrionplaceStorefront } from '@/hooks/useOrionplaceStorefront';
|
|
5
5
|
import { useOrionplaceCatalog } from '@/providers/OrionplaceProvider';
|
|
6
6
|
import { catalogBranchIds } from '@/lib/orionplace/catalog';
|
|
7
|
-
import type {
|
|
7
|
+
import type { ProductListLocation } from '@/lib/orionplace/productListLocation';
|
|
8
|
+
import type { ProductListPage } from '@/lib/orionplace/productSearch';
|
|
8
9
|
import { useProductSearch } from './useProductSearch';
|
|
9
10
|
import { useStorefrontNavigation } from './useStorefrontNavigation';
|
|
10
11
|
|
|
@@ -12,10 +13,12 @@ export function CategoryPage({
|
|
|
12
13
|
categoryId,
|
|
13
14
|
initial,
|
|
14
15
|
initialError = '',
|
|
16
|
+
location,
|
|
15
17
|
}: {
|
|
16
18
|
categoryId: number;
|
|
17
|
-
initial?:
|
|
19
|
+
initial?: ProductListPage;
|
|
18
20
|
initialError?: string;
|
|
21
|
+
location?: ProductListLocation;
|
|
19
22
|
}) {
|
|
20
23
|
const navigation = useStorefrontNavigation();
|
|
21
24
|
const controls = useOrionplaceStorefront();
|
|
@@ -24,6 +27,7 @@ export function CategoryPage({
|
|
|
24
27
|
{ categoryId, categoryIds: catalogBranchIds(catalog, categoryId) },
|
|
25
28
|
initial,
|
|
26
29
|
initialError,
|
|
30
|
+
{ location },
|
|
27
31
|
);
|
|
28
32
|
const pagination = search.data?.products?.rpag;
|
|
29
33
|
|
|
@@ -15,10 +15,11 @@ import { OrionplaceEmptyState } from '@/components/orionplace/shared/OrionplaceE
|
|
|
15
15
|
import { useOrionplaceStorefront } from '@/hooks/useOrionplaceStorefront';
|
|
16
16
|
import { browseLabels } from '@/lib/orionplace/browseLabels';
|
|
17
17
|
import { featuredLabels } from '@/lib/orionplace/featuredLabels';
|
|
18
|
+
import type { ProductListLocation } from '@/lib/orionplace/productListLocation';
|
|
18
19
|
import {
|
|
19
20
|
productSearchHeading,
|
|
20
21
|
productSearchTrail,
|
|
21
|
-
type
|
|
22
|
+
type ProductListPage as ProductSearchData,
|
|
22
23
|
type ProductSearchScope,
|
|
23
24
|
} from '@/lib/orionplace/productSearch';
|
|
24
25
|
import { useProductSearch } from './useProductSearch';
|
|
@@ -41,10 +42,12 @@ export function SearchPage({
|
|
|
41
42
|
scope,
|
|
42
43
|
initial,
|
|
43
44
|
initialError = '',
|
|
45
|
+
location,
|
|
44
46
|
}: {
|
|
45
47
|
scope: ProductSearchScope;
|
|
46
48
|
initial?: ProductSearchData;
|
|
47
49
|
initialError?: string;
|
|
50
|
+
location?: ProductListLocation;
|
|
48
51
|
}) {
|
|
49
52
|
const locale = useOrionplaceSelections((state) => state.localeCode);
|
|
50
53
|
const labels = browseLabels(locale);
|
|
@@ -55,7 +58,9 @@ export function SearchPage({
|
|
|
55
58
|
const guest = Boolean(scope.featured && ready && !init?.is_authenticated);
|
|
56
59
|
const navigation = useStorefrontNavigation();
|
|
57
60
|
const controls = useOrionplaceStorefront();
|
|
58
|
-
const search = useProductSearch(scope, initial, initialError
|
|
61
|
+
const search = useProductSearch(scope, initial, initialError, {
|
|
62
|
+
location,
|
|
63
|
+
});
|
|
59
64
|
const pagination = search.data?.products?.rpag;
|
|
60
65
|
const api = useOrionplaceApi();
|
|
61
66
|
const directory = directoryLabels(locale);
|
|
@@ -12,7 +12,8 @@ import { useOrionplaceApi } from '@/hooks/useOrionplaceApi';
|
|
|
12
12
|
import { useOrionplaceReviews } from '@/hooks/useOrionplaceReviews';
|
|
13
13
|
import { useOrionplaceStorefront } from '@/hooks/useOrionplaceStorefront';
|
|
14
14
|
import { directoryLabels } from '@/lib/orionplace/directoryLabels';
|
|
15
|
-
import type {
|
|
15
|
+
import type { ProductListLocation } from '@/lib/orionplace/productListLocation';
|
|
16
|
+
import type { ProductListPage } from '@/lib/orionplace/productSearch';
|
|
16
17
|
import { useOrionplaceSelections } from '@/providers/OrionplaceProvider';
|
|
17
18
|
import { useProductSearch } from './useProductSearch';
|
|
18
19
|
import { useStorefrontNavigation } from './useStorefrontNavigation';
|
|
@@ -25,11 +26,13 @@ export function SellersPage({
|
|
|
25
26
|
initial,
|
|
26
27
|
initialProducts,
|
|
27
28
|
initialError = '',
|
|
29
|
+
location,
|
|
28
30
|
}: {
|
|
29
31
|
sellerId?: number;
|
|
30
32
|
initial?: SellerPageData;
|
|
31
|
-
initialProducts?:
|
|
33
|
+
initialProducts?: ProductListPage;
|
|
32
34
|
initialError?: string;
|
|
35
|
+
location?: ProductListLocation;
|
|
33
36
|
}) {
|
|
34
37
|
const api = useOrionplaceApi();
|
|
35
38
|
const locale = useOrionplaceSelections((state) => state.localeCode);
|
|
@@ -38,6 +41,7 @@ export function SellersPage({
|
|
|
38
41
|
const labels = directoryLabels(locale);
|
|
39
42
|
const search = useProductSearch({ sellerId }, initialProducts, '', {
|
|
40
43
|
scrollToTop: false,
|
|
44
|
+
location,
|
|
41
45
|
});
|
|
42
46
|
const reviews = useOrionplaceReviews({
|
|
43
47
|
sellerId,
|
|
@@ -112,7 +116,9 @@ export function SellersPage({
|
|
|
112
116
|
products={search.data?.products?.data ?? []}
|
|
113
117
|
facets={search.facets}
|
|
114
118
|
filters={search.filters}
|
|
115
|
-
|
|
119
|
+
// The seller scope fixes the seller and its rating, as in the
|
|
120
|
+
// Nuxt and Svelte packages.
|
|
121
|
+
fixedFilters={['product_owner', 'owner_rating_from']}
|
|
116
122
|
page={search.page}
|
|
117
123
|
pages={pagination?.pages}
|
|
118
124
|
total={total}
|
|
@@ -67,10 +67,11 @@ import {
|
|
|
67
67
|
type ManufacturerSearchData,
|
|
68
68
|
} from '@/lib/orionplace/manufacturerDirectory';
|
|
69
69
|
import {
|
|
70
|
-
|
|
71
|
-
type
|
|
70
|
+
loadProductListPage,
|
|
71
|
+
type ProductListPage,
|
|
72
72
|
type ProductSearchScope,
|
|
73
73
|
} from '@/lib/orionplace/productSearch';
|
|
74
|
+
import { productListLocationFromQuery } from '@/lib/orionplace/productListLocation';
|
|
74
75
|
import {
|
|
75
76
|
emptySellerFilters,
|
|
76
77
|
sellerDirectoryRequest,
|
|
@@ -212,8 +213,11 @@ const routes: Record<string, Loader> = {
|
|
|
212
213
|
!scope.query ||
|
|
213
214
|
scope.query.length >= 3 ||
|
|
214
215
|
Object.values(scope).some((value) => typeof value === 'number');
|
|
216
|
+
const location = productListLocationFromQuery(query);
|
|
215
217
|
const { initial, initialError } = searched
|
|
216
|
-
? await initialData(context, (api) =>
|
|
218
|
+
? await initialData(context, (api) =>
|
|
219
|
+
loadProductListPage(api, scope, location.filters, location.page),
|
|
220
|
+
)
|
|
217
221
|
: { initial: undefined, initialError: '' };
|
|
218
222
|
return {
|
|
219
223
|
page: (
|
|
@@ -221,6 +225,7 @@ const routes: Record<string, Loader> = {
|
|
|
221
225
|
scope={scope}
|
|
222
226
|
initial={initial}
|
|
223
227
|
initialError={initialError}
|
|
228
|
+
location={location}
|
|
224
229
|
/>
|
|
225
230
|
),
|
|
226
231
|
};
|
|
@@ -254,11 +259,17 @@ const routes: Record<string, Loader> = {
|
|
|
254
259
|
if (!categoryId || !findCatalogCategory(catalog, categoryId)) {
|
|
255
260
|
return { notFound: true };
|
|
256
261
|
}
|
|
262
|
+
const location = productListLocationFromQuery(context.query);
|
|
257
263
|
const { initial, initialError } = await initialData(context, (api) =>
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
264
|
+
loadProductListPage(
|
|
265
|
+
api,
|
|
266
|
+
{
|
|
267
|
+
categoryId,
|
|
268
|
+
categoryIds: catalogBranchIds(catalog, categoryId),
|
|
269
|
+
},
|
|
270
|
+
location.filters,
|
|
271
|
+
location.page,
|
|
272
|
+
),
|
|
262
273
|
);
|
|
263
274
|
return {
|
|
264
275
|
page: (
|
|
@@ -266,6 +277,7 @@ const routes: Record<string, Loader> = {
|
|
|
266
277
|
categoryId={categoryId}
|
|
267
278
|
initial={initial}
|
|
268
279
|
initialError={initialError}
|
|
280
|
+
location={location}
|
|
269
281
|
/>
|
|
270
282
|
),
|
|
271
283
|
};
|
|
@@ -275,8 +287,9 @@ const routes: Record<string, Loader> = {
|
|
|
275
287
|
|
|
276
288
|
'/featured': async (context) => {
|
|
277
289
|
const scope = { featured: true } as const;
|
|
290
|
+
const location = productListLocationFromQuery(context.query);
|
|
278
291
|
const { initial, initialError } = await initialData(context, (api) =>
|
|
279
|
-
|
|
292
|
+
loadProductListPage(api, scope, location.filters, location.page),
|
|
280
293
|
);
|
|
281
294
|
return {
|
|
282
295
|
page: (
|
|
@@ -284,6 +297,7 @@ const routes: Record<string, Loader> = {
|
|
|
284
297
|
scope={scope}
|
|
285
298
|
initial={initial}
|
|
286
299
|
initialError={initialError}
|
|
300
|
+
location={location}
|
|
287
301
|
/>
|
|
288
302
|
),
|
|
289
303
|
};
|
|
@@ -487,8 +501,9 @@ const routes: Record<string, Loader> = {
|
|
|
487
501
|
return { redirect: `/catalog?locale=${encodeURIComponent(text(query.locale))}` };
|
|
488
502
|
}
|
|
489
503
|
const sellerId = positive(text(query.seller) || undefined);
|
|
504
|
+
const location = productListLocationFromQuery(query);
|
|
490
505
|
let initial;
|
|
491
|
-
let initialProducts:
|
|
506
|
+
let initialProducts: ProductListPage | undefined;
|
|
492
507
|
let initialError = '';
|
|
493
508
|
try {
|
|
494
509
|
const api = await context.api();
|
|
@@ -499,7 +514,12 @@ const routes: Record<string, Loader> = {
|
|
|
499
514
|
: sellerDirectoryRequest(emptySellerFilters(), 1),
|
|
500
515
|
);
|
|
501
516
|
if (sellerId && initial.sellers.data[0]) {
|
|
502
|
-
initialProducts = await
|
|
517
|
+
initialProducts = await loadProductListPage(
|
|
518
|
+
api,
|
|
519
|
+
{ sellerId },
|
|
520
|
+
location.filters,
|
|
521
|
+
location.page,
|
|
522
|
+
);
|
|
503
523
|
}
|
|
504
524
|
} catch (reason) {
|
|
505
525
|
initialError = await context.errorText(reason);
|
|
@@ -511,6 +531,7 @@ const routes: Record<string, Loader> = {
|
|
|
511
531
|
initial={initial}
|
|
512
532
|
initialProducts={initialProducts}
|
|
513
533
|
initialError={initialError}
|
|
534
|
+
location={location}
|
|
514
535
|
/>
|
|
515
536
|
),
|
|
516
537
|
};
|
|
@@ -9,14 +9,19 @@ import {
|
|
|
9
9
|
import type { ProductFacetSelection } from '@/interfaces/orionplace/products/IProductSearchFacets';
|
|
10
10
|
import { useOrionplaceApi } from '@/hooks/useOrionplaceApi';
|
|
11
11
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
type
|
|
12
|
+
loadProductListPage,
|
|
13
|
+
productListFacetData,
|
|
14
|
+
type ProductListPage,
|
|
15
15
|
type ProductSearchScope,
|
|
16
16
|
} from '@/lib/orionplace/productSearch';
|
|
17
|
-
import { initialSearchFacets } from '@/lib/orionplace/searchFilters';
|
|
18
17
|
import { apiErrorMessage } from '@/lib/orionplace/api';
|
|
19
18
|
import { featureLabels } from '@/lib/orionplace/featureLabels';
|
|
19
|
+
import {
|
|
20
|
+
productListLocationFromQuery,
|
|
21
|
+
productListLocationQuery,
|
|
22
|
+
sameProductListLocation,
|
|
23
|
+
type ProductListLocation,
|
|
24
|
+
} from '@/lib/orionplace/productListLocation';
|
|
20
25
|
|
|
21
26
|
/*
|
|
22
27
|
* A search needs a category, a fixed scope or a 3-letter query, as in the
|
|
@@ -30,35 +35,77 @@ function searchable(scope: ProductSearchScope) {
|
|
|
30
35
|
);
|
|
31
36
|
}
|
|
32
37
|
|
|
38
|
+
const firstListPage: ProductListLocation = { page: 1, filters: {} };
|
|
39
|
+
|
|
40
|
+
function listSignature(location: ProductListLocation) {
|
|
41
|
+
return JSON.stringify(productListLocationQuery({}, location));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function addressQuery(): Record<string, string> {
|
|
45
|
+
return Object.fromEntries(new URL(window.location.href).searchParams);
|
|
46
|
+
}
|
|
47
|
+
|
|
33
48
|
/*
|
|
34
49
|
* `scrollToTop: false` keeps the scroll position on a page turn, for a page
|
|
35
50
|
* whose pagination scrolls to its own list (the seller page).
|
|
51
|
+
* `location` is the page and filters `initial` was loaded with (the URL of the
|
|
52
|
+
* server render). The page and filters stay in the URL, so Back from a
|
|
53
|
+
* product or a reload opens the list the visitor left; `rememberInAddress:
|
|
54
|
+
* false` keeps them out of it.
|
|
36
55
|
*/
|
|
37
56
|
export function useProductSearch(
|
|
38
57
|
scope: ProductSearchScope,
|
|
39
|
-
initial?:
|
|
58
|
+
initial?: ProductListPage,
|
|
40
59
|
initialError = '',
|
|
41
|
-
options: {
|
|
60
|
+
options: {
|
|
61
|
+
scrollToTop?: boolean;
|
|
62
|
+
location?: ProductListLocation;
|
|
63
|
+
rememberInAddress?: boolean;
|
|
64
|
+
} = {},
|
|
42
65
|
) {
|
|
66
|
+
const remember = options.rememberInAddress !== false;
|
|
67
|
+
const serverLocation = remember
|
|
68
|
+
? (options.location ?? firstListPage)
|
|
69
|
+
: firstListPage;
|
|
70
|
+
// The page and filters in the URL; the server render reads them from the
|
|
71
|
+
// request, the browser from the address bar.
|
|
72
|
+
function addressLocation(): ProductListLocation {
|
|
73
|
+
if (!remember) {
|
|
74
|
+
return firstListPage;
|
|
75
|
+
}
|
|
76
|
+
return typeof window === 'undefined'
|
|
77
|
+
? serverLocation
|
|
78
|
+
: productListLocationFromQuery(addressQuery());
|
|
79
|
+
}
|
|
80
|
+
// `initial` belongs to the URL it was loaded for.
|
|
81
|
+
function fromServer(location: ProductListLocation) {
|
|
82
|
+
return listSignature(location) === listSignature(serverLocation);
|
|
83
|
+
}
|
|
84
|
+
const [opening] = useState(addressLocation);
|
|
85
|
+
const openedFromServer = fromServer(opening);
|
|
43
86
|
const api = useOrionplaceApi();
|
|
44
87
|
const locale = useOrionplaceSelections(
|
|
45
88
|
(state) => state.localeCode,
|
|
46
89
|
);
|
|
47
90
|
const productsStore = useOrionplaceProductsStore();
|
|
48
91
|
const browseStore = useOrionplaceBrowseStore();
|
|
49
|
-
const [data, setData] = useState(initial);
|
|
50
|
-
const [filters, setFilters] = useState<ProductFacetSelection>(
|
|
51
|
-
const [page, setPage] = useState(
|
|
92
|
+
const [data, setData] = useState(openedFromServer ? initial : undefined);
|
|
93
|
+
const [filters, setFilters] = useState<ProductFacetSelection>(opening.filters);
|
|
94
|
+
const [page, setPage] = useState(
|
|
95
|
+
openedFromServer ? (initial?.products?.rpag.page ?? opening.page) : opening.page,
|
|
96
|
+
);
|
|
52
97
|
const [pending, setPending] = useState(false);
|
|
53
|
-
const [loaded, setLoaded] = useState(Boolean(initial));
|
|
54
|
-
const [error, setError] = useState(initialError);
|
|
98
|
+
const [loaded, setLoaded] = useState(openedFromServer && Boolean(initial));
|
|
99
|
+
const [error, setError] = useState(openedFromServer ? initialError : '');
|
|
55
100
|
// Filter choices of the scope's first response; a filtered response must
|
|
56
101
|
// not shrink them, so the visitor can still pick another seller or price.
|
|
57
|
-
const [facets, setFacets] = useState(() =>
|
|
102
|
+
const [facets, setFacets] = useState(() =>
|
|
103
|
+
productListFacetData(openedFromServer ? initial : undefined),
|
|
104
|
+
);
|
|
58
105
|
const request = useRef<AbortController | undefined>(undefined);
|
|
59
106
|
const signature = JSON.stringify(scope);
|
|
60
107
|
|
|
61
|
-
function apply(next:
|
|
108
|
+
function apply(next: ProductListPage) {
|
|
62
109
|
// The backend sends the unfiltered total with the first page only;
|
|
63
110
|
// later pages keep it, as in the Nuxt and Svelte packages.
|
|
64
111
|
setData((previous) =>
|
|
@@ -80,10 +127,10 @@ export function useProductSearch(
|
|
|
80
127
|
: next,
|
|
81
128
|
);
|
|
82
129
|
setLoaded(true);
|
|
83
|
-
const incoming =
|
|
130
|
+
const incoming = productListFacetData(next);
|
|
84
131
|
if (incoming) {
|
|
85
132
|
browseStore.getState().preserveSearchFacets(incoming);
|
|
86
|
-
setFacets((previous) =>
|
|
133
|
+
setFacets((previous) => productListFacetData(next, previous));
|
|
87
134
|
}
|
|
88
135
|
for (const product of next.products?.data ?? []) {
|
|
89
136
|
productsStore
|
|
@@ -92,19 +139,39 @@ export function useProductSearch(
|
|
|
92
139
|
}
|
|
93
140
|
}
|
|
94
141
|
|
|
142
|
+
// Replaces the URL, so Back from the list still leaves it in one step.
|
|
143
|
+
function rememberLocation(next: ProductListLocation) {
|
|
144
|
+
const query = addressQuery();
|
|
145
|
+
if (
|
|
146
|
+
!remember ||
|
|
147
|
+
sameProductListLocation(productListLocationFromQuery(query), next)
|
|
148
|
+
) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const search = new URLSearchParams(productListLocationQuery(query, next));
|
|
152
|
+
window.history.replaceState(
|
|
153
|
+
null,
|
|
154
|
+
'',
|
|
155
|
+
`${window.location.pathname}?${search}${window.location.hash}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
95
159
|
async function load(nextPage = page, nextFilters = filters) {
|
|
160
|
+
rememberLocation({ page: nextPage, filters: nextFilters });
|
|
96
161
|
request.current?.abort();
|
|
97
162
|
const controller = new AbortController();
|
|
98
163
|
request.current = controller;
|
|
99
164
|
setPending(true);
|
|
100
165
|
setError('');
|
|
101
166
|
try {
|
|
102
|
-
const next = await
|
|
167
|
+
const next = await loadProductListPage(
|
|
103
168
|
api,
|
|
104
169
|
scope,
|
|
105
170
|
nextFilters,
|
|
106
171
|
nextPage,
|
|
107
172
|
controller.signal,
|
|
173
|
+
// Only a list without its filter choices yet needs the full ones.
|
|
174
|
+
!browseStore.getState().searchFacets,
|
|
108
175
|
);
|
|
109
176
|
if (!controller.signal.aborted) {
|
|
110
177
|
setPage(nextPage);
|
|
@@ -122,18 +189,22 @@ export function useProductSearch(
|
|
|
122
189
|
}
|
|
123
190
|
}
|
|
124
191
|
|
|
192
|
+
// A new scope starts from the page and filters of its URL: none after a
|
|
193
|
+
// link, the ones the visitor left after Back or a reload.
|
|
125
194
|
useEffect(() => {
|
|
195
|
+
const location = addressLocation();
|
|
196
|
+
const server = fromServer(location) ? initial : undefined;
|
|
126
197
|
browseStore.getState().setSearchQuery(scope.query ?? '');
|
|
127
|
-
browseStore.getState().setSearchFilters(
|
|
128
|
-
browseStore.getState().setSearchFacets(
|
|
129
|
-
setFacets(
|
|
130
|
-
setData(
|
|
131
|
-
setFilters(
|
|
132
|
-
setPage(
|
|
133
|
-
setLoaded(Boolean(
|
|
134
|
-
setError(initialError);
|
|
135
|
-
if (!
|
|
136
|
-
void load(
|
|
198
|
+
browseStore.getState().setSearchFilters(location.filters);
|
|
199
|
+
browseStore.getState().setSearchFacets(productListFacetData(server));
|
|
200
|
+
setFacets(productListFacetData(server));
|
|
201
|
+
setData(server);
|
|
202
|
+
setFilters(location.filters);
|
|
203
|
+
setPage(server?.products?.rpag.page ?? location.page);
|
|
204
|
+
setLoaded(Boolean(server));
|
|
205
|
+
setError(fromServer(location) ? initialError : '');
|
|
206
|
+
if (!server && searchable(scope)) {
|
|
207
|
+
void load(location.page, location.filters);
|
|
137
208
|
}
|
|
138
209
|
return () => request.current?.abort();
|
|
139
210
|
}, [signature]);
|
|
@@ -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>{
|
|
156
|
+
<small>{phoneHint}</small>
|
|
152
157
|
</label>
|
|
153
158
|
<label>
|
|
154
159
|
<span>{labels.dob}</span>
|
|
@@ -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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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}>
|
|
@@ -31,6 +31,8 @@ export interface IApiDateTime {
|
|
|
31
31
|
export interface IProfileData {
|
|
32
32
|
id: number;
|
|
33
33
|
phone: string;
|
|
34
|
+
/** ISO 3166-1 alpha-2 country of the phone number; null when the number has none. */
|
|
35
|
+
origin_country: string | null;
|
|
34
36
|
first_name: string;
|
|
35
37
|
last_name: string;
|
|
36
38
|
email: string;
|
|
@@ -6,6 +6,8 @@ export interface IApploadUserProfile {
|
|
|
6
6
|
last_login: string | null;
|
|
7
7
|
date_joined: string;
|
|
8
8
|
phone: string;
|
|
9
|
+
/** ISO 3166-1 alpha-2 country of the phone number; null when the number has none. */
|
|
10
|
+
origin_country: string | null;
|
|
9
11
|
password: boolean;
|
|
10
12
|
dob: string | null;
|
|
11
13
|
notifications_enabled: boolean;
|
|
@@ -17,6 +17,7 @@ const en = {
|
|
|
17
17
|
'The address is saved, but there is no delivery to this country. Choose another address.',
|
|
18
18
|
payment: 'Payment method',
|
|
19
19
|
choosePayment: 'Choose payment method',
|
|
20
|
+
noPayment: 'No payment method is available for this order. Available methods depend on the order and on the country of your phone number.',
|
|
20
21
|
recipient: 'Recipient',
|
|
21
22
|
myself: 'Myself',
|
|
22
23
|
myselfHint: 'Myself: I will receive the order',
|
|
@@ -143,6 +144,7 @@ const ru: typeof en = {
|
|
|
143
144
|
'Адрес сохранён, но доставки в эту страну нет. Выберите другой адрес.',
|
|
144
145
|
payment: 'Способ оплаты',
|
|
145
146
|
choosePayment: 'Выберите способ оплаты',
|
|
147
|
+
noPayment: 'Для этого заказа нет доступных способов оплаты. Они зависят от заказа и от страны вашего номера телефона.',
|
|
146
148
|
recipient: 'Получатель',
|
|
147
149
|
myself: 'Себе',
|
|
148
150
|
myselfHint: 'Себе: получу заказ самостоятельно',
|
|
@@ -13,6 +13,8 @@ import type { ICategoryLocale } from '@/interfaces/orionplace/category/ICategory
|
|
|
13
13
|
import type { IPromoDefinition } from '@/interfaces/orionplace/cart/ICartData';
|
|
14
14
|
import { promoName } from '@/lib/orionplace/promoPresentation';
|
|
15
15
|
import {
|
|
16
|
+
storefrontAbsoluteUrl,
|
|
17
|
+
storefrontCanonicalUrl,
|
|
16
18
|
storefrontPageMetadata,
|
|
17
19
|
type StorefrontSeoPage,
|
|
18
20
|
} from '@/lib/orionplace/pageSeo';
|
|
@@ -111,6 +113,8 @@ export function storefrontMetadata(
|
|
|
111
113
|
locale: string,
|
|
112
114
|
url: URL,
|
|
113
115
|
records: StorefrontPageRecords = {},
|
|
116
|
+
// The public origin (the proxy's host on the server, the page's own in the browser).
|
|
117
|
+
origin = url.origin,
|
|
114
118
|
) {
|
|
115
119
|
const segments = url.pathname.split('/').filter(Boolean);
|
|
116
120
|
let page = records.pageKind ?? storefrontSeoPage(url.pathname);
|
|
@@ -125,7 +129,13 @@ export function storefrontMetadata(
|
|
|
125
129
|
let image: string | undefined;
|
|
126
130
|
const tags: MetaTags = [];
|
|
127
131
|
let robots: string | undefined;
|
|
128
|
-
|
|
132
|
+
// A private product link (`key`) stays out of search results, loaded or not.
|
|
133
|
+
if (
|
|
134
|
+
privatePages.has(page) ||
|
|
135
|
+
page === 'notFound' ||
|
|
136
|
+
page === 'error' ||
|
|
137
|
+
(page === 'product' && url.searchParams.get('key'))
|
|
138
|
+
) {
|
|
129
139
|
robots = 'noindex, nofollow';
|
|
130
140
|
} else if (page === 'browse') {
|
|
131
141
|
robots = 'noindex, follow';
|
|
@@ -177,9 +187,6 @@ export function storefrontMetadata(
|
|
|
177
187
|
field('meta_description') || field('short_description'),
|
|
178
188
|
).slice(0, 160);
|
|
179
189
|
image = product.images?.full?.[0] ?? product.images?.small?.[0];
|
|
180
|
-
if (url.searchParams.get('key')) {
|
|
181
|
-
robots = 'noindex, nofollow';
|
|
182
|
-
}
|
|
183
190
|
} else if (page === 'publication' && records.post) {
|
|
184
191
|
values.title = getAvailableNestedValueFromObject(
|
|
185
192
|
records.post.locale,
|
|
@@ -210,12 +217,24 @@ export function storefrontMetadata(
|
|
|
210
217
|
values.title = promoName(records.promo, locale);
|
|
211
218
|
}
|
|
212
219
|
const content = storefrontPageMetadata(page, locale, values);
|
|
220
|
+
// Link previews load only complete image addresses; the canonical address
|
|
221
|
+
// is the sitemap's one for this page.
|
|
222
|
+
image = storefrontAbsoluteUrl(image, origin);
|
|
223
|
+
const canonical = storefrontCanonicalUrl(
|
|
224
|
+
origin,
|
|
225
|
+
url.pathname,
|
|
226
|
+
url.search,
|
|
227
|
+
locale,
|
|
228
|
+
robots,
|
|
229
|
+
);
|
|
213
230
|
const metadata: Metadata = {
|
|
214
231
|
...content,
|
|
215
232
|
...(robots ? { robots } : {}),
|
|
233
|
+
...(canonical ? { alternates: { canonical } } : {}),
|
|
216
234
|
openGraph: {
|
|
217
235
|
title: content.title,
|
|
218
236
|
description: content.description,
|
|
237
|
+
...(canonical ? { url: canonical } : {}),
|
|
219
238
|
...(image ? { images: [image] } : {}),
|
|
220
239
|
},
|
|
221
240
|
};
|
|
@@ -230,6 +249,7 @@ export function storefrontMetadata(
|
|
|
230
249
|
'og:title': content.title,
|
|
231
250
|
'og:description': content.description,
|
|
232
251
|
'og:image': image,
|
|
252
|
+
'og:url': canonical,
|
|
233
253
|
})) {
|
|
234
254
|
headTags.set(name, {
|
|
235
255
|
...(name.startsWith('og:') ? { property: name } : { name }),
|