itube-specs 0.0.868 → 0.0.870
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/assets/scss/base/_hacks.scss +2 -2
- package/assets/scss/mixins/_surface.scss +2 -1
- package/assets/scss/themes/dark/_theme.scss +1 -32
- package/assets/scss/themes/light/_theme.scss +9 -40
- package/components/layout/header.vue +3 -9
- package/components/layout/language.vue +14 -52
- package/components/search/input.vue +4 -0
- package/components/ui/input.vue +5 -0
- package/composables/__tests__/use-client-country.test.ts +121 -0
- package/composables/use-client-country.ts +73 -0
- package/composables/use-meta.ts +13 -2
- package/package.json +1 -1
- package/pages/categories/index.vue +0 -11
- package/pages/index.vue +20 -21
- package/plugins/countries.server.ts +22 -40
- package/server/middleware/country.ts +3 -3
|
@@ -13,8 +13,8 @@ select:-webkit-autofill,
|
|
|
13
13
|
select:-webkit-autofill:hover,
|
|
14
14
|
select:-webkit-autofill:focus {
|
|
15
15
|
box-shadow: 0 0 0 1000px transparent inset !important;
|
|
16
|
-
-webkit-text-fill-color: $input-autofill-text !important;
|
|
17
|
-
color: $input-autofill-text !important;
|
|
16
|
+
-webkit-text-fill-color: var(--input-autofill-text, #{$input-autofill-text}) !important;
|
|
17
|
+
color: var(--input-autofill-text, #{$input-autofill-text}) !important;
|
|
18
18
|
border: none !important;
|
|
19
19
|
transition: background-color 5000s ease-in-out 0s !important;
|
|
20
20
|
font-size: inherit !important;
|
|
@@ -37,7 +37,8 @@ $surface-roles: (
|
|
|
37
37
|
control-bg: (--input-bg, --select-bg),
|
|
38
38
|
control-compact-bg: (--input-s-bg,),
|
|
39
39
|
control-bg--disabled: (--input-bg--disabled,),
|
|
40
|
-
control-text: (--input-text, --select-text),
|
|
40
|
+
control-text: (--input-text, --input-autofill-text, --select-text),
|
|
41
|
+
control-compact-text: (--input-s-text,),
|
|
41
42
|
control-text--disabled: (--input-text--disabled,),
|
|
42
43
|
control-placeholder: (--input-placeholder, --select-placeholder),
|
|
43
44
|
control-label: (--input-label, --select-label),
|
|
@@ -77,10 +77,10 @@ $input-label: $color-zinc-400;
|
|
|
77
77
|
$input-icon: inherit;
|
|
78
78
|
$input-pre-icon: $color-zinc-500;
|
|
79
79
|
$input-s-bg: $color-zinc-800;
|
|
80
|
+
$input-s-text: $color-white;
|
|
80
81
|
$input-s-border: transparent;
|
|
81
82
|
$input-s-border--focus: $color-zinc-600;
|
|
82
83
|
$input-text--disabled: $color-zinc-400;
|
|
83
|
-
$input-text--error: $color-red-500;
|
|
84
84
|
$input-border--error: $color-red-500;
|
|
85
85
|
$input-autofill-text: $color-white;
|
|
86
86
|
$input-button-text: $color-zinc-500;
|
|
@@ -218,8 +218,6 @@ $video-info-description-text: $color-zinc-400;
|
|
|
218
218
|
// ─── Video interface ───
|
|
219
219
|
$video-interface-button-bg: $color-zinc-800;
|
|
220
220
|
$video-interface-button-text: $color-zinc-400;
|
|
221
|
-
$video-interface-button-bg--hover: $color-zinc-700;
|
|
222
|
-
$video-interface-button-text--hover: $color-white;
|
|
223
221
|
|
|
224
222
|
// ─── Video channel ───
|
|
225
223
|
$video-channel-bg: $color-zinc-800-60;
|
|
@@ -428,9 +426,7 @@ $dropdown-crest: $color-zinc-500;
|
|
|
428
426
|
$dropdown-separator: $color-zinc-800-60;
|
|
429
427
|
|
|
430
428
|
// ─── Snackbar ───
|
|
431
|
-
$snackbar-bg: $color-zinc-800;
|
|
432
429
|
$snackbar-text: $color-zinc-200;
|
|
433
|
-
$snackbar-border: $color-zinc-700;
|
|
434
430
|
$snackbar-close: $color-zinc-500;
|
|
435
431
|
$snackbar-close--hover: $color-zinc-300;
|
|
436
432
|
$snackbar-close-bg--hover: $color-zinc-700;
|
|
@@ -528,7 +524,6 @@ $radio-bg: $color-zinc-800;
|
|
|
528
524
|
$radio-border--checked: $color-indigo-600;
|
|
529
525
|
$radio-bg--checked: $color-indigo-600;
|
|
530
526
|
$radio-label: $color-white;
|
|
531
|
-
$radio-label--hover: $color-zinc-300;
|
|
532
527
|
$radio-description: $color-zinc-500;
|
|
533
528
|
$radio-border--disabled: $color-zinc-800;
|
|
534
529
|
$radio-text--disabled: $color-zinc-400;
|
|
@@ -600,9 +595,6 @@ $expand-row-pre-text: $color-zinc-500;
|
|
|
600
595
|
$full-description-text: $color-zinc-400;
|
|
601
596
|
$full-description-bg: $color-zinc-800-50;
|
|
602
597
|
|
|
603
|
-
// ─── Swiper items ───
|
|
604
|
-
$swiper-items-all-text: $color-zinc-400;
|
|
605
|
-
|
|
606
598
|
// ─── Report ───
|
|
607
599
|
$report-category-border: $color-zinc-800;
|
|
608
600
|
$report-category-bg--hover: $color-zinc-800-50;
|
|
@@ -612,9 +604,6 @@ $report-text: $color-zinc-400;
|
|
|
612
604
|
$report-required: $color-red-500;
|
|
613
605
|
$report-text--error: $color-red-500;
|
|
614
606
|
|
|
615
|
-
// ─── Model filters ───
|
|
616
|
-
$model-filters-border: $color-zinc-800;
|
|
617
|
-
|
|
618
607
|
// ─── Share ───
|
|
619
608
|
$share-cancel-border: $color-zinc-800;
|
|
620
609
|
$share-button-copy-bg: $color-zinc-800;
|
|
@@ -689,10 +678,6 @@ $player-menu-scene-time: $color-zinc-500; // text-zinc-500
|
|
|
689
678
|
$player-menu-scene-time--current: $color-zinc-200; // text-zinc-200
|
|
690
679
|
$player-menu-section-title: $color-zinc-400;
|
|
691
680
|
|
|
692
|
-
// ─── Filter page ───
|
|
693
|
-
$filter-page-group-title: $color-white;
|
|
694
|
-
$filter-page-group-border: $color-zinc-800;
|
|
695
|
-
|
|
696
681
|
// ─── Search ───
|
|
697
682
|
$search-close-bg: $color-zinc-700;
|
|
698
683
|
$search-close-text: $color-zinc-300;
|
|
@@ -728,10 +713,8 @@ $filters-main-bg--hover: $color-zinc-700-70;
|
|
|
728
713
|
|
|
729
714
|
// ─── Info grid ───
|
|
730
715
|
$info-grid-group-title: $color-zinc-300;
|
|
731
|
-
$info-grid-item-bg: $color-zinc-700-50;
|
|
732
716
|
$info-grid-item-border: $color-zinc-700-50;
|
|
733
717
|
$info-grid-item-bg--hover: $color-zinc-700-30;
|
|
734
|
-
$info-grid-item-border--hover: $color-zinc-600;
|
|
735
718
|
$info-grid-item-title: $color-zinc-500;
|
|
736
719
|
$info-grid-item-title-bg: $color-zinc-900;
|
|
737
720
|
$info-grid-item-value-bg: $color-zinc-900;
|
|
@@ -739,7 +722,6 @@ $info-grid-item-value-bg--link: $color-zinc-700-50;
|
|
|
739
722
|
$info-grid-item-value-link-bg--hover: $color-zinc-700-50;
|
|
740
723
|
$info-grid-item-value--link: $color-white;
|
|
741
724
|
$info-grid-item-value-border: $color-zinc-700-50;
|
|
742
|
-
$info-grid-item-values: $color-white;
|
|
743
725
|
$info-grid-item-success: $color-green-400;
|
|
744
726
|
$info-grid-item-warning: $color-red-400;
|
|
745
727
|
$info-grid-item-icon: $color-zinc-400;
|
|
@@ -783,7 +765,6 @@ $shadow-color--active: $mono-black-42; // тень при drag
|
|
|
783
765
|
|
|
784
766
|
// ─── Page models ───
|
|
785
767
|
$page-models-popular-border: $color-zinc-800;
|
|
786
|
-
$page-models-categories-border: $color-zinc-800-50;
|
|
787
768
|
|
|
788
769
|
// ─── Page model videos ───
|
|
789
770
|
$page-model-videos-section-border: $color-zinc-800-50;
|
|
@@ -809,7 +790,6 @@ $model-card-params-footer: $color-zinc-500;
|
|
|
809
790
|
$model-card-name: $color-zinc-200;
|
|
810
791
|
$model-card-name--hover: $color-indigo-400;
|
|
811
792
|
$model-card-count: $color-zinc-500;
|
|
812
|
-
$model-card-divider: $color-zinc-700;
|
|
813
793
|
|
|
814
794
|
// ─── Playlist card ───
|
|
815
795
|
$playlist-card-bg: $color-zinc-800-50;
|
|
@@ -896,7 +876,6 @@ $native-card-secondary: $color-zinc-500;
|
|
|
896
876
|
|
|
897
877
|
// ─── Page: grouped categories ───
|
|
898
878
|
$grouped-highlight-border: $color-zinc-800;
|
|
899
|
-
$grouped-separator: $color-zinc-800;
|
|
900
879
|
$grouped-category-icon: $color-zinc-500;
|
|
901
880
|
$grouped-footer-text: $color-zinc-500;
|
|
902
881
|
$grouped-section-border: $color-zinc-800;
|
|
@@ -944,16 +923,6 @@ $contacts-wrapper-bg: $color-zinc-900;
|
|
|
944
923
|
$contacts-link: $color-indigo-400;
|
|
945
924
|
$contacts-link--hover: $color-indigo-300;
|
|
946
925
|
|
|
947
|
-
// ─── Page: playlist ───
|
|
948
|
-
$page-playlist-edit: $color-zinc-500;
|
|
949
|
-
$page-playlist-edit-border: $color-zinc-800;
|
|
950
|
-
$page-playlist-header-border: $color-zinc-800;
|
|
951
|
-
$page-playlist-avatar-bg: $color-indigo-600;
|
|
952
|
-
$page-playlist-avatar-text: $color-white;
|
|
953
|
-
$page-playlist-user-name: $color-zinc-400;
|
|
954
|
-
$page-playlist-private-bg: $color-zinc-800-60;
|
|
955
|
-
$page-playlist-like-bg: $color-zinc-900;
|
|
956
|
-
|
|
957
926
|
// ─── Page: playlists ───
|
|
958
927
|
$page-playlists-border: $color-zinc-800;
|
|
959
928
|
|
|
@@ -87,14 +87,14 @@ $input-label: $color-gray-700;
|
|
|
87
87
|
$input-icon: $color-gray-500;
|
|
88
88
|
$input-pre-icon: $color-gray-500;
|
|
89
89
|
$input-s-bg: $color-slate-750;
|
|
90
|
+
$input-s-text: $color-white;
|
|
90
91
|
$input-s-border: transparent;
|
|
91
92
|
$input-s-border--focus: $color-indigo-soft;
|
|
92
93
|
$input-text--disabled: $color-gray-400;
|
|
93
|
-
$input-text--error: $color-red-500;
|
|
94
94
|
$input-border--error: $color-red-500;
|
|
95
|
-
$input-autofill-text: $color-
|
|
95
|
+
$input-autofill-text: $color-gray-900;
|
|
96
96
|
$input-button-text: $color-gray-500;
|
|
97
|
-
$input-button-text--hover: $color-
|
|
97
|
+
$input-button-text--hover: $color-gray-500;
|
|
98
98
|
|
|
99
99
|
// ─── Surfaces ("островки") ───
|
|
100
100
|
// Только отличия от базовых токенов. Подложки нет в карте — нет и оверрайда.
|
|
@@ -207,7 +207,7 @@ $popup-auth-icon-bg: $color-indigo-600;
|
|
|
207
207
|
$popup-border: transparent;
|
|
208
208
|
$popup-footer-border: $color-slate-750;
|
|
209
209
|
$popup-text: $color-white;
|
|
210
|
-
$popup-scrollbar: $color-gray-
|
|
210
|
+
$popup-scrollbar: $color-gray-400;
|
|
211
211
|
$popup-close-icon: $color-gray-400;
|
|
212
212
|
$popup-close-icon--hover: $color-white;
|
|
213
213
|
|
|
@@ -275,8 +275,6 @@ $video-info-description-text: $color-gray-700;
|
|
|
275
275
|
// ─── Video interface ───
|
|
276
276
|
$video-interface-button-bg: $color-slate-750;
|
|
277
277
|
$video-interface-button-text: $color-gray-400;
|
|
278
|
-
$video-interface-button-bg--hover: $color-slate-700;
|
|
279
|
-
$video-interface-button-text--hover: $color-white;
|
|
280
278
|
|
|
281
279
|
// ─── Video channel ───
|
|
282
280
|
$video-channel-bg: $color-gray-200;
|
|
@@ -485,9 +483,7 @@ $dropdown-crest: $color-gray-400;
|
|
|
485
483
|
$dropdown-separator: $color-slate-750;
|
|
486
484
|
|
|
487
485
|
// ─── Snackbar ───
|
|
488
|
-
$snackbar-bg: $color-slate-750;
|
|
489
486
|
$snackbar-text: $color-gray-200;
|
|
490
|
-
$snackbar-border: $color-slate-700;
|
|
491
487
|
$snackbar-close: $color-gray-500;
|
|
492
488
|
$snackbar-close--hover: $color-gray-300;
|
|
493
489
|
$snackbar-close-bg--hover: $color-slate-700;
|
|
@@ -568,8 +564,8 @@ $checkbox-border: $color-gray-400;
|
|
|
568
564
|
$checkbox-color: $color-white;
|
|
569
565
|
$checkbox-bg--checked: $color-indigo-soft;
|
|
570
566
|
$checkbox-border--checked: $color-indigo-600;
|
|
571
|
-
$checkbox-label: $color-gray-
|
|
572
|
-
$checkbox-label--hover: $color-gray-
|
|
567
|
+
$checkbox-label: $color-gray-700;
|
|
568
|
+
$checkbox-label--hover: $color-gray-500;
|
|
573
569
|
$checkbox-description: $color-gray-500;
|
|
574
570
|
$checkbox-border--disabled: transparent;
|
|
575
571
|
$checkbox-text--disabled: $color-gray-400;
|
|
@@ -581,7 +577,6 @@ $radio-bg: transparent;
|
|
|
581
577
|
$radio-border--checked: $color-indigo-soft;
|
|
582
578
|
$radio-bg--checked: $color-indigo-soft;
|
|
583
579
|
$radio-label: $color-gray-700;
|
|
584
|
-
$radio-label--hover: $color-gray-300;
|
|
585
580
|
$radio-description: $color-gray-500;
|
|
586
581
|
$radio-border--disabled: $color-slate-700;
|
|
587
582
|
$radio-text--disabled: $color-gray-400;
|
|
@@ -653,9 +648,6 @@ $expand-row-pre-text: $color-gray-500;
|
|
|
653
648
|
$full-description-text: $color-gray-600;
|
|
654
649
|
$full-description-bg: $color-gray-100;
|
|
655
650
|
|
|
656
|
-
// ─── Swiper items ───
|
|
657
|
-
$swiper-items-all-text: $color-gray-400;
|
|
658
|
-
|
|
659
651
|
// ─── Report ───
|
|
660
652
|
$report-category-border: $color-slate-700;
|
|
661
653
|
$report-category-bg--hover: $color-slate-750;
|
|
@@ -665,9 +657,6 @@ $report-text: $color-gray-400;
|
|
|
665
657
|
$report-required: $color-red-500;
|
|
666
658
|
$report-text--error: $color-red-500;
|
|
667
659
|
|
|
668
|
-
// ─── Model filters ───
|
|
669
|
-
$model-filters-border: transparent;
|
|
670
|
-
|
|
671
660
|
// ─── Share ───
|
|
672
661
|
$share-cancel-border: transparent;
|
|
673
662
|
$share-button-copy-bg: $color-slate-750;
|
|
@@ -742,10 +731,6 @@ $player-menu-scene-time: $color-gray-500; // text-zinc-500
|
|
|
742
731
|
$player-menu-scene-time--current: $color-gray-200; // text-zinc-200
|
|
743
732
|
$player-menu-section-title: $color-gray-400;
|
|
744
733
|
|
|
745
|
-
// ─── Filter page ───
|
|
746
|
-
$filter-page-group-title: $color-white;
|
|
747
|
-
$filter-page-group-border: transparent;
|
|
748
|
-
|
|
749
734
|
// ─── Filters main ───
|
|
750
735
|
$filters-main-slider-bg: $color-night;
|
|
751
736
|
$filters-main-slider-border: transparent;
|
|
@@ -758,10 +743,8 @@ $filters-main-bg--hover: $button-secondary-bg--hover;
|
|
|
758
743
|
|
|
759
744
|
// ─── Info grid ───
|
|
760
745
|
$info-grid-group-title: $color-gray-600;
|
|
761
|
-
$info-grid-item-bg: $color-white;
|
|
762
746
|
$info-grid-item-border: $color-gray-300;
|
|
763
747
|
$info-grid-item-bg--hover: $color-slate-700;
|
|
764
|
-
$info-grid-item-border--hover: $color-gray-600;
|
|
765
748
|
$info-grid-item-title: $color-gray-500;
|
|
766
749
|
$info-grid-item-title-bg: $color-white;
|
|
767
750
|
$info-grid-item-value-bg: $color-gray-100;
|
|
@@ -769,7 +752,6 @@ $info-grid-item-value-bg--link: $color-slate-700;
|
|
|
769
752
|
$info-grid-item-value-link-bg--hover: $color-slate-750;
|
|
770
753
|
$info-grid-item-value--link: $color-white;
|
|
771
754
|
$info-grid-item-value-border: $color-gray-300;
|
|
772
|
-
$info-grid-item-values: $color-white;
|
|
773
755
|
$info-grid-item-success: $color-green-400;
|
|
774
756
|
$info-grid-item-warning: $color-red-400;
|
|
775
757
|
$info-grid-item-icon: $color-gray-400;
|
|
@@ -813,7 +795,6 @@ $shadow-color--active: $mono-black-42; // тень при drag
|
|
|
813
795
|
|
|
814
796
|
// ─── Page models ───
|
|
815
797
|
$page-models-popular-border: transparent;
|
|
816
|
-
$page-models-categories-border: transparent;
|
|
817
798
|
|
|
818
799
|
// ─── Page model videos ───
|
|
819
800
|
$page-model-videos-section-border: transparent;
|
|
@@ -839,7 +820,6 @@ $model-card-params-footer: $color-gray-600;
|
|
|
839
820
|
$model-card-name: $color-gray-900;
|
|
840
821
|
$model-card-name--hover: $model-card-name;
|
|
841
822
|
$model-card-count: $color-gray-600;
|
|
842
|
-
$model-card-divider: $color-gray-400;
|
|
843
823
|
|
|
844
824
|
// ─── Playlist card ───
|
|
845
825
|
$playlist-card-bg: $color-gray-100;
|
|
@@ -883,8 +863,8 @@ $channel-card-mini-name: $color-white;
|
|
|
883
863
|
$channel-card-mini-info: $color-gray-400;
|
|
884
864
|
|
|
885
865
|
// ─── Playlist card mini ───
|
|
886
|
-
$playlist-card-mini-empty-gradient-from: $color-
|
|
887
|
-
$playlist-card-mini-empty-gradient-to: $color-
|
|
866
|
+
$playlist-card-mini-empty-gradient-from: $color-slate-700;
|
|
867
|
+
$playlist-card-mini-empty-gradient-to: $color-slate-750;
|
|
888
868
|
$playlist-card-mini-empty-text: $color-white;
|
|
889
869
|
$playlist-card-mini-name: $color-white;
|
|
890
870
|
$playlist-card-mini-user: $color-gray-500;
|
|
@@ -920,13 +900,12 @@ $auth-register-link--hover: $color-indigo-blue;
|
|
|
920
900
|
$auth-register-login: $color-gray-400;
|
|
921
901
|
|
|
922
902
|
// ─── Native card ───
|
|
923
|
-
$native-card-bg: $color-
|
|
903
|
+
$native-card-bg: $color-slate-800;
|
|
924
904
|
$native-card-primary: $color-gray-400;
|
|
925
905
|
$native-card-secondary: $color-gray-500;
|
|
926
906
|
|
|
927
907
|
// ─── Page: grouped categories ───
|
|
928
908
|
$grouped-highlight-border: transparent;
|
|
929
|
-
$grouped-separator: $color-gray-800;
|
|
930
909
|
$grouped-category-icon: $color-gray-500;
|
|
931
910
|
$grouped-footer-text: $color-gray-500;
|
|
932
911
|
$grouped-section-border: $color-gray-200;
|
|
@@ -974,16 +953,6 @@ $contacts-wrapper-bg: $color-gray-200;
|
|
|
974
953
|
$contacts-link: $color-indigo-400;
|
|
975
954
|
$contacts-link--hover: $color-indigo-300;
|
|
976
955
|
|
|
977
|
-
// ─── Page: playlist ───
|
|
978
|
-
$page-playlist-edit: $color-gray-500;
|
|
979
|
-
$page-playlist-edit-border: $color-slate-700;
|
|
980
|
-
$page-playlist-header-border: $color-slate-700;
|
|
981
|
-
$page-playlist-avatar-bg: $color-indigo-600;
|
|
982
|
-
$page-playlist-avatar-text: $color-white;
|
|
983
|
-
$page-playlist-user-name: $color-gray-400;
|
|
984
|
-
$page-playlist-private-bg: $color-slate-750;
|
|
985
|
-
$page-playlist-like-bg: $color-slate-800;
|
|
986
|
-
|
|
987
956
|
// ─── Page: playlists ───
|
|
988
957
|
$page-playlists-border: transparent;
|
|
989
958
|
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
@click="showLanguageMenu"
|
|
28
28
|
>
|
|
29
29
|
<img
|
|
30
|
-
v-if="
|
|
31
|
-
:src="
|
|
30
|
+
v-if="clientCountryFlag"
|
|
31
|
+
:src="clientCountryFlag"
|
|
32
32
|
:alt="clientCountryIcon"
|
|
33
33
|
class="layout-header__language-icon"
|
|
34
34
|
width="24"
|
|
@@ -96,14 +96,8 @@
|
|
|
96
96
|
import { AuthSteps } from '../../runtime';
|
|
97
97
|
import { onClickOutside } from '@vueuse/core';
|
|
98
98
|
import { AuthorizationApiService } from '../../services/api/authorization.service';
|
|
99
|
-
import { isoCodeMap } from '../../utils/iso-code-map';
|
|
100
99
|
|
|
101
|
-
const
|
|
102
|
-
const clientCountryIcon = computed(() => {
|
|
103
|
-
const code = clientCountryCode.value;
|
|
104
|
-
if (!code) return '';
|
|
105
|
-
return isoCodeMap[code] || code;
|
|
106
|
-
});
|
|
100
|
+
const { icon: clientCountryIcon, flagSrc: clientCountryFlag } = useClientCountry();
|
|
107
101
|
|
|
108
102
|
const burgerShow = ref(false);
|
|
109
103
|
const showLanguage = ref(false);
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
>
|
|
43
43
|
<summary class="layout-language__summary">
|
|
44
44
|
<img
|
|
45
|
-
v-if="
|
|
45
|
+
v-if="clientCountryFlag"
|
|
46
46
|
:alt="clientCountryName"
|
|
47
|
-
:src="
|
|
47
|
+
:src="clientCountryFlag"
|
|
48
48
|
class="layout-language__icon"
|
|
49
49
|
width="20"
|
|
50
50
|
height="20"
|
|
@@ -109,8 +109,6 @@
|
|
|
109
109
|
|
|
110
110
|
<script setup lang="ts">
|
|
111
111
|
import type { ISelectItem } from '../../types';
|
|
112
|
-
import { isoCodeMap } from '../../utils/iso-code-map';
|
|
113
|
-
import { DictionariesApiService } from '../../services/api/dictionaries.service';
|
|
114
112
|
|
|
115
113
|
const { locale, locales } = useI18n();
|
|
116
114
|
const switchLocalePath = useSwitchLocalePath();
|
|
@@ -119,22 +117,14 @@ defineProps<{
|
|
|
119
117
|
iconSize: string
|
|
120
118
|
}>();
|
|
121
119
|
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
const clientCountryNameState = useState<string>('clientCountryName', () => '');
|
|
132
|
-
|
|
133
|
-
const clientCountryName = computed(() => {
|
|
134
|
-
if (!clientCountryIcon.value) return '';
|
|
135
|
-
const fromList = countriesItems.value.find(item => item.icon === clientCountryIcon.value)?.value;
|
|
136
|
-
return fromList || clientCountryNameState.value || '';
|
|
137
|
-
});
|
|
120
|
+
const {
|
|
121
|
+
icon: clientCountryIcon,
|
|
122
|
+
name: clientCountryName,
|
|
123
|
+
flagSrc: clientCountryFlag,
|
|
124
|
+
countriesItems,
|
|
125
|
+
setCountry,
|
|
126
|
+
loadCountries,
|
|
127
|
+
} = useClientCountry();
|
|
138
128
|
|
|
139
129
|
const isCountryOpen = ref(false);
|
|
140
130
|
const countryValue = ref('');
|
|
@@ -144,26 +134,6 @@ const filteredCountries = computed(() => {
|
|
|
144
134
|
return countriesItems.value.filter(item => item.title.toLowerCase().includes(query));
|
|
145
135
|
});
|
|
146
136
|
|
|
147
|
-
const isCountryLoading = ref(false);
|
|
148
|
-
|
|
149
|
-
// Список стран не приходит в payload (см. plugins/countries.server.ts) — догружаем
|
|
150
|
-
// его лениво на клиенте при первом открытии селектора.
|
|
151
|
-
async function loadCountries() {
|
|
152
|
-
if (countriesItems.value.length || isCountryLoading.value) return;
|
|
153
|
-
isCountryLoading.value = true;
|
|
154
|
-
try {
|
|
155
|
-
const data = await DictionariesApiService.getCountries();
|
|
156
|
-
countriesItems.value = (data?.countries ?? []).map((item) => {
|
|
157
|
-
const iso = item.isoCodes[0].toLowerCase();
|
|
158
|
-
return { title: item.title, value: item.title, icon: isoCodeMap[iso] || iso, key: iso };
|
|
159
|
-
});
|
|
160
|
-
} catch {
|
|
161
|
-
// оставляем пустым — следующее открытие попробует снова
|
|
162
|
-
} finally {
|
|
163
|
-
isCountryLoading.value = false;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
137
|
function onCountryToggle(e: Event) {
|
|
168
138
|
isCountryOpen.value = (e.target as HTMLDetailsElement).open;
|
|
169
139
|
if (isCountryOpen.value) loadCountries();
|
|
@@ -191,18 +161,10 @@ function changeLocale(locale: string) {
|
|
|
191
161
|
window.location.href = switchLocalePath(locale);
|
|
192
162
|
}
|
|
193
163
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
function changeCountry(item: ISelectItem) {
|
|
200
|
-
const code = item.key || '';
|
|
201
|
-
clientCountryCode.value = code;
|
|
202
|
-
clientCountryNameState.value = item.value;
|
|
203
|
-
countryCookie.value = code;
|
|
204
|
-
countryNameCookie.value = item.value;
|
|
205
|
-
countryNameCodeCookie.value = code;
|
|
164
|
+
async function changeCountry(item: ISelectItem) {
|
|
165
|
+
setCountry(item);
|
|
166
|
+
// куку пишет вотчер useCookie — ждём флаш, иначе навигация может срезать запись
|
|
167
|
+
await nextTick();
|
|
206
168
|
window.location.href = '/';
|
|
207
169
|
}
|
|
208
170
|
</script>
|
|
@@ -121,12 +121,16 @@ $icon-size: 16px;
|
|
|
121
121
|
padding-block: 12px;
|
|
122
122
|
|
|
123
123
|
@include to-br(sm) {
|
|
124
|
+
--input-autofill-text: #{$search-text--mobile};
|
|
125
|
+
|
|
124
126
|
color: $search-text--mobile;
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
@include from-br(sm) {
|
|
128
130
|
@include font-sm;
|
|
129
131
|
|
|
132
|
+
--input-autofill-text: #{$search-text};
|
|
133
|
+
|
|
130
134
|
color: $search-text;
|
|
131
135
|
}
|
|
132
136
|
}
|
package/components/ui/input.vue
CHANGED
|
@@ -336,6 +336,11 @@ const normalizedValue = computed(() => {
|
|
|
336
336
|
color: var(--input-pre-icon, #{$input-pre-icon});
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
+
.ui-input.ui-input--s {
|
|
340
|
+
--input-text: var(--input-s-text, #{$input-s-text});
|
|
341
|
+
--input-autofill-text: var(--input-s-text, #{$input-s-text});
|
|
342
|
+
}
|
|
343
|
+
|
|
339
344
|
.ui-input.ui-input--s .ui-input__input {
|
|
340
345
|
padding: 8px 16px;
|
|
341
346
|
background-color: var(--input-s-bg, #{$input-s-bg});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// @vitest-environment nuxt
|
|
2
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
3
|
+
import { useClientCountry } from '../use-client-country';
|
|
4
|
+
|
|
5
|
+
const getCountries = vi.fn();
|
|
6
|
+
|
|
7
|
+
vi.mock('../../services/api/dictionaries.service', () => ({
|
|
8
|
+
DictionariesApiService: { getCountries: () => getCountries() },
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
const setCode = (code: string) => useState<string>('clientCountryCode', () => '').value = code;
|
|
12
|
+
const setResolvedName = (name: string) => useState<string>('clientCountryName', () => '').value = name;
|
|
13
|
+
|
|
14
|
+
describe('useClientCountry', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
useNuxtApp().payload.state = {};
|
|
17
|
+
getCountries.mockReset();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('без кода не отдаёт ни флаг, ни имя', () => {
|
|
21
|
+
const { icon, name, flagSrc } = useClientCountry();
|
|
22
|
+
|
|
23
|
+
expect(icon.value).toBe('');
|
|
24
|
+
expect(name.value).toBe('');
|
|
25
|
+
expect(flagSrc.value).toBe('');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('строит иконку и путь к флагу из кода', () => {
|
|
29
|
+
setCode('ad');
|
|
30
|
+
setResolvedName('Andorra');
|
|
31
|
+
const { icon, flagSrc } = useClientCountry();
|
|
32
|
+
|
|
33
|
+
expect(icon.value).toBe('ad');
|
|
34
|
+
expect(flagSrc.value).toBe('/icons/flags/ad.svg');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('нормализует код: регистр и пробелы', () => {
|
|
38
|
+
setCode(' AD ');
|
|
39
|
+
setResolvedName('Andorra');
|
|
40
|
+
const { icon, flagSrc } = useClientCountry();
|
|
41
|
+
|
|
42
|
+
expect(icon.value).toBe('ad');
|
|
43
|
+
expect(flagSrc.value).toBe('/icons/flags/ad.svg');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('учитывает страны, где код флага отличается от ISO-кода', () => {
|
|
47
|
+
setCode('cn');
|
|
48
|
+
setResolvedName('China');
|
|
49
|
+
const { icon, flagSrc } = useClientCountry();
|
|
50
|
+
|
|
51
|
+
expect(icon.value).toBe('zh');
|
|
52
|
+
expect(flagSrc.value).toBe('/icons/flags/zh.svg');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('не показывает флаг для служебного кода, которого нет в словаре', () => {
|
|
56
|
+
// Cloudflare отдаёт XX для неизвестного IP и T1 для Tor — файла такого флага нет
|
|
57
|
+
setCode('xx');
|
|
58
|
+
const { icon, name, flagSrc } = useClientCountry();
|
|
59
|
+
|
|
60
|
+
expect(icon.value).toBe('xx');
|
|
61
|
+
expect(name.value).toBe('');
|
|
62
|
+
expect(flagSrc.value).toBe('');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('имя следует за кодом и не может остаться от другой страны', async () => {
|
|
66
|
+
setCode('ad');
|
|
67
|
+
// имя, доставшееся от предыдущей страны — именно этот рассинхрон ловим
|
|
68
|
+
setResolvedName('USA');
|
|
69
|
+
getCountries.mockResolvedValue({
|
|
70
|
+
countries: [
|
|
71
|
+
{ title: 'Andorra', isoCodes: ['AD', 'AND'] },
|
|
72
|
+
{ title: 'USA', isoCodes: ['US', 'USA'] },
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const { name, flagSrc, loadCountries } = useClientCountry();
|
|
77
|
+
await loadCountries();
|
|
78
|
+
|
|
79
|
+
expect(name.value).toBe('Andorra');
|
|
80
|
+
expect(flagSrc.value).toBe('/icons/flags/ad.svg');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('до загрузки списка берёт имя, отрезолвленное на сервере', () => {
|
|
84
|
+
setCode('ad');
|
|
85
|
+
setResolvedName('Andorra');
|
|
86
|
+
const { name } = useClientCountry();
|
|
87
|
+
|
|
88
|
+
expect(name.value).toBe('Andorra');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('выбор страны меняет и код, и имя', () => {
|
|
92
|
+
setCode('ad');
|
|
93
|
+
setResolvedName('Andorra');
|
|
94
|
+
const { code, name, flagSrc, setCountry } = useClientCountry();
|
|
95
|
+
|
|
96
|
+
setCountry({ title: 'USA', value: 'USA', icon: 'us', key: 'us' });
|
|
97
|
+
|
|
98
|
+
expect(code.value).toBe('us');
|
|
99
|
+
expect(name.value).toBe('USA');
|
|
100
|
+
expect(flagSrc.value).toBe('/icons/flags/us.svg');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('не перезапрашивает список, если он уже загружен', async () => {
|
|
104
|
+
getCountries.mockResolvedValue({ countries: [{ title: 'Andorra', isoCodes: ['AD'] }] });
|
|
105
|
+
const { loadCountries } = useClientCountry();
|
|
106
|
+
|
|
107
|
+
await loadCountries();
|
|
108
|
+
await loadCountries();
|
|
109
|
+
|
|
110
|
+
expect(getCountries).toHaveBeenCalledTimes(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('падение запроса оставляет список пустым — следующее открытие попробует снова', async () => {
|
|
114
|
+
getCountries.mockRejectedValue(new Error('500'));
|
|
115
|
+
const { countriesItems, loadCountries } = useClientCountry();
|
|
116
|
+
|
|
117
|
+
await loadCountries();
|
|
118
|
+
|
|
119
|
+
expect(countriesItems.value).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ISelectItem } from '../types';
|
|
2
|
+
import { isoCodeMap } from '../utils/iso-code-map';
|
|
3
|
+
import { DictionariesApiService } from '../services/api/dictionaries.service';
|
|
4
|
+
|
|
5
|
+
/** Ручной выбор страны переживает перезагрузку год — читает его server/middleware/country.ts */
|
|
6
|
+
const COOKIE_OPTS = { maxAge: 60 * 60 * 24 * 365, path: '/', secure: true, sameSite: 'lax' as const };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Единый источник страны клиента: ISO-код (гео-заголовок прокси или ручной выбор
|
|
10
|
+
* в селекторе), иконка флага и локализованное имя.
|
|
11
|
+
*
|
|
12
|
+
* Имя всегда резолвится ОТ ТЕКУЩЕГО кода — своего кеша у него нет. Раньше имя
|
|
13
|
+
* лежало в отдельной куке `clientCountryName` рядом с парной `clientCountryNameCode`,
|
|
14
|
+
* и пара могла разъехаться: заголовок писал одну страну, флаг и селектор показывали
|
|
15
|
+
* другую (реальный кейс на проде: `clientCountryName=USA` при коде `ad`).
|
|
16
|
+
*/
|
|
17
|
+
export const useClientCountry = () => {
|
|
18
|
+
const code = useState<string>('clientCountryCode', () => '');
|
|
19
|
+
const resolvedName = useState<string>('clientCountryName', () => '');
|
|
20
|
+
const countriesItems = useState<ISelectItem[]>('countriesItems', () => []);
|
|
21
|
+
const codeCookie = useCookie<string | null>('clientCountryCode', COOKIE_OPTS);
|
|
22
|
+
|
|
23
|
+
/** ISO-код → имя файла флага (у части стран код локали отличается от кода страны) */
|
|
24
|
+
const toIcon = (value: string) => {
|
|
25
|
+
const iso = value.trim().toLowerCase();
|
|
26
|
+
return iso ? isoCodeMap[iso] || iso : '';
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const icon = computed(() => toIcon(code.value));
|
|
30
|
+
|
|
31
|
+
// Список стран догружается лениво на клиенте (см. layout/language.vue) — если он
|
|
32
|
+
// уже есть, имя берём из него: там оно точно совпадает с текущим кодом.
|
|
33
|
+
const name = computed(() => {
|
|
34
|
+
if (!icon.value) return '';
|
|
35
|
+
return countriesItems.value.find(item => item.icon === icon.value)?.title || resolvedName.value;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Флаг только для страны, которую смогли назвать: гео-заголовок умеет отдавать
|
|
39
|
+
// служебные коды (Cloudflare `XX` для неизвестного IP и `T1` для Tor, MaxMind `AP`),
|
|
40
|
+
// их нет ни в словаре стран, ни среди файлов флагов — вышла бы битая картинка.
|
|
41
|
+
const flagSrc = computed(() => (icon.value && name.value ? `/icons/flags/${icon.value}.svg` : ''));
|
|
42
|
+
|
|
43
|
+
/** Ручной выбор страны в селекторе: наружу переживает перезагрузку только код. */
|
|
44
|
+
const setCountry = (item: ISelectItem) => {
|
|
45
|
+
code.value = item.key || '';
|
|
46
|
+
resolvedName.value = item.title;
|
|
47
|
+
codeCookie.value = code.value || null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const isLoading = ref(false);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Полный список стран в payload не кладётся (см. plugins/countries.server.ts) —
|
|
54
|
+
* догружаем лениво на клиенте, когда открыли селектор.
|
|
55
|
+
*/
|
|
56
|
+
const loadCountries = async () => {
|
|
57
|
+
if (countriesItems.value.length || isLoading.value) return;
|
|
58
|
+
isLoading.value = true;
|
|
59
|
+
try {
|
|
60
|
+
const data = await DictionariesApiService.getCountries();
|
|
61
|
+
countriesItems.value = (data?.countries ?? []).map((item) => {
|
|
62
|
+
const iso = item.isoCodes[0].toLowerCase();
|
|
63
|
+
return { title: item.title, value: item.title, icon: toIcon(iso), key: iso };
|
|
64
|
+
});
|
|
65
|
+
} catch {
|
|
66
|
+
// оставляем пустым — следующее открытие попробует снова
|
|
67
|
+
} finally {
|
|
68
|
+
isLoading.value = false;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return { code, icon, name, flagSrc, countriesItems, setCountry, loadCountries };
|
|
73
|
+
};
|
package/composables/use-meta.ts
CHANGED
|
@@ -3,6 +3,16 @@ import type { Ref } from 'vue';
|
|
|
3
3
|
import { useRoute } from 'vue-router';
|
|
4
4
|
import type { IChipsItem } from '../types';
|
|
5
5
|
|
|
6
|
+
type MetaOptions = {
|
|
7
|
+
/**
|
|
8
|
+
* Подменяет i18n-ключ заголовка (по умолчанию 'h1') — вызывается с фактической
|
|
9
|
+
* сортировкой, потому что варианты заголовка есть не у каждой из них.
|
|
10
|
+
* Нужен, когда подставляемого текста может не быть и в шаблоне остаётся
|
|
11
|
+
* висячий предлог (`… Videos in ` без страны).
|
|
12
|
+
*/
|
|
13
|
+
h1Key?: (sortType: string) => string
|
|
14
|
+
};
|
|
15
|
+
|
|
6
16
|
/**
|
|
7
17
|
* Генерирует реактивные meta (title, description) и h1 с учётом сортировки, страницы и i18n-ключей.
|
|
8
18
|
* @param t - функция перевода (i18n)
|
|
@@ -13,8 +23,9 @@ import type { IChipsItem } from '../types';
|
|
|
13
23
|
* @param secondText - второй подставляемый текст
|
|
14
24
|
* @param thirdText - третий подставляемый текст
|
|
15
25
|
* @param fourthText - четвёртый подставляемый текст
|
|
26
|
+
* @param options - уточнения (подмена ключа h1)
|
|
16
27
|
*/
|
|
17
|
-
export function useMeta(t: (key: string, params?: Record<string, unknown>) => string, page: string, brandName: string, sortOptions?: IChipsItem[], text?: Ref<string>, secondText?: Ref<string>, thirdText?: Ref<string>, fourthText?: Ref<string
|
|
28
|
+
export function useMeta(t: (key: string, params?: Record<string, unknown>) => string, page: string, brandName: string, sortOptions?: IChipsItem[], text?: Ref<string>, secondText?: Ref<string>, thirdText?: Ref<string>, fourthText?: Ref<string>, options?: MetaOptions) {
|
|
18
29
|
const route = useRoute();
|
|
19
30
|
|
|
20
31
|
const sortType = computed(() => {
|
|
@@ -46,7 +57,7 @@ export function useMeta(t: (key: string, params?: Record<string, unknown>) => st
|
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
const metaTitle = computed(() => getPath('title'));
|
|
49
|
-
const h1 = computed(() => getPath('h1'));
|
|
60
|
+
const h1 = computed(() => getPath(options?.h1Key?.(sortType.value) || 'h1'));
|
|
50
61
|
|
|
51
62
|
const meta = computed(() => {
|
|
52
63
|
const description = getPath('meta_description');
|
package/package.json
CHANGED
|
@@ -117,17 +117,6 @@ const breadcrumbs = [
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
.grouped__options-separate {
|
|
121
|
-
display: block;
|
|
122
|
-
width: 1px;
|
|
123
|
-
height: 24px;
|
|
124
|
-
background-color: $grouped-separator;
|
|
125
|
-
|
|
126
|
-
@include from-br(sm) {
|
|
127
|
-
height: 32px;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
120
|
.grouped__loading {
|
|
132
121
|
--size: 24px;
|
|
133
122
|
|
package/pages/index.vue
CHANGED
|
@@ -21,9 +21,8 @@ import { convertQueryCategories, convertString, AsyncData } from '../runtime';
|
|
|
21
21
|
import { VideosApiService } from '../services/api/videos.service';
|
|
22
22
|
import { ModelsApiService } from '../services/api/models.service';
|
|
23
23
|
import { CategoriesApiService } from '../services/api/categories.service';
|
|
24
|
-
import { isoCodeMap } from '../utils/iso-code-map';
|
|
25
24
|
import { sortItemsDefault } from '../lib';
|
|
26
|
-
import type {
|
|
25
|
+
import type { IChipsItem } from '../types';
|
|
27
26
|
|
|
28
27
|
const { t } = useI18n();
|
|
29
28
|
|
|
@@ -62,28 +61,28 @@ const routeQueryCategories = computed(() => {
|
|
|
62
61
|
return '';
|
|
63
62
|
});
|
|
64
63
|
|
|
65
|
-
const
|
|
66
|
-
const countriesItems = useState<ISelectItem[]>('countriesItems', () => []);
|
|
67
|
-
const clientCountryNameState = useState<string>('clientCountryName', () => '');
|
|
64
|
+
const clientCountry = useClientCountry();
|
|
68
65
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (!code) return '';
|
|
73
|
-
const icon = isoCodeMap[code] || code;
|
|
74
|
-
const fromList = countriesItems.value.find(item => item.icon === icon)?.title;
|
|
75
|
-
return fromList || clientCountryNameState.value || '';
|
|
76
|
-
});
|
|
66
|
+
// Страна в заголовке уместна только на дефолтной сортировке (в остальных шаблонах
|
|
67
|
+
// её нет) — при явной сортировке гасим и подпись, и флаг.
|
|
68
|
+
const isCountryShown = computed(() => !route.query['sort'] && !!clientCountry.name.value);
|
|
77
69
|
|
|
78
|
-
const
|
|
70
|
+
const countryName = computed(() => (isCountryShown.value ? clientCountry.name.value : ''));
|
|
79
71
|
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
72
|
+
const { meta, h1 } = useMeta(
|
|
73
|
+
t,
|
|
74
|
+
pageName,
|
|
75
|
+
useAppConfig().brandName,
|
|
76
|
+
sortItemsDefault,
|
|
77
|
+
routeQueryCategories,
|
|
78
|
+
countryName,
|
|
79
|
+
undefined,
|
|
80
|
+
undefined,
|
|
81
|
+
// Страну не определили — берём заголовок без неё, иначе останется висячий предлог.
|
|
82
|
+
{ h1Key: sortType => (sortType === 'trending' && !countryName.value ? 'h1_no_country' : 'h1') },
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const countryFlagImg = computed(() => (isCountryShown.value ? clientCountry.flagSrc.value : undefined));
|
|
87
86
|
|
|
88
87
|
watchEffect(() => {
|
|
89
88
|
useHead(meta.value);
|
|
@@ -1,45 +1,32 @@
|
|
|
1
1
|
// SSR-плагин: по ISO-коду клиента резолвит имя страны в useState('clientCountryName')
|
|
2
2
|
// для гео-заголовка. Полный список стран в payload не кладётся (его лениво догружает
|
|
3
|
-
// селектор языка на клиенте). Результат кэшируется в
|
|
4
|
-
import type { ICountries
|
|
5
|
-
import { isoCodeMap } from '../utils/iso-code-map';
|
|
3
|
+
// селектор языка на клиенте). Результат кэшируется в память процесса.
|
|
4
|
+
import type { ICountries } from '../types';
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
/** Опции легаси-кук — нужны только чтобы их погасить (см. ниже) */
|
|
7
|
+
const LEGACY_COOKIE_OPTS = { path: '/', secure: true, sameSite: 'lax' as const };
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
items: ISelectItem[];
|
|
11
|
-
nameByIso: Map<string, string>;
|
|
12
|
-
};
|
|
9
|
+
const cache = new Map<string, Promise<Map<string, string> | null>>();
|
|
13
10
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
function buildCache(data: ICountries): CountriesCache {
|
|
17
|
-
const items: ISelectItem[] = [];
|
|
11
|
+
function buildNameByIso(data: ICountries): Map<string, string> {
|
|
18
12
|
const nameByIso = new Map<string, string>();
|
|
19
13
|
|
|
20
14
|
for (const item of data.countries) {
|
|
21
|
-
const iso = item.isoCodes[0].toLowerCase();
|
|
22
|
-
items.push({
|
|
23
|
-
title: item.title,
|
|
24
|
-
value: item.title,
|
|
25
|
-
icon: isoCodeMap[iso] || iso,
|
|
26
|
-
key: iso,
|
|
27
|
-
});
|
|
28
15
|
for (const code of item.isoCodes) {
|
|
29
16
|
nameByIso.set(code.toLowerCase(), item.title);
|
|
30
17
|
}
|
|
31
18
|
}
|
|
32
|
-
return
|
|
19
|
+
return nameByIso;
|
|
33
20
|
}
|
|
34
21
|
|
|
35
|
-
function
|
|
22
|
+
function getNameByIso(language: string, domain: string): Promise<Map<string, string> | null> {
|
|
36
23
|
const key = `${language}|${domain}`;
|
|
37
24
|
let promise = cache.get(key);
|
|
38
25
|
if (!promise) {
|
|
39
26
|
promise = $fetch<ICountries>('/api/dictionaries/countries', {
|
|
40
27
|
headers: { 'x-language': language, 'x-domain': domain },
|
|
41
28
|
})
|
|
42
|
-
.then(data => (data?.countries?.length ?
|
|
29
|
+
.then(data => (data?.countries?.length ? buildNameByIso(data) : null))
|
|
43
30
|
.catch(() => {
|
|
44
31
|
cache.delete(key);
|
|
45
32
|
return null;
|
|
@@ -51,34 +38,29 @@ function getCountries(language: string, domain: string): Promise<CountriesCache
|
|
|
51
38
|
|
|
52
39
|
export default defineNuxtPlugin(async () => {
|
|
53
40
|
const event = useRequestEvent();
|
|
54
|
-
const code = (event?.context?.clientCountryCode as string | undefined) || '';
|
|
41
|
+
const code = ((event?.context?.clientCountryCode as string | undefined) || '').trim().toLowerCase();
|
|
55
42
|
|
|
56
43
|
const countryName = useState<string>('clientCountryName', () => '');
|
|
57
44
|
|
|
45
|
+
// Имя страны больше не кешируется в куках: пара `clientCountryName` +
|
|
46
|
+
// `clientCountryNameCode` умела разъезжаться с кодом страны (заголовок писал одну
|
|
47
|
+
// страну, флаг и селектор показывали другую), а выигрыш был только в одном
|
|
48
|
+
// сетевом запросе на холодный процесс. Старые куки гасим, чтобы не висели год.
|
|
49
|
+
const legacyName = useCookie<string | null>('clientCountryName', LEGACY_COOKIE_OPTS);
|
|
50
|
+
const legacyNameCode = useCookie<string | null>('clientCountryNameCode', LEGACY_COOKIE_OPTS);
|
|
51
|
+
if (legacyName.value) legacyName.value = null;
|
|
52
|
+
if (legacyNameCode.value) legacyNameCode.value = null;
|
|
53
|
+
|
|
58
54
|
// Резолвим только имя текущей страны для гео-заголовка. Полный список стран
|
|
59
55
|
// НЕ кладём в useState (не сериализуем в payload) — language.vue догружает
|
|
60
56
|
// его лениво на клиенте при открытии селектора.
|
|
61
57
|
if (!code) return;
|
|
62
58
|
|
|
63
|
-
const codeCookie = useCookie<string>('clientCountryNameCode', COOKIE_OPTS);
|
|
64
|
-
const nameCookie = useCookie<string>('clientCountryName', COOKIE_OPTS);
|
|
65
|
-
|
|
66
|
-
// Тёплая кука → имя уже известно, серверный фетч стран не нужен.
|
|
67
|
-
if (nameCookie.value && codeCookie.value === code) {
|
|
68
|
-
countryName.value = nameCookie.value;
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
59
|
const language = useRequestHeaders(['x-language'])['x-language'] ?? '';
|
|
73
60
|
const domain = useRuntimeConfig().public.xDomain as string;
|
|
74
61
|
|
|
75
|
-
const
|
|
76
|
-
if (!
|
|
62
|
+
const nameByIso = await getNameByIso(language, domain);
|
|
63
|
+
if (!nameByIso) return;
|
|
77
64
|
|
|
78
|
-
|
|
79
|
-
if (name) {
|
|
80
|
-
countryName.value = name;
|
|
81
|
-
nameCookie.value = name;
|
|
82
|
-
codeCookie.value = code;
|
|
83
|
-
}
|
|
65
|
+
countryName.value = nameByIso.get(code) || '';
|
|
84
66
|
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Определяет страну клиента и кладёт ISO-код в event.context.clientCountryCode:
|
|
2
|
-
// сначала из cookie `clientCountryCode
|
|
3
|
-
// `cf-ipcountry` / `x-country-code` / `x-country`).
|
|
2
|
+
// сначала из cookie `clientCountryCode` (ручной выбор в селекторе), иначе из
|
|
3
|
+
// заголовков прокси (Cloudflare `cf-ipcountry` / `x-country-code` / `x-country`).
|
|
4
4
|
export default defineEventHandler((event) => {
|
|
5
5
|
const cookie = getCookie(event, 'clientCountryCode');
|
|
6
6
|
if (cookie) {
|
|
7
|
-
event.context.clientCountryCode = cookie;
|
|
7
|
+
event.context.clientCountryCode = cookie.trim().toLowerCase();
|
|
8
8
|
return;
|
|
9
9
|
}
|
|
10
10
|
|