orionplace-svelte 0.6.1 → 0.6.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orionplace-svelte",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
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",
@@ -8,6 +8,8 @@ Article videos may come as the backend's own paths (`/ps3/...`). In an Android a
8
8
 
9
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
10
 
11
+ Products the author places in the text appear at those positions: an empty block with `data-product-id="12"` shows one product card, `data-product-ids="12,15,9"` a row of up to 24 cards. The block keeps the editor's float or centred style, and `data-product-hidden-badges` (`new`, `popular`, `good-deal`) turns card badges off. Products missing from `related_products` are loaded with `products_search` and `filters.id_in`, up to 35 IDs per request, and stored with `store.setProduct()`; a block whose products are not found stays hidden. Article text styles do not reach the cards, and in a narrow article a floated card stands alone in the middle. The helper is `src/lib/utils/orionplace/blogProductPlacements.ts`.
12
+
11
13
  Source: `src/lib/components/orionplace/blog/BlogArticle.svelte`.
12
14
 
13
15
  ## Inputs
@@ -5,6 +5,7 @@
5
5
  nextTick,
6
6
  onMounted,
7
7
  ref,
8
+ shallowRef,
8
9
  watch,
9
10
  } from '@/lib/orionplace/reactivity.svelte';
10
11
  import { useOrionplaceStore } from '@/lib/stores/orionplace.svelte';
@@ -28,6 +29,13 @@
28
29
  import { htmlTextParagraphs } from '@/lib/utils/orionplace/htmlText';
29
30
  import { APP_ORIGIN, appMediaUrl } from '@/lib/utils/orionplace/nativeApp';
30
31
  import { requestBlogPost } from '@/lib/utils/orionplace/blogLayout';
32
+ import {
33
+ blogPlacementProductIds,
34
+ blogPlacementRequests,
35
+ blogProductPlacements,
36
+ } from '@/lib/utils/orionplace/blogProductPlacements';
37
+ import type { IBlogProductPlacement } from '@/lib/utils/orionplace/blogProductPlacements';
38
+ import type { IProductSearchSeller } from '@/lib/interfaces/orionplace/products/IProduct';
31
39
  import type { IBlogPost } from '@/lib/interfaces/orionplace/blog/IBlogApi';
32
40
  import type { IProductSecondaryVariantRequest } from '@/lib/interfaces/orionplace/products/IProductSecondaryVariantRequest';
33
41
  import type {
@@ -38,6 +46,7 @@
38
46
  } from '@/lib/interfaces/orionplace/products/IProduct';
39
47
  import BlogComments from '@/lib/components/orionplace/blog/BlogComments.svelte';
40
48
  import ProductGroup from '@/lib/components/orionplace/products/ProductGroup.svelte';
49
+ import ProductCard from '@/lib/components/orionplace/products/ProductCard.svelte';
41
50
  import '@/lib/components/orionplace/shared/features.css';
42
51
  import OrionplaceEmptyState from '@/lib/components/orionplace/shared/OrionplaceEmptyState.svelte';
43
52
  import ProductReviewImageViewer from '@/lib/components/orionplace/products/ProductReviewImageViewer.svelte';
@@ -153,6 +162,8 @@
153
162
  const accessChanged = ref(false);
154
163
  const coverFailed = ref(false);
155
164
  const productIds = ref<number[]>([]);
165
+ // Product cards the author placed in the text (see blogProductPlacements).
166
+ const placements = shallowRef<IBlogProductPlacement[]>([]);
156
167
  const wasShown = ref(false);
157
168
  const settled = ref(false);
158
169
  const labels = computed(() => blogLabels(selectionStore.localeCode));
@@ -300,6 +311,57 @@
300
311
  wasShown.value = true;
301
312
  showProducts(inputs.initialPost.related_products);
302
313
  }
314
+ function placementShown(placement: IBlogProductPlacement) {
315
+ return placement.productIds.some((id) => productStore.getProduct(id, 'products'));
316
+ }
317
+
318
+ // Placed products that no list loaded yet come with one request per 35.
319
+ async function loadPlacementProducts(ids: number[]) {
320
+ const missing = ids.filter((id) => !productStore.getProduct(id, 'products'));
321
+
322
+ for (const chunk of blogPlacementRequests(missing)) {
323
+ try {
324
+ const data = await api.request<{
325
+ products?: { data?: IProduct[] };
326
+ selects?: { sellers?: IProductSearchSeller[] };
327
+ }>('products_search', {
328
+ filters: { id_in: chunk.join(',') },
329
+ paginate: { page: 1, pp_items: chunk.length },
330
+ });
331
+
332
+ for (const product of data?.products?.data ?? []) {
333
+ productStore.setProduct(product, 'products', data?.selects?.sellers ?? []);
334
+ }
335
+ } catch {
336
+ // A placement without its products stays empty and hidden.
337
+ }
338
+ }
339
+ }
340
+
341
+ watch(
342
+ [() => html.value, () => mounted.value, () => post.value?.id],
343
+ () => {
344
+ void nextTick(() => {
345
+ placements.value = mounted.value ? blogProductPlacements(article.value) : [];
346
+ void loadPlacementProducts(blogPlacementProductIds(placements.value));
347
+ });
348
+ },
349
+ { immediate: true },
350
+ );
351
+
352
+ // Moves the cards into their placement block in the article text.
353
+ function portal(node: HTMLElement, target: HTMLElement) {
354
+ target.append(node);
355
+ return {
356
+ update(next: HTMLElement) {
357
+ next.append(node);
358
+ },
359
+ destroy() {
360
+ node.remove();
361
+ },
362
+ };
363
+ }
364
+
303
365
  function openableImages() {
304
366
  return blogViewerImages([coverImage.value, article.value]);
305
367
  }
@@ -473,6 +535,94 @@
473
535
  {/each}
474
536
  </div>
475
537
  {/if}
538
+ {#each placements.value as placement, index (index)}
539
+ {#if placementShown(placement)}
540
+ <div
541
+ use:portal={placement.element}
542
+ data-blog-placement={placement.layout}
543
+ >
544
+ {#if placement.layout === 'single'}
545
+ <ProductCard
546
+ productId={placement.productIds[0]!}
547
+ hiddenBadges={placement.hiddenBadges}
548
+ disabled={inputs.productActionsDisabled}
549
+ onSelect={($event) => {
550
+ emit('selectProduct', $event);
551
+ }}
552
+ onSelectSeller={($event) => {
553
+ emit('selectSeller', $event);
554
+ }}
555
+ onSelectVariant={($event) => {
556
+ emit('selectVariant', $event);
557
+ }}
558
+ onRequestSecondaryVariants={($event) => {
559
+ emit('requestSecondaryVariants', $event);
560
+ }}
561
+ onSelectSecondaryVariant={($event) => {
562
+ emit('selectSecondaryVariant', $event);
563
+ }}
564
+ onToggleFavorite={($event) => {
565
+ emit('toggleFavorite', $event);
566
+ }}
567
+ onAdd={($event) => {
568
+ emit('add', $event);
569
+ }}
570
+ onRemove={($event) => {
571
+ emit('remove', $event);
572
+ }}
573
+ onSubscribe={($event) => {
574
+ emit('subscribe', $event);
575
+ }}
576
+ onUnsubscribe={($event) => {
577
+ emit('unsubscribe', $event);
578
+ }}
579
+ onPreorder={($event) => {
580
+ emit('preorder', $event);
581
+ }}
582
+ />
583
+ {:else}
584
+ <ProductGroup
585
+ productIds={placement.productIds}
586
+ hiddenBadges={placement.hiddenBadges}
587
+ disabled={inputs.productActionsDisabled}
588
+ onSelect={($event) => {
589
+ emit('selectProduct', $event);
590
+ }}
591
+ onSelectSeller={($event) => {
592
+ emit('selectSeller', $event);
593
+ }}
594
+ onSelectVariant={($event) => {
595
+ emit('selectVariant', $event);
596
+ }}
597
+ onRequestSecondaryVariants={($event) => {
598
+ emit('requestSecondaryVariants', $event);
599
+ }}
600
+ onSelectSecondaryVariant={($event) => {
601
+ emit('selectSecondaryVariant', $event);
602
+ }}
603
+ onToggleFavorite={($event) => {
604
+ emit('toggleFavorite', $event);
605
+ }}
606
+ onAdd={($event) => {
607
+ emit('add', $event);
608
+ }}
609
+ onRemove={($event) => {
610
+ emit('remove', $event);
611
+ }}
612
+ onSubscribe={($event) => {
613
+ emit('subscribe', $event);
614
+ }}
615
+ onUnsubscribe={($event) => {
616
+ emit('unsubscribe', $event);
617
+ }}
618
+ onPreorder={($event) => {
619
+ emit('preorder', $event);
620
+ }}
621
+ />
622
+ {/if}
623
+ </div>
624
+ {/if}
625
+ {/each}
476
626
 
477
627
  {#if !full.value}
478
628
  <div
@@ -670,144 +820,145 @@
670
820
  clear: both;
671
821
  }
672
822
 
673
- .op-feature-prose :global(:where(p, ul, ol, blockquote, pre, figure)) {
823
+ /* Product cards placed in the text keep their own look: the text rules skip placement blocks. */
824
+ .op-feature-prose :global(:where(p, ul, ol, blockquote, pre, figure):where(:not([data-product-id] *, [data-product-ids] *))) {
674
825
  margin: 1rem 0;
675
826
  }
676
827
 
677
828
  /* A new section or wide block starts below a floated image. */
678
829
  .op-feature-prose
679
- :global(:where(h1, h2, h3, h4, h5, h6, figure, table, pre)) {
830
+ :global(:where(h1, h2, h3, h4, h5, h6, figure, table, pre):where(:not([data-product-id] *, [data-product-ids] *))) {
680
831
  clear: both;
681
832
  }
682
833
 
683
- .op-feature-prose :global(:where(h1, h2, h3, h4, h5, h6)) {
834
+ .op-feature-prose :global(:where(h1, h2, h3, h4, h5, h6):where(:not([data-product-id] *, [data-product-ids] *))) {
684
835
  margin: 2rem 0 1rem;
685
836
  font-weight: 500;
686
837
  line-height: 1.3;
687
838
  }
688
839
 
689
- .op-feature-prose :global(h1) {
840
+ .op-feature-prose :global(h1:where(:not([data-product-id] *, [data-product-ids] *))) {
690
841
  font-size: 1.875rem;
691
842
  }
692
843
 
693
- .op-feature-prose :global(h2) {
844
+ .op-feature-prose :global(h2:where(:not([data-product-id] *, [data-product-ids] *))) {
694
845
  font-size: 1.5rem;
695
846
  }
696
847
 
697
- .op-feature-prose :global(h3) {
848
+ .op-feature-prose :global(h3:where(:not([data-product-id] *, [data-product-ids] *))) {
698
849
  font-size: 1.25rem;
699
850
  }
700
851
 
701
- .op-feature-prose :global(h4) {
852
+ .op-feature-prose :global(h4:where(:not([data-product-id] *, [data-product-ids] *))) {
702
853
  font-size: 1.125rem;
703
854
  }
704
855
 
705
- .op-feature-prose :global(h5) {
856
+ .op-feature-prose :global(h5:where(:not([data-product-id] *, [data-product-ids] *))) {
706
857
  font-size: 1rem;
707
858
  }
708
859
 
709
- .op-feature-prose :global(h6) {
860
+ .op-feature-prose :global(h6:where(:not([data-product-id] *, [data-product-ids] *))) {
710
861
  font-size: 0.875rem;
711
862
  text-transform: uppercase;
712
863
  letter-spacing: 0.08em;
713
864
  }
714
865
 
715
- .op-feature-prose :global(:where(strong, b)) {
866
+ .op-feature-prose :global(:where(strong, b):where(:not([data-product-id] *, [data-product-ids] *))) {
716
867
  font-weight: 500;
717
868
  }
718
869
 
719
- .op-feature-prose :global(:where(sub, sup)) {
870
+ .op-feature-prose :global(:where(sub, sup):where(:not([data-product-id] *, [data-product-ids] *))) {
720
871
  font-size: 0.75em;
721
872
  line-height: 0;
722
873
  }
723
874
 
724
- .op-feature-prose :global(mark) {
875
+ .op-feature-prose :global(mark:where(:not([data-product-id] *, [data-product-ids] *))) {
725
876
  color: inherit;
726
877
  background: var(--orionplace-attention-surface);
727
878
  }
728
879
 
729
- .op-feature-prose :global(a) {
880
+ .op-feature-prose :global(a:where(:not([data-product-id] *, [data-product-ids] *))) {
730
881
  color: var(--orionplace-text);
731
882
  text-decoration: none;
732
883
  border-bottom: 1px solid currentColor;
733
884
  }
734
885
 
735
- .op-feature-prose :global(a:not([href])) {
886
+ .op-feature-prose :global(a:not([href]):where(:not([data-product-id] *, [data-product-ids] *))) {
736
887
  border-bottom: 0;
737
888
  }
738
889
 
739
- .op-feature-prose :global(a:focus-visible) {
890
+ .op-feature-prose :global(a:focus-visible:where(:not([data-product-id] *, [data-product-ids] *))) {
740
891
  color: var(--orionplace-accent);
741
892
  outline: none;
742
893
  }
743
894
 
744
895
  @media (hover: hover) {
745
- .op-feature-prose :global(a:hover) {
896
+ .op-feature-prose :global(a:hover:where(:not([data-product-id] *, [data-product-ids] *))) {
746
897
  color: var(--orionplace-accent);
747
898
  outline: none;
748
899
  }
749
900
  }
750
901
 
751
- .op-feature-prose :global(:where(ul, ol)) {
902
+ .op-feature-prose :global(:where(ul, ol):where(:not([data-product-id] *, [data-product-ids] *))) {
752
903
  padding-left: 1.5rem;
753
904
  }
754
905
 
755
- .op-feature-prose :global(ul) {
906
+ .op-feature-prose :global(ul:where(:not([data-product-id] *, [data-product-ids] *))) {
756
907
  list-style: disc;
757
908
  }
758
909
 
759
- .op-feature-prose :global(ol) {
910
+ .op-feature-prose :global(ol:where(:not([data-product-id] *, [data-product-ids] *))) {
760
911
  list-style: decimal;
761
912
  }
762
913
 
763
- .op-feature-prose :global(li > :where(ul, ol)) {
914
+ .op-feature-prose :global(li > :where(ul, ol):where(:not([data-product-id] *, [data-product-ids] *))) {
764
915
  margin: 0.25rem 0;
765
916
  }
766
917
 
767
- .op-feature-prose :global(li > p) {
918
+ .op-feature-prose :global(li > p:where(:not([data-product-id] *, [data-product-ids] *))) {
768
919
  margin: 0;
769
920
  }
770
921
 
771
- .op-feature-prose :global(ul[data-type='taskList']) {
922
+ .op-feature-prose :global(ul[data-type='taskList']:where(:not([data-product-id] *, [data-product-ids] *))) {
772
923
  list-style: none;
773
924
  padding-left: 0;
774
925
  }
775
926
 
776
- .op-feature-prose :global(li[data-type='taskItem']) {
927
+ .op-feature-prose :global(li[data-type='taskItem']:where(:not([data-product-id] *, [data-product-ids] *))) {
777
928
  display: flex;
778
929
  align-items: flex-start;
779
930
  gap: 0.5rem;
780
931
  }
781
932
 
782
- .op-feature-prose :global(li[data-type='taskItem'] > label) {
933
+ .op-feature-prose :global(li[data-type='taskItem'] > label:where(:not([data-product-id] *, [data-product-ids] *))) {
783
934
  display: flex;
784
935
  align-items: center;
785
936
  min-height: 1.85em;
786
937
  }
787
938
 
788
- .op-feature-prose :global(li[data-type='taskItem'] > div > p) {
939
+ .op-feature-prose :global(li[data-type='taskItem'] > div > p:where(:not([data-product-id] *, [data-product-ids] *))) {
789
940
  margin: 0;
790
941
  }
791
942
 
792
- .op-feature-prose :global(blockquote) {
943
+ .op-feature-prose :global(blockquote:where(:not([data-product-id] *, [data-product-ids] *))) {
793
944
  border-left: 2px solid var(--orionplace-border);
794
945
  padding-left: 1rem;
795
946
  color: var(--orionplace-muted);
796
947
  }
797
948
 
798
- .op-feature-prose :global(hr) {
949
+ .op-feature-prose :global(hr:where(:not([data-product-id] *, [data-product-ids] *))) {
799
950
  margin: 2rem 0;
800
951
  border: 0;
801
952
  border-top: 1px solid var(--orionplace-border);
802
953
  }
803
954
 
804
- .op-feature-prose :global(code) {
955
+ .op-feature-prose :global(code:where(:not([data-product-id] *, [data-product-ids] *))) {
805
956
  padding: 0.1em 0.3em;
806
957
  font-size: 0.875em;
807
958
  background: var(--orionplace-muted-surface);
808
959
  }
809
960
 
810
- .op-feature-prose :global(pre) {
961
+ .op-feature-prose :global(pre:where(:not([data-product-id] *, [data-product-ids] *))) {
811
962
  overflow-x: auto;
812
963
  padding: 1rem;
813
964
  line-height: 1.6;
@@ -815,13 +966,13 @@
815
966
  background: var(--orionplace-muted-surface);
816
967
  }
817
968
 
818
- .op-feature-prose :global(pre code) {
969
+ .op-feature-prose :global(pre code:where(:not([data-product-id] *, [data-product-ids] *))) {
819
970
  padding: 0;
820
971
  background: transparent;
821
972
  }
822
973
 
823
974
  /* A wide table scrolls inside the article instead of widening the page. */
824
- .op-feature-prose :global(table) {
975
+ .op-feature-prose :global(table:where(:not([data-product-id] *, [data-product-ids] *))) {
825
976
  display: block;
826
977
  max-width: 100%;
827
978
  overflow-x: auto;
@@ -829,7 +980,7 @@
829
980
  border-collapse: collapse;
830
981
  }
831
982
 
832
- .op-feature-prose :global(:where(th, td)) {
983
+ .op-feature-prose :global(:where(th, td):where(:not([data-product-id] *, [data-product-ids] *))) {
833
984
  min-width: 6rem;
834
985
  padding: 0.5rem 0.75rem;
835
986
  vertical-align: top;
@@ -837,46 +988,46 @@
837
988
  border: 1px solid var(--orionplace-border);
838
989
  }
839
990
 
840
- .op-feature-prose :global(th) {
991
+ .op-feature-prose :global(th:where(:not([data-product-id] *, [data-product-ids] *))) {
841
992
  font-weight: 500;
842
993
  background: var(--orionplace-muted-surface);
843
994
  }
844
995
 
845
- .op-feature-prose :global(caption) {
996
+ .op-feature-prose :global(caption:where(:not([data-product-id] *, [data-product-ids] *))) {
846
997
  padding: 0.5rem 0;
847
998
  color: var(--orionplace-muted);
848
999
  text-align: left;
849
1000
  }
850
1001
 
851
- .op-feature-prose :global([data-text-align='center']) {
1002
+ .op-feature-prose :global([data-text-align='center']:where(:not([data-product-id] *, [data-product-ids] *))) {
852
1003
  text-align: center;
853
1004
  }
854
1005
 
855
- .op-feature-prose :global([data-text-align='right']) {
1006
+ .op-feature-prose :global([data-text-align='right']:where(:not([data-product-id] *, [data-product-ids] *))) {
856
1007
  text-align: right;
857
1008
  }
858
1009
 
859
- .op-feature-prose :global([data-vertical-align='middle']) {
1010
+ .op-feature-prose :global([data-vertical-align='middle']:where(:not([data-product-id] *, [data-product-ids] *))) {
860
1011
  vertical-align: middle;
861
1012
  }
862
1013
 
863
- .op-feature-prose :global([data-vertical-align='bottom']) {
1014
+ .op-feature-prose :global([data-vertical-align='bottom']:where(:not([data-product-id] *, [data-product-ids] *))) {
864
1015
  vertical-align: bottom;
865
1016
  }
866
1017
 
867
1018
  /* Images that open in the viewer. */
868
1019
  [data-blog-article-cover][data-blog-image-viewer],
869
- .op-feature-prose :global(img[data-blog-image-viewer]) {
1020
+ .op-feature-prose :global(img[data-blog-image-viewer]:where(:not([data-product-id] *, [data-product-ids] *))) {
870
1021
  cursor: zoom-in;
871
1022
  }
872
1023
 
873
1024
  [data-blog-article-cover][data-blog-image-viewer]:focus-visible,
874
- .op-feature-prose :global(img[data-blog-image-viewer]:focus-visible) {
1025
+ .op-feature-prose :global(img[data-blog-image-viewer]:focus-visible:where(:not([data-product-id] *, [data-product-ids] *))) {
875
1026
  outline: 2px solid var(--orionplace-accent);
876
1027
  outline-offset: 3px;
877
1028
  }
878
1029
 
879
- .op-feature-prose :global(:where(img, video)) {
1030
+ .op-feature-prose :global(:where(img, video):where(:not([data-product-id] *, [data-product-ids] *))) {
880
1031
  display: block;
881
1032
  max-width: 100%;
882
1033
  height: auto;
@@ -886,31 +1037,31 @@
886
1037
  /* A narrow article has no room for text beside a floated image; 592px is
887
1038
  the article's width in the /journal page on a 640px screen. */
888
1039
  @container article (max-width: 591px) {
889
- .op-feature-prose :global(:where(img, video)[style*='float']) {
1040
+ .op-feature-prose :global(:where(img, video)[style*='float']:where(:not([data-product-id] *, [data-product-ids] *))) {
890
1041
  float: none !important;
891
1042
  margin: 1rem auto !important;
892
1043
  }
893
1044
  }
894
1045
 
895
- .op-feature-prose :global(audio) {
1046
+ .op-feature-prose :global(audio:where(:not([data-product-id] *, [data-product-ids] *))) {
896
1047
  display: block;
897
1048
  width: 100%;
898
1049
  }
899
1050
 
900
- .op-feature-prose :global(figcaption) {
1051
+ .op-feature-prose :global(figcaption:where(:not([data-product-id] *, [data-product-ids] *))) {
901
1052
  margin-top: 0.5rem;
902
1053
  font-size: 0.875rem;
903
1054
  color: var(--orionplace-muted);
904
1055
  }
905
1056
 
906
- .op-feature-prose :global(.blog-image-gallery__track) {
1057
+ .op-feature-prose :global(.blog-image-gallery__track:where(:not([data-product-id] *, [data-product-ids] *))) {
907
1058
  display: flex;
908
1059
  gap: 0.5rem;
909
1060
  overflow-x: auto;
910
1061
  scroll-snap-type: x mandatory;
911
1062
  }
912
1063
 
913
- .op-feature-prose :global(.blog-image-gallery__track img) {
1064
+ .op-feature-prose :global(.blog-image-gallery__track img:where(:not([data-product-id] *, [data-product-ids] *))) {
914
1065
  flex: 0 0 auto;
915
1066
  width: auto;
916
1067
  max-width: min(80%, 32rem);
@@ -919,105 +1070,123 @@
919
1070
  scroll-snap-align: start;
920
1071
  }
921
1072
 
922
- .op-feature-prose :global(details) {
1073
+ .op-feature-prose :global(details:where(:not([data-product-id] *, [data-product-ids] *))) {
923
1074
  margin: 1rem 0;
924
1075
  padding: 0.75rem 1rem;
925
1076
  border: 1px solid var(--orionplace-border);
926
1077
  }
927
1078
 
928
- .op-feature-prose :global(summary) {
1079
+ .op-feature-prose :global(summary:where(:not([data-product-id] *, [data-product-ids] *))) {
929
1080
  cursor: pointer;
930
1081
  }
931
1082
 
932
- .op-feature-prose :global([data-type='math-inline']),
933
- .op-feature-prose :global([data-type='math-block']) {
1083
+ .op-feature-prose :global([data-type='math-inline']:where(:not([data-product-id] *, [data-product-ids] *))),
1084
+ .op-feature-prose :global([data-type='math-block']:where(:not([data-product-id] *, [data-product-ids] *))) {
934
1085
  font-family: ui-monospace, monospace;
935
1086
  font-size: 0.9em;
936
1087
  }
937
1088
 
938
- .op-feature-prose :global([data-type='math-block']) {
1089
+ .op-feature-prose :global([data-type='math-block']:where(:not([data-product-id] *, [data-product-ids] *))) {
939
1090
  display: block;
940
1091
  margin: 1rem 0;
941
1092
  overflow-x: auto;
942
1093
  text-align: center;
943
1094
  }
944
1095
 
945
- /* Product placements from the editor have no content in the text. */
1096
+ /* Product placements: cards fill them; one without its products stays hidden. */
946
1097
  .op-feature-prose
947
1098
  :global(:where([data-product-id], [data-product-ids]):empty) {
948
1099
  display: none;
949
1100
  }
950
1101
 
1102
+ .op-feature-prose :global([data-product-ids]) {
1103
+ clear: both;
1104
+ margin: 1.5rem 0;
1105
+ }
1106
+
1107
+ .op-feature-prose :global([data-product-id]:not([data-product-ids])) {
1108
+ width: min(100%, 260px);
1109
+ margin-block: 1.5rem;
1110
+ }
1111
+
1112
+ /* The editor's float has no room beside a card on a narrow page. */
1113
+ @container article (max-width: 480px) {
1114
+ .op-feature-prose :global([data-product-id]:not([data-product-ids])) {
1115
+ float: none !important;
1116
+ margin-inline: auto !important;
1117
+ }
1118
+ }
1119
+
951
1120
  /* The editor's named colors, mixed with the theme text for contrast. */
952
- .op-feature-prose :global([data-text-color='gray']) {
1121
+ .op-feature-prose :global([data-text-color='gray']:where(:not([data-product-id] *, [data-product-ids] *))) {
953
1122
  color: color-mix(in srgb, gray 70%, var(--orionplace-text));
954
1123
  }
955
1124
 
956
- .op-feature-prose :global([data-bg-color='gray']) {
1125
+ .op-feature-prose :global([data-bg-color='gray']:where(:not([data-product-id] *, [data-product-ids] *))) {
957
1126
  background: color-mix(in srgb, gray 18%, transparent);
958
1127
  }
959
1128
 
960
- .op-feature-prose :global([data-text-color='brown']) {
1129
+ .op-feature-prose :global([data-text-color='brown']:where(:not([data-product-id] *, [data-product-ids] *))) {
961
1130
  color: color-mix(in srgb, brown 70%, var(--orionplace-text));
962
1131
  }
963
1132
 
964
- .op-feature-prose :global([data-bg-color='brown']) {
1133
+ .op-feature-prose :global([data-bg-color='brown']:where(:not([data-product-id] *, [data-product-ids] *))) {
965
1134
  background: color-mix(in srgb, brown 18%, transparent);
966
1135
  }
967
1136
 
968
- .op-feature-prose :global([data-text-color='orange']) {
1137
+ .op-feature-prose :global([data-text-color='orange']:where(:not([data-product-id] *, [data-product-ids] *))) {
969
1138
  color: color-mix(in srgb, orange 70%, var(--orionplace-text));
970
1139
  }
971
1140
 
972
- .op-feature-prose :global([data-bg-color='orange']) {
1141
+ .op-feature-prose :global([data-bg-color='orange']:where(:not([data-product-id] *, [data-product-ids] *))) {
973
1142
  background: color-mix(in srgb, orange 18%, transparent);
974
1143
  }
975
1144
 
976
- .op-feature-prose :global([data-text-color='yellow']) {
1145
+ .op-feature-prose :global([data-text-color='yellow']:where(:not([data-product-id] *, [data-product-ids] *))) {
977
1146
  color: color-mix(in srgb, yellow 70%, var(--orionplace-text));
978
1147
  }
979
1148
 
980
- .op-feature-prose :global([data-bg-color='yellow']) {
1149
+ .op-feature-prose :global([data-bg-color='yellow']:where(:not([data-product-id] *, [data-product-ids] *))) {
981
1150
  background: color-mix(in srgb, yellow 18%, transparent);
982
1151
  }
983
1152
 
984
- .op-feature-prose :global([data-text-color='green']) {
1153
+ .op-feature-prose :global([data-text-color='green']:where(:not([data-product-id] *, [data-product-ids] *))) {
985
1154
  color: color-mix(in srgb, green 70%, var(--orionplace-text));
986
1155
  }
987
1156
 
988
- .op-feature-prose :global([data-bg-color='green']) {
1157
+ .op-feature-prose :global([data-bg-color='green']:where(:not([data-product-id] *, [data-product-ids] *))) {
989
1158
  background: color-mix(in srgb, green 18%, transparent);
990
1159
  }
991
1160
 
992
- .op-feature-prose :global([data-text-color='blue']) {
1161
+ .op-feature-prose :global([data-text-color='blue']:where(:not([data-product-id] *, [data-product-ids] *))) {
993
1162
  color: color-mix(in srgb, blue 70%, var(--orionplace-text));
994
1163
  }
995
1164
 
996
- .op-feature-prose :global([data-bg-color='blue']) {
1165
+ .op-feature-prose :global([data-bg-color='blue']:where(:not([data-product-id] *, [data-product-ids] *))) {
997
1166
  background: color-mix(in srgb, blue 18%, transparent);
998
1167
  }
999
1168
 
1000
- .op-feature-prose :global([data-text-color='purple']) {
1169
+ .op-feature-prose :global([data-text-color='purple']:where(:not([data-product-id] *, [data-product-ids] *))) {
1001
1170
  color: color-mix(in srgb, purple 70%, var(--orionplace-text));
1002
1171
  }
1003
1172
 
1004
- .op-feature-prose :global([data-bg-color='purple']) {
1173
+ .op-feature-prose :global([data-bg-color='purple']:where(:not([data-product-id] *, [data-product-ids] *))) {
1005
1174
  background: color-mix(in srgb, purple 18%, transparent);
1006
1175
  }
1007
1176
 
1008
- .op-feature-prose :global([data-text-color='pink']) {
1177
+ .op-feature-prose :global([data-text-color='pink']:where(:not([data-product-id] *, [data-product-ids] *))) {
1009
1178
  color: color-mix(in srgb, pink 70%, var(--orionplace-text));
1010
1179
  }
1011
1180
 
1012
- .op-feature-prose :global([data-bg-color='pink']) {
1181
+ .op-feature-prose :global([data-bg-color='pink']:where(:not([data-product-id] *, [data-product-ids] *))) {
1013
1182
  background: color-mix(in srgb, pink 18%, transparent);
1014
1183
  }
1015
1184
 
1016
- .op-feature-prose :global([data-text-color='red']) {
1185
+ .op-feature-prose :global([data-text-color='red']:where(:not([data-product-id] *, [data-product-ids] *))) {
1017
1186
  color: color-mix(in srgb, red 70%, var(--orionplace-text));
1018
1187
  }
1019
1188
 
1020
- .op-feature-prose :global([data-bg-color='red']) {
1189
+ .op-feature-prose :global([data-bg-color='red']:where(:not([data-product-id] *, [data-product-ids] *))) {
1021
1190
  background: color-mix(in srgb, red 18%, transparent);
1022
1191
  }
1023
1192
  </style>
@@ -52,6 +52,8 @@
52
52
  actionsDisabled?: boolean;
53
53
  purchaseActions?: boolean;
54
54
  variantProductIds?: number[];
55
+ // Badge keys not to show on this card (new / popular / deal).
56
+ hiddenBadges?: string[];
55
57
  onSelect?: (...args: [product: IProduct]) => unknown;
56
58
  onSelectSeller?: (...args: [seller: IProductSeller]) => unknown;
57
59
  onToggleFavorite?: (
@@ -88,6 +90,7 @@
88
90
  actionsDisabled: input_actionsDisabled = false,
89
91
  purchaseActions: input_purchaseActions = true,
90
92
  variantProductIds: input_variantProductIds,
93
+ hiddenBadges: input_hiddenBadges = [],
91
94
  onSelect: callback_onSelect,
92
95
  onSelectSeller: callback_onSelectSeller,
93
96
  onToggleFavorite: callback_onToggleFavorite,
@@ -128,6 +131,9 @@
128
131
  get variantProductIds() {
129
132
  return input_variantProductIds;
130
133
  },
134
+ get hiddenBadges() {
135
+ return input_hiddenBadges;
136
+ },
131
137
  get footer() {
132
138
  return snippet_footer;
133
139
  },
@@ -155,6 +161,11 @@
155
161
  const card = ref<HTMLElement>();
156
162
  const { minHeight } = useOrionplaceStableHeight(card);
157
163
 
164
+ // An empty label hides the badge; undefined keeps the locale's label.
165
+ function hiddenBadgeLabel(key: string) {
166
+ return inputs.hiddenBadges.includes(key) ? '' : undefined;
167
+ }
168
+
158
169
  const store = useOrionplaceStore();
159
170
  const productStore = useOrionplaceProductsStore();
160
171
  const selectionStore = useOrionplaceSelectionsStore();
@@ -664,6 +675,9 @@
664
675
  productId={displayedProductId.value}
665
676
  collection={inputs.collection}
666
677
  lowStockLabel=""
678
+ newLabel={hiddenBadgeLabel('new')}
679
+ popularLabel={hiddenBadgeLabel('popular')}
680
+ dealLabel={hiddenBadgeLabel('deal')}
667
681
  class={['pointer-events-none absolute']}
668
682
  />
669
683
  {/if}
@@ -789,6 +803,9 @@
789
803
  productId={displayedProductId.value}
790
804
  collection={inputs.collection}
791
805
  lowStockLabel=""
806
+ newLabel={hiddenBadgeLabel('new')}
807
+ popularLabel={hiddenBadgeLabel('popular')}
808
+ dealLabel={hiddenBadgeLabel('deal')}
792
809
  />
793
810
  {/if}
794
811
  <ProductCardPrice
@@ -27,6 +27,7 @@
27
27
  limit?: number;
28
28
  productIds?: number[];
29
29
  layout?: 'responsive' | 'grid';
30
+ hiddenBadges?: string[];
30
31
  onSelect?: (...args: [product: IProduct]) => unknown;
31
32
  onSelectSeller?: (...args: [seller: IProductSeller]) => unknown;
32
33
  onRequestSecondaryVariants?: (
@@ -59,6 +60,7 @@
59
60
  limit: input_limit = 0,
60
61
  productIds: input_productIds,
61
62
  layout: input_layout = 'responsive',
63
+ hiddenBadges: input_hiddenBadges = [],
62
64
  onSelect: callback_onSelect,
63
65
  onSelectSeller: callback_onSelectSeller,
64
66
  onRequestSecondaryVariants: callback_onRequestSecondaryVariants,
@@ -89,6 +91,9 @@
89
91
  get layout() {
90
92
  return input_layout;
91
93
  },
94
+ get hiddenBadges() {
95
+ return input_hiddenBadges;
96
+ },
92
97
  get footer() {
93
98
  return snippet_footer;
94
99
  },
@@ -207,6 +212,7 @@
207
212
  productId={product.id}
208
213
  collection={inputs.collection}
209
214
  disabled={inputs.disabled}
215
+ hiddenBadges={inputs.hiddenBadges}
210
216
  onSelect={($event) => {
211
217
  emit('select', $event);
212
218
  }}
@@ -134,6 +134,7 @@ const attributes = [
134
134
  'data-details-content',
135
135
  'data-product-id',
136
136
  'data-product-ids',
137
+ 'data-product-hidden-badges',
137
138
  ];
138
139
 
139
140
  const marginLength = '(-?\\d+(\\.\\d+)?(px|em|rem|%)?|auto)';
@@ -0,0 +1,105 @@
1
+ /*
2
+ * Product placements in article text. The marketplace blog editor writes an
3
+ * empty block where the author put products:
4
+ * <div data-product-id="12"></div> one product card
5
+ * <div data-product-ids="12,15,9"></div> a row of cards (up to 24)
6
+ * with optional data-product-hidden-badges="new,popular,good-deal" and a
7
+ * float or centered style. The article shows product cards in those blocks.
8
+ */
9
+
10
+ export const blogPlacementMaxProducts = 24;
11
+
12
+ // The backend lists at most 35 products per page.
13
+ export const blogPlacementPageSize = 35;
14
+
15
+ export const blogPlacementSelector = '[data-product-id], [data-product-ids]';
16
+
17
+ // Editor badge names → product card badge keys.
18
+ const placementBadges = {
19
+ 'new': 'new',
20
+ 'popular': 'popular',
21
+ 'good-deal': 'deal',
22
+ } as const;
23
+
24
+ export type TBlogPlacementBadge = (typeof placementBadges)[keyof typeof placementBadges];
25
+
26
+ export interface IBlogProductPlacement {
27
+ element: HTMLElement;
28
+ layout: 'single' | 'list';
29
+ productIds: number[];
30
+ hiddenBadges: TBlogPlacementBadge[];
31
+ }
32
+
33
+ /** Positive whole ids from «12,15,9», without repeats, at most `limit`. */
34
+ export function blogPlacementIds(value: string | null | undefined, limit = blogPlacementMaxProducts): number[] {
35
+ const ids: number[] = [];
36
+
37
+ if (!value || value.length > 512) {
38
+ return ids;
39
+ }
40
+ for (const part of value.split(',')) {
41
+ const source = part.trim();
42
+ const id = /^\d+$/.test(source) ? Number(source) : 0;
43
+
44
+ if (Number.isSafeInteger(id) && id > 0 && !ids.includes(id)) {
45
+ ids.push(id);
46
+ }
47
+ if (ids.length >= limit) {
48
+ break;
49
+ }
50
+ }
51
+ return ids;
52
+ }
53
+
54
+ /** Card badge keys the author turned off for this placement. */
55
+ export function blogPlacementHiddenBadges(value: string | null | undefined): TBlogPlacementBadge[] {
56
+ if (!value || value.length > 512) {
57
+ return [];
58
+ }
59
+ const names = value.split(',').map((name) => name.trim().toLowerCase());
60
+
61
+ return Object.entries(placementBadges)
62
+ .filter(([name]) => names.includes(name))
63
+ .map(([, key]) => key);
64
+ }
65
+
66
+ /** The placements of a rendered article, in text order. */
67
+ export function blogProductPlacements(root: ParentNode | null | undefined): IBlogProductPlacement[] {
68
+ if (typeof root?.querySelectorAll !== 'function') {
69
+ return [];
70
+ }
71
+ return Array.from(root.querySelectorAll<HTMLElement>(blogPlacementSelector)).flatMap((element) => {
72
+ // Product cards inside a filled placement carry data-product-id too.
73
+ if (element.parentElement?.closest(blogPlacementSelector)) {
74
+ return [];
75
+ }
76
+ const list = element.hasAttribute('data-product-ids');
77
+ const productIds = list
78
+ ? blogPlacementIds(element.getAttribute('data-product-ids'))
79
+ : blogPlacementIds(element.getAttribute('data-product-id'), 1);
80
+
81
+ return productIds.length
82
+ ? [{
83
+ element,
84
+ layout: list ? 'list' : 'single',
85
+ productIds,
86
+ hiddenBadges: blogPlacementHiddenBadges(element.getAttribute('data-product-hidden-badges')),
87
+ }]
88
+ : [];
89
+ });
90
+ }
91
+
92
+ /** Every product id of the placements, once, in text order. */
93
+ export function blogPlacementProductIds(placements: Pick<IBlogProductPlacement, 'productIds'>[]): number[] {
94
+ return [...new Set(placements.flatMap((placement) => placement.productIds))];
95
+ }
96
+
97
+ /** Ids split into requests the backend answers in one page each. */
98
+ export function blogPlacementRequests(ids: number[]): number[][] {
99
+ const requests: number[][] = [];
100
+
101
+ for (let start = 0; start < ids.length; start += blogPlacementPageSize) {
102
+ requests.push(ids.slice(start, start + blogPlacementPageSize));
103
+ }
104
+ return requests;
105
+ }