@crm-market/template-shared 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/assets/scss/_variables.scss +3 -3
  2. package/assets/scss/components/_header.scss +1 -1
  3. package/assets/scss/style.css +4 -4
  4. package/components/AboutUs/StaticContent.vue +32 -0
  5. package/components/Cart/CartEmpty.vue +42 -0
  6. package/components/Cart/CartItem.vue +145 -0
  7. package/components/Cart/CartSummary.vue +57 -0
  8. package/components/Cart/ProductQuantity.vue +23 -13
  9. package/components/Cart/index.vue +68 -150
  10. package/components/Categories/index.vue +88 -291
  11. package/components/Checkout/AddressFields.vue +74 -0
  12. package/components/Checkout/CheckoutForm.vue +75 -0
  13. package/components/Checkout/CityAutocomplete.vue +68 -0
  14. package/components/Checkout/DeliveryMethodCard.vue +66 -0
  15. package/components/Checkout/DeliveryOptions.vue +148 -0
  16. package/components/Checkout/OrderSummary.vue +116 -0
  17. package/components/Checkout/PaymentOptions.vue +76 -0
  18. package/components/Checkout/PickupPoints.vue +46 -0
  19. package/components/Checkout/PromoCode.vue +166 -0
  20. package/components/Checkout/index.vue +235 -173
  21. package/components/Checkout/types.ts +45 -0
  22. package/components/Common/PageBanner.vue +22 -20
  23. package/components/Common/ProductCard.vue +139 -115
  24. package/components/Common/Services.vue +4 -4
  25. package/components/Contact/ContactForm.vue +44 -74
  26. package/components/Contact/ContactInfo.vue +35 -61
  27. package/components/FAQ/index.vue +6 -119
  28. package/components/Layout/Copyright.vue +11 -9
  29. package/components/Layout/Footer.vue +49 -136
  30. package/components/Layout/HeaderBar.vue +174 -0
  31. package/components/Layout/HeaderLogo.vue +27 -0
  32. package/components/Layout/MiddleHeader.vue +1 -228
  33. package/components/Layout/MiddleHeaderThree.vue +1 -203
  34. package/components/Layout/MiddleHeaderTwo.vue +1 -239
  35. package/components/Layout/NavLinks.vue +23 -0
  36. package/components/Layout/Navbar.vue +38 -151
  37. package/components/Layout/NavbarStyleFour.vue +1 -332
  38. package/components/Layout/NavbarStyleThree.vue +1 -188
  39. package/components/Layout/NavbarStyleTwo.vue +1 -108
  40. package/components/Layout/Preloader.vue +5 -12
  41. package/components/Layout/ResponsiveNavbar.vue +32 -418
  42. package/components/Layout/SitewideBanner.vue +285 -0
  43. package/components/Layout/TopHeader.vue +17 -83
  44. package/components/Layout/layoutData.ts +83 -0
  45. package/components/PrivacyPolicy/index.vue +6 -110
  46. package/components/ProductDetails/ProductDetailsTab.vue +31 -321
  47. package/components/ProductDetails/ProductGallery.vue +45 -0
  48. package/components/ProductDetails/ProductQuantity.vue +8 -14
  49. package/components/ProductDetails/RecentlyViewed.vue +53 -289
  50. package/components/ProductDetails/RelatedProducts.vue +64 -0
  51. package/components/ProductDetails/index.vue +90 -230
  52. package/components/Products/index.vue +78 -16
  53. package/components/TermsConditions/index.vue +6 -110
  54. package/components/Wishlist/index.vue +62 -88
  55. package/composables/useCart.ts +133 -81
  56. package/composables/useCategories.ts +66 -29
  57. package/composables/useCategoryImages.ts +45 -0
  58. package/composables/useCheckout.ts +302 -95
  59. package/composables/useContactForm.ts +57 -0
  60. package/composables/useHomeContent.ts +220 -0
  61. package/composables/useI18n.ts +12 -24
  62. package/composables/useJsonLd.ts +162 -0
  63. package/composables/useProducts.ts +244 -81
  64. package/composables/useRecent.ts +30 -0
  65. package/composables/useSanitize.ts +76 -0
  66. package/composables/useSiteConfig.ts +51 -45
  67. package/composables/useStaticPages.ts +48 -0
  68. package/composables/useStoreOrigin.ts +32 -0
  69. package/composables/useTemplateSections.ts +51 -3
  70. package/composables/useThemeVars.ts +113 -0
  71. package/composables/useWishlist.ts +72 -0
  72. package/layouts/default.vue +80 -19
  73. package/locales/en.json +255 -86
  74. package/locales/uk.json +251 -82
  75. package/modules/template-schema.ts +35 -0
  76. package/nuxt.config.ts +8 -11
  77. package/package.json +1 -1
  78. package/pages/about-us.vue +9 -9
  79. package/pages/cart.vue +0 -1
  80. package/pages/categories.vue +0 -1
  81. package/pages/category/[slug].vue +84 -0
  82. package/pages/checkout.vue +0 -1
  83. package/pages/contact.vue +6 -6
  84. package/pages/delivery.vue +12 -0
  85. package/pages/faq.vue +6 -6
  86. package/pages/order-success.vue +113 -0
  87. package/pages/privacy-policy.vue +6 -6
  88. package/pages/product/[slug].vue +82 -0
  89. package/pages/product-details.vue +14 -6
  90. package/pages/products.vue +12 -1
  91. package/pages/returns.vue +12 -0
  92. package/pages/terms-conditions.vue +6 -6
  93. package/pages/wishlist.vue +2 -2
  94. package/utils/seo-urls.ts +70 -0
  95. package/utils/store-origin.ts +36 -0
  96. package/components/AboutUs/AboutUsTuan.vue +0 -25
  97. package/components/AboutUs/Statistics.vue +0 -42
  98. package/components/AboutUs/SubscribeToTheNewsletter.vue +0 -57
  99. package/components/AddAddress/index.vue +0 -68
  100. package/components/BestSellers/Products.vue +0 -1562
  101. package/components/BestSellers/RecentlyViewed.vue +0 -304
  102. package/components/ChangePassword/index.vue +0 -69
  103. package/components/Common/DashboardNavigation.vue +0 -35
  104. package/components/Dashboard/RecentOrder.vue +0 -173
  105. package/components/Dashboard/index.vue +0 -77
  106. package/components/EditAddress/index.vue +0 -117
  107. package/components/EditProfile/index.vue +0 -95
  108. package/components/FeaturedProduct/FeaturedProducts.vue +0 -304
  109. package/components/FeaturedProduct/Products.vue +0 -1562
  110. package/components/ForgotPassword/index.vue +0 -49
  111. package/components/Layout/FooterTwo.vue +0 -159
  112. package/components/Layout/LocationOption.vue +0 -178
  113. package/components/Layout/RTLSwitchBtn.vue +0 -40
  114. package/components/Login/index.vue +0 -89
  115. package/components/MyAccount/index.vue +0 -154
  116. package/components/NewArrivals/Products.vue +0 -1969
  117. package/components/NewArrivals/RecentlyViewed.vue +0 -304
  118. package/components/OrderDetails/index.vue +0 -75
  119. package/components/OrderHistory/index.vue +0 -195
  120. package/components/Products/RecentlyViewed.vue +0 -302
  121. package/components/Register/index.vue +0 -83
  122. package/components/TrendingProducts/FeaturedProducts.vue +0 -304
  123. package/components/TrendingProducts/Products.vue +0 -1564
  124. package/components/Wishlist/ProductQuantity.vue +0 -29
  125. package/layouts/inner.vue +0 -70
  126. package/locales/ar.json +0 -234
  127. package/locales/ru.json +0 -234
  128. package/pages/address.vue +0 -11
  129. package/pages/change-password.vue +0 -11
  130. package/pages/dashboard.vue +0 -11
  131. package/pages/edit-address.vue +0 -11
  132. package/pages/edit-profile.vue +0 -11
  133. package/pages/forgot-password.vue +0 -11
  134. package/pages/login.vue +0 -11
  135. package/pages/my-account.vue +0 -11
  136. package/pages/order-details.vue +0 -11
  137. package/pages/order-history.vue +0 -11
  138. package/pages/register.vue +0 -11
@@ -3,191 +3,58 @@
3
3
  <div class="container">
4
4
  <div class="row">
5
5
  <div class="col-lg-6">
6
- <v-card-text>
7
- <v-tabs-window v-model="tab">
8
- <v-tabs-window-item value="img1">
9
- <div class="product-details-img">
10
- <img src="~/assets/images/product-66.png" alt="product" />
11
- </div>
12
- </v-tabs-window-item>
13
- <v-tabs-window-item value="img2">
14
- <div class="product-details-img">
15
- <img src="~/assets/images/product-67.png" alt="product" />
16
- </div>
17
- </v-tabs-window-item>
18
- <v-tabs-window-item value="img3">
19
- <div class="product-details-img">
20
- <img src="~/assets/images/product-68.png" alt="product" />
21
- </div>
22
- </v-tabs-window-item>
23
- <span class="discounts">-10%</span>
24
- </v-tabs-window>
25
- </v-card-text>
26
-
27
- <v-tabs
28
- v-model="tab"
29
- bg-color="white"
30
- class="products-tabs justify-content-center"
31
- >
32
- <v-tab value="img1">
33
- <img src="~/assets/images/product-66.png" alt="product" />
34
- </v-tab>
35
- <v-tab value="img2">
36
- <img src="~/assets/images/product-67.png" alt="product" />
37
- </v-tab>
38
- <v-tab value="img3">
39
- <img src="~/assets/images/product-68.png" alt="product" />
40
- </v-tab>
41
- </v-tabs>
6
+ <ProductDetailsProductGallery :product="product" :discount="discountLabel" />
42
7
  </div>
43
8
  <div class="col-lg-6">
44
9
  <div class="product-details-content">
45
- <span class="stock">
10
+ <span v-if="outOfStock" class="stock text-danger">{{ t('product.outOfStock') }}</span>
11
+ <span v-else class="stock">
46
12
  <i class="ri-check-line"></i>
47
13
  {{ t('product.inStock') }}
48
14
  </span>
49
- <h3>
50
- Apple MacBook Pro 16.2" with Liquid Retina XDR Display, M2 Max
51
- Chip with 12-Core CPU
52
- </h3>
53
- <div class="d-flex gap-1 review">
54
- <i class="flaticon-star"></i>
55
- <i class="flaticon-star"></i>
56
- <i class="flaticon-star"></i>
57
- <i class="flaticon-star"></i>
58
- <i class="flaticon-star"></i>
59
- <span>(5 {{ t('product.reviews') }})</span>
60
- </div>
61
- <h2 class="price">$399 <del>$450</del></h2>
62
- <div class="d-flex style-select">
63
- <span>{{ t('product.style') }}:</span>
64
- <h5>{{ typeText }}</h5>
65
- </div>
66
- <div class="select-style mb-4">
67
- <input type="radio" class="btn-check" />
68
- <label
69
- class="btn mb-2 mb-sm-0 mr-1"
70
- @click="setTypeTextToFirst"
71
- :class="{ active: typeActiveButton === 'first' }"
72
- >
73
- Apple M1 Max Chip
74
- </label>
75
- <input type="radio" class="btn-check" />
76
- <label
77
- class="btn mb-2 mb-sm-0"
78
- @click="setTypeTextToSecond"
79
- :class="{ active: typeActiveButton === 'second' }"
80
- >
81
- Apple M1 Pro Chip
82
- </label>
83
- </div>
84
- <div class="d-flex style-select">
85
- <span>{{ t('product.capacity') }}:</span>
86
- <h5>{{ capacityText }}</h5>
87
- </div>
88
- <div class="select-style mb-4">
89
- <input type="radio" class="btn-check" />
90
- <label
91
- class="btn mb-2 mb-sm-0 mr-1"
92
- @click="setCapacityTextToFirst"
93
- :class="{ active: capacityActiveButton === 'first' }"
94
- >
95
- 512 GB
96
- </label>
15
+ <h3>{{ product.title }}</h3>
16
+ <h2 class="price">
17
+ {{ formatPrice(price) }}
18
+ <del v-if="oldPrice" class="ms-2">{{ formatPrice(oldPrice) }}</del>
19
+ <span v-if="product.unit" class="fs-6 fw-normal text-muted ms-1">/ {{ product.unit }}</span>
20
+ </h2>
21
+ <p v-if="discountEnd" class="pd-discount-end">
22
+ <i class="ri-time-line"></i>
23
+ {{ t('product.saleUntil', { date: discountEnd }) }}
24
+ </p>
97
25
 
98
- <input type="radio" class="btn-check" />
99
- <label
100
- class="btn mb-2 mb-sm-0"
101
- @click="setCapacityTextToSecond"
102
- :class="{ active: capacityActiveButton === 'second' }"
103
- >
104
- 1 TB
105
- </label>
106
- </div>
107
- <div class="d-flex style-select">
108
- <span>{{ t('product.color') }}:</span>
109
- <h5>{{ colorText }}</h5>
110
- </div>
111
- <ul class="d-flex ps-0 mb-0 list-unstyled select-color gap-2 mb-4">
112
- <li
113
- class="color1"
114
- @click="setColorTextToFirst"
115
- :class="{ active: colorActiveButton === 'first' }"
116
- ></li>
117
- <li
118
- class="color2"
119
- @click="setColorTextToSecond"
120
- :class="{ active: colorActiveButton === 'second' }"
121
- ></li>
122
- </ul>
123
26
  <div class="d-flex align-items-center mb-4 gap-3">
124
- <ProductDetailsProductQuantity />
125
- <button class="btn btn-success text-white rounded-pill">
126
- <i class="ri-shopping-cart-line"></i>
127
- <span>{{ t('product.addToCart') }}</span>
27
+ <ProductDetailsProductQuantity v-model="quantity" />
28
+ <button type="button" class="btn btn-success text-white rounded-pill" :disabled="outOfStock" @click="handleAddToCart">
29
+ <i :class="inCart ? 'ri-check-line' : 'ri-shopping-cart-line'"></i>
30
+ <span>{{ inCart ? t('product.inCart') : t('product.addToCart') }}</span>
128
31
  </button>
129
32
  </div>
130
- <button class="d-flex align-items-center gap-3 wishlist mb-3">
131
- <i class="ri-heart-line"></i>
132
- <span>{{ t('product.addToWishlist') }}</span>
133
- </button>
134
- <button class="d-flex align-items-center gap-3 wishlist">
135
- <i class="ri-eye-line"></i>
136
- <span><span>565</span> {{ t('product.viewingProduct') }}</span>
33
+ <button type="button" class="d-flex align-items-center gap-3 wishlist mb-3" @click="toggle(product)">
34
+ <i :class="inWishlist ? 'ri-heart-fill text-danger' : 'ri-heart-line'"></i>
35
+ <span>{{ inWishlist ? t('product.inWishlist') : t('product.addToWishlist') }}</span>
137
36
  </button>
138
- <ul class="services-list ps-0 mb-0 list-unstyled">
139
- <li class="d-flex align-items-center">
140
- <i class="ri-check-line"></i>
141
- <span>{{ t('product.freeDelivery') }}</span>
142
- </li>
143
- <li class="d-flex align-items-center">
144
- <i class="ri-check-line"></i>
145
- <span>{{ t('product.moneyBack') }}</span>
146
- </li>
147
- <li class="d-flex align-items-center">
148
- <i class="ri-check-line"></i>
149
- <span>{{ t('product.returnPolicy') }}</span>
150
- </li>
151
- </ul>
37
+
152
38
  <ul class="sku-list ps-0 mb-0 list-unstyled">
153
- <li class="d-flex align-items-center">
39
+ <li v-if="product.sku" class="d-flex align-items-center">
154
40
  <span class="title">{{ t('product.sku') }}:</span>
155
- <span class="title2">SMTGS6T45</span>
156
- </li>
157
- <li class="d-flex align-items-center">
158
- <span class="title">{{ t('product.category') }}:</span>
159
- <span class="title2">Computer</span>
160
- </li>
161
- <li class="d-flex align-items-center">
162
- <span class="title">{{ t('nav.tags') }}:</span>
163
- <span class="title2">laptop, macbook, PC</span>
164
- </li>
165
- </ul>
166
- <ul
167
- class="social-link ps-0 mb-0 list-unstyled d-flex gap-2 flex-wrap"
168
- >
169
- <li class="d-flex align-items-center me-2">
170
- <span>{{ t('product.share') }}:</span>
171
- </li>
172
- <li class="d-flex align-items-center gap-2">
173
- <a href="https://facebook.com/" target="_blank">
174
- <i class="ri-facebook-fill"></i>
175
- </a>
41
+ <span class="title2">{{ product.sku }}</span>
176
42
  </li>
177
- <li class="d-flex align-items-center gap-2">
178
- <a href="https://twitter.com/" target="_blank">
179
- <i class="ri-twitter-x-line"></i>
180
- </a>
43
+ <li v-if="brandNames" class="d-flex align-items-center">
44
+ <span class="title">{{ t('product.brand') }}:</span>
45
+ <span class="title2">{{ brandNames }}</span>
181
46
  </li>
182
- <li class="d-flex align-items-center gap-2">
183
- <a href="https://linkedin.com/" target="_blank">
184
- <i class="ri-linkedin-fill"></i>
185
- </a>
47
+ <li v-if="product.categories?.length" class="d-flex align-items-center">
48
+ <span class="title">{{ t('product.category') }}:</span>
49
+ <span class="title2">
50
+ <template v-for="(cat, i) in product.categories" :key="cat.id">
51
+ <NuxtLink :to="categoryPath(cat)">{{ cat.name }}</NuxtLink><span v-if="i < product.categories.length - 1">, </span>
52
+ </template>
53
+ </span>
186
54
  </li>
187
- <li class="d-flex align-items-center gap-2">
188
- <a href="https://whatsapp.com/" target="_blank">
189
- <i class="ri-whatsapp-line"></i>
190
- </a>
55
+ <li v-if="product.keywords?.length" class="d-flex align-items-center">
56
+ <span class="title">{{ t('product.tags') }}:</span>
57
+ <span class="title2">{{ product.keywords.join(', ') }}</span>
191
58
  </li>
192
59
  </ul>
193
60
  </div>
@@ -196,75 +63,68 @@
196
63
 
197
64
  <div class="pt-60"></div>
198
65
 
199
- <ProductDetailsTab />
66
+ <ProductDetailsTab :product="product" />
200
67
  </div>
201
68
  </div>
202
69
  </template>
203
70
 
204
- <script lang="ts">
205
- import { defineComponent, ref } from "vue";
206
-
207
- export default defineComponent({
208
- name: "ProductDetails",
209
- data: () => ({
210
- tab: null,
211
- }),
212
- setup() {
213
- const { t } = useI18n();
214
- const typeText = ref("Apple M1 Max Chip");
215
- const typeActiveButton = ref("first");
216
-
217
- const setTypeTextToFirst = () => {
218
- typeText.value = "Apple M1 Max Chip";
219
- typeActiveButton.value = "first";
220
- };
71
+ <script setup lang="ts">
72
+ import { computed, onMounted, ref } from 'vue';
73
+ import {
74
+ formatPrice,
75
+ getProductDiscountEnd,
76
+ getProductDiscountLabel,
77
+ getProductOldPrice,
78
+ getProductPrice,
79
+ getProductImage,
80
+ type Product,
81
+ } from '../../composables/useProducts';
221
82
 
222
- const setTypeTextToSecond = () => {
223
- typeText.value = "Apple M1 Pro Chip";
224
- typeActiveButton.value = "second";
225
- };
83
+ const props = defineProps<{ product: Product }>();
226
84
 
227
- const capacityText = ref("512 GB");
228
- const capacityActiveButton = ref("first");
85
+ const { t } = useI18n();
86
+ const { addToCart, isInCart } = useCart();
87
+ const { ids, load, toggle } = useWishlist();
229
88
 
230
- const setCapacityTextToFirst = () => {
231
- capacityText.value = "512 GB";
232
- capacityActiveButton.value = "first";
233
- };
89
+ const quantity = ref(1);
234
90
 
235
- const setCapacityTextToSecond = () => {
236
- capacityText.value = "1 TB";
237
- capacityActiveButton.value = "second";
238
- };
91
+ const price = computed(() => getProductPrice(props.product));
92
+ const oldPrice = computed(() => getProductOldPrice(props.product));
93
+ // Бейдж «-20%» / «-50 ₴» (у галереї) і дата завершення акції
94
+ const discountLabel = computed(() => getProductDiscountLabel(props.product));
95
+ const discountEnd = computed(() => getProductDiscountEnd(props.product));
96
+ const outOfStock = computed(() => typeof props.product.stock === 'number' && props.product.stock <= 0);
97
+ const inCart = computed(() => isInCart(props.product.id));
98
+ const inWishlist = computed(() => ids.value.includes(props.product.id));
99
+ const brandNames = computed(() =>
100
+ (props.product.brands || []).map((b: any) => b?.name).filter(Boolean).join(', '),
101
+ );
239
102
 
240
- const colorText = ref("Silver");
241
- const colorActiveButton = ref("first");
103
+ const handleAddToCart = () => {
104
+ if (outOfStock.value) return;
105
+ addToCart(
106
+ {
107
+ productId: props.product.id,
108
+ name: props.product.title,
109
+ price: oldPrice.value || price.value,
110
+ discountPrice: oldPrice.value ? price.value : undefined,
111
+ image: getProductImage(props.product),
112
+ unit: props.product.unit,
113
+ },
114
+ quantity.value,
115
+ );
116
+ };
242
117
 
243
- const setColorTextToFirst = () => {
244
- colorText.value = "Silver";
245
- colorActiveButton.value = "first";
246
- };
247
-
248
- const setColorTextToSecond = () => {
249
- colorText.value = "Gray";
250
- colorActiveButton.value = "second";
251
- };
252
-
253
- return {
254
- t,
255
- typeText,
256
- typeActiveButton,
257
- setTypeTextToFirst,
258
- setTypeTextToSecond,
259
- capacityText,
260
- capacityActiveButton,
261
- setCapacityTextToFirst,
262
- setCapacityTextToSecond,
263
- colorText,
264
- colorActiveButton,
265
- setColorTextToFirst,
266
- setColorTextToSecond,
267
- };
268
- },
269
- });
118
+ onMounted(load);
270
119
  </script>
120
+
121
+ <style scoped>
122
+ .pd-discount-end {
123
+ display: flex;
124
+ align-items: center;
125
+ gap: 6px;
126
+ margin: -6px 0 18px;
127
+ color: #c62828;
128
+ font-weight: 500;
129
+ }
130
+ </style>
@@ -10,7 +10,17 @@
10
10
  md="3"
11
11
  class="mb-4 mt-xxl-0 overflow-hidden"
12
12
  >
13
- <div class="sidebar product-sidebar">
13
+ <!-- Мобільний: кнопка «Фільтри» згортає/розгортає панель -->
14
+ <button
15
+ type="button"
16
+ class="btn btn-outline-primary w-100 d-md-none"
17
+ :aria-expanded="filtersOpen"
18
+ @click="filtersOpen = !filtersOpen"
19
+ >
20
+ <i class="ri-filter-3-line me-1"></i>{{ t('catalog.filters') }}
21
+ <i :class="filtersOpen ? 'ri-arrow-up-s-line' : 'ri-arrow-down-s-line'"></i>
22
+ </button>
23
+ <div :class="['sidebar product-sidebar', filtersOpen ? 'd-block mt-3' : 'd-none d-md-block']">
14
24
  <!-- Categories filter -->
15
25
  <div class="single-sidebar-widget">
16
26
  <h3>{{ t('product.filterByCategories') }}</h3>
@@ -66,8 +76,8 @@
66
76
  </button>
67
77
  </div>
68
78
 
69
- <!-- Sort -->
70
- <div class="single-sidebar-widget">
79
+ <!-- Sort (на мобільному винесено над товари) -->
80
+ <div class="single-sidebar-widget d-none d-md-block">
71
81
  <h3>{{ t('product.sortBy') }}</h3>
72
82
  <select class="form-select form-select-sm" v-model="sortBy">
73
83
  <option value="newest">{{ t('product.newest') }}</option>
@@ -81,6 +91,16 @@
81
91
 
82
92
  <!-- Products grid -->
83
93
  <v-col cols="12" xl="10" lg="9" md="9">
94
+ <!-- Мобільне сортування над списком -->
95
+ <div class="d-md-none mb-3">
96
+ <select class="form-select form-select-sm" v-model="sortBy" :aria-label="t('product.sortBy')">
97
+ <option value="newest">{{ t('product.newest') }}</option>
98
+ <option value="price-asc">{{ t('product.priceLowHigh') }}</option>
99
+ <option value="price-desc">{{ t('product.priceHighLow') }}</option>
100
+ <option value="name">{{ t('product.nameAZ') }}</option>
101
+ </select>
102
+ </div>
103
+
84
104
  <!-- Search info -->
85
105
  <div class="d-flex align-items-center justify-content-between mb-3" v-if="searchQuery || selectedCategoryId">
86
106
  <p class="mb-0 text-muted">
@@ -160,21 +180,32 @@ import { defineComponent, ref, computed, watch, onMounted } from "vue";
160
180
 
161
181
  export default defineComponent({
162
182
  name: "ProductsIndex",
163
- setup() {
183
+ props: {
184
+ // Категорія зі сторінки /category/<slug>-<id>: початковий фільтр, в URL не потрапляє
185
+ categoryId: { type: String, default: "" },
186
+ },
187
+ async setup(props) {
164
188
  const { t } = useI18n();
165
189
  const route = useRoute();
166
190
  const router = useRouter();
167
- const { fetchProducts, products, loading } = useProducts();
191
+ // Локальний стан сторінки: глобальні products/loading з useProducts ділять інші секції, тому не підходять
192
+ const { fetchPage } = useProducts();
193
+ // useState: результат SSR передається клієнту без повторного запиту і без миготіння «0 товарів»
194
+ const products = useState<any[]>('catalog-products', () => []);
195
+ const loading = useState<boolean>('catalog-loading', () => true);
196
+ const loaded = useState<boolean>('catalog-loaded', () => false);
168
197
  const { topLevelCategories, getCategoryById } = useCategories();
169
198
 
170
- const selectedCategoryId = ref('');
199
+ const selectedCategoryId = ref(props.categoryId || '');
171
200
  const minPrice = ref<number | undefined>(undefined);
172
201
  const maxPrice = ref<number | undefined>(undefined);
173
202
  const sortBy = ref('newest');
174
203
  const searchQuery = ref('');
175
204
  const currentPage = ref(1);
176
- const totalProducts = ref(0);
205
+ const totalProducts = useState<number>('catalog-total', () => 0);
177
206
  const perPage = 20;
207
+ // Мобільна панель фільтрів: за замовчуванням згорнута
208
+ const filtersOpen = ref(false);
178
209
 
179
210
  const selectedCategoryName = computed(() => {
180
211
  if (!selectedCategoryId.value) return '';
@@ -201,18 +232,27 @@ export default defineComponent({
201
232
  const sortField = sortParts[0] as any;
202
233
  const sortOrder = sortParts[1] as 'asc' | 'desc' | undefined;
203
234
 
235
+ loading.value = true;
204
236
  try {
205
- const result = await fetchProducts({
237
+ const page = await fetchPage({
206
238
  categoryId: selectedCategoryId.value || undefined,
207
239
  minPrice: minPrice.value,
208
240
  maxPrice: maxPrice.value,
209
241
  search: searchQuery.value || undefined,
210
242
  sortBy: sortField === 'price' ? 'price' : sortField === 'name' ? 'name' : 'newest',
211
243
  sortOrder: sortOrder || (sortField === 'price' ? 'asc' : 'desc'),
212
- });
213
- totalProducts.value = result?.length || 0;
244
+ limit: perPage,
245
+ offset: (currentPage.value - 1) * perPage,
246
+ } as any);
247
+ products.value = page.items;
248
+ totalProducts.value = page.total;
249
+ loaded.value = true;
214
250
  } catch (e) {
215
251
  console.error('Error loading products:', e);
252
+ products.value = [];
253
+ totalProducts.value = 0;
254
+ } finally {
255
+ loading.value = false;
216
256
  }
217
257
  };
218
258
 
@@ -242,18 +282,19 @@ export default defineComponent({
242
282
 
243
283
  const syncToUrl = () => {
244
284
  const query: any = {};
245
- if (selectedCategoryId.value) query.categoryId = selectedCategoryId.value;
285
+ if (selectedCategoryId.value && !props.categoryId) query.categoryId = selectedCategoryId.value;
246
286
  if (searchQuery.value) query.search = searchQuery.value;
247
287
  if (minPrice.value) query.minPrice = minPrice.value;
248
288
  if (maxPrice.value) query.maxPrice = maxPrice.value;
249
289
  if (sortBy.value !== 'newest') query.sortBy = sortBy.value;
250
290
  if (currentPage.value > 1) query.page = currentPage.value;
251
- router.replace({ path: '/products', query });
291
+ // На сторінці категорії лишаємось на її адресі (/category/<slug>-<id>?page=2)
292
+ router.replace({ path: props.categoryId ? route.path : '/products', query });
252
293
  };
253
294
 
254
295
  const syncFromUrl = () => {
255
296
  const q = route.query;
256
- if (q.categoryId) selectedCategoryId.value = q.categoryId as string;
297
+ if (q.categoryId && !props.categoryId) selectedCategoryId.value = q.categoryId as string;
257
298
  if (q.search) searchQuery.value = q.search as string;
258
299
  if (q.minPrice) minPrice.value = Number(q.minPrice);
259
300
  if (q.maxPrice) maxPrice.value = Number(q.maxPrice);
@@ -262,16 +303,37 @@ export default defineComponent({
262
303
  };
263
304
 
264
305
  // Watch for category and sort changes
265
- watch(selectedCategoryId, () => applyFilters());
306
+ watch(selectedCategoryId, (id) => {
307
+ // На сторінці категорії інший вибір у фільтрі — перехід на адресу вибраної категорії (або в каталог)
308
+ if (props.categoryId && id !== props.categoryId) {
309
+ const cat = id ? getCategoryById(id) : undefined;
310
+ router.push(cat ? categoryPath(cat) : '/products');
311
+ return;
312
+ }
313
+ applyFilters();
314
+ });
266
315
  watch(sortBy, () => applyFilters());
316
+ // Інша категорія в props — одразу очищаємо список, щоб не блимали товари попередньої
317
+ watch(() => props.categoryId, (id) => {
318
+ products.value = [];
319
+ loading.value = true;
320
+ if (selectedCategoryId.value === (id || '')) applyFilters();
321
+ else selectedCategoryId.value = id || ''; // далі підхопить watch(selectedCategoryId)
322
+ });
267
323
 
324
+ // SSR: рендеримо каталог одразу (SEO), клієнт лише гідрує; повторно вантажимо тільки при зміні фільтрів
325
+ // Хуки життєвого циклу реєструємо ДО першого await (вимога async setup)
268
326
  onMounted(() => {
269
- syncFromUrl();
270
- loadProducts();
327
+ if (!loaded.value) loadProducts();
271
328
  });
329
+ syncFromUrl();
330
+ if (import.meta.server) {
331
+ await loadProducts();
332
+ }
272
333
 
273
334
  return {
274
335
  t,
336
+ filtersOpen,
275
337
  products,
276
338
  loading,
277
339
  topLevelCategories,
@@ -1,112 +1,8 @@
1
- <template>
2
- <div class="ptb-60">
3
- <div class="container">
4
- <div class="main-content-text">
5
- <span class="update">Last updated: February 2024</span>
6
- <h3>Terms & conditions</h3>
7
- <p>
8
- Turpis gravida turpis mauris magna in libero ac dis consequat.
9
- Imperdiet ac semper quisque volutpat consectetur cursus non pretium
10
- pharetra. Nisl duis blandit dui imperdiet nec id integer. Elit varius
11
- cras arcu tempor integer in.
12
- </p>
13
- <p>
14
- Quam nunc arcu nunc a lobortis convallis magnis dolor. Integer
15
- malesuada vel mi eu elementum. Etiam eget urna quis dui amet facilisis
16
- fringilla sed. Massa vitae accumsan viverra in morbi nec volutpat
17
- aenean tortor. At nec sagittis eget placerat. Mi accumsan massa vitae
18
- etiam nisi suspendisse condimentum elementum. Arcu varius id ipsum
19
- arcu et diam non rutrum. Auctor elit malesuada non aenean posuere
20
- mattis iaculis mauris nibh.
21
- </p>
22
-
23
- <div class="gap-20"></div>
24
-
25
- <h3>Intellectual property and acceptable use</h3>
26
- <p>
27
- Turpis gravida turpis mauris magna in libero ac dis consequat.
28
- Imperdiet ac semper quisque volutpat consectetur cursus non pretium
29
- pharetra. Nisl duis blandit dui imperdiet nec id integer. Elit varius
30
- cras arcu tempor integer in. Quam nunc arcu nunc a lobortis convallis
31
- magnis dolor. Integer malesuada vel mi eu elementum. Etiam eget urna
32
- quis dui amet facilisis fringilla sed. Massa vitae accumsan viverra in
33
- morbi nec volutpat aenean tortor. At nec sagittis eget placerat. Mi
34
- accumsan massa vitae etiam nisi suspendisse condimentum elementum.
35
- Arcu varius id ipsum arcu et diam non rutrum. Auctor elit malesuada
36
- non aenean posuere mattis iaculis mauris nibh.
37
- </p>
38
-
39
- <div class="gap-20"></div>
40
-
41
- <h3>Limitation of liability</h3>
42
- <p>
43
- Turpis gravida turpis mauris magna in libero ac dis consequat.
44
- Imperdiet ac semper quisque volutpat consectetur cursus non pretium
45
- pharetra. Nisl duis blandit dui imperdiet nec id integer. Elit varius
46
- cras arcu tempor integer in.
47
- </p>
48
- <p>
49
- Quam nunc arcu nunc a lobortis convallis magnis dolor. Integer
50
- malesuada vel mi eu elementum. Etiam eget urna quis dui amet facilisis
51
- fringilla sed. Massa vitae accumsan viverra in morbi nec volutpat
52
- aenean tortor. At nec sagittis eget placerat. Mi accumsan massa vitae
53
- etiam nisi suspendisse condimentum elementum. Arcu varius id ipsum
54
- arcu et diam non rutrum. Auctor elit malesuada non aenean posuere
55
- mattis iaculis mauris nibh.
56
- </p>
57
- <ol>
58
- <li>
59
- Imperdiet ac semper quisque volutpat consectetur cursus non pretium
60
- pharetra.
61
- </li>
62
- <li>Nisl duis blandit dui imperdiet nec id integer.</li>
63
- <li>Quam nunc arcu nunc a lobortis convallis magnis dolor.</li>
64
- <li>
65
- Massa vitae accumsan viverra in morbi nec volutpat aenean tortor.
66
- </li>
67
- </ol>
68
-
69
- <div class="gap-20"></div>
70
-
71
- <h3>Registration</h3>
72
- <p>
73
- Turpis gravida turpis mauris magna in libero ac dis consequat.
74
- Imperdiet ac semper quisque volutpat consectetur cursus non pretium
75
- pharetra. Nisl duis blandit dui imperdiet nec id integer. Elit varius
76
- cras arcu tempor integer in. Quam nunc arcu nunc a lobortis convallis
77
- magnis dolor. Integer malesuada vel mi eu elementum. Etiam eget urna
78
- quis dui amet facilisis fringilla sed. Massa vitae accumsan viverra in
79
- morbi nec volutpat aenean tortor. At nec sagittis eget placerat. Mi
80
- accumsan massa vitae etiam nisi suspendisse condimentum elementum.
81
- Arcu varius id ipsum arcu et diam non rutrum. Auctor elit malesuada
82
- non aenean posuere mattis iaculis mauris nibh.
83
- </p>
84
-
85
- <div class="gap-20"></div>
1
+ <script setup lang="ts">
2
+ // Умови використання: HTML продавця або дефолтний текст
3
+ const { pageHtml } = useStaticPages();
4
+ </script>
86
5
 
87
- <h3>Password and security</h3>
88
- <p>
89
- Turpis gravida turpis mauris magna in libero ac dis consequat.
90
- Imperdiet ac semper quisque volutpat consectetur cursus non pretium
91
- pharetra. Nisl duis blandit dui imperdiet nec id integer. Elit varius
92
- cras arcu tempor integer in.
93
- </p>
94
- <p>
95
- Quam nunc arcu nunc a lobortis convallis magnis dolor. Integer
96
- malesuada vel mi eu elementum. Etiam eget urna quis dui amet facilisis
97
- fringilla sed. Massa vitae accumsan viverra in morbi nec volutpat
98
- aenean tortor. At nec sagittis eget placerat. Mi accumsan massa vitae
99
- etiam nisi suspendisse condimentum elementum. Arcu varius id ipsum
100
- arcu et diam non rutrum. Auctor elit malesuada non aenean posuere
101
- mattis iaculis mauris nibh.
102
- </p>
103
- </div>
104
- </div>
105
- </div>
6
+ <template>
7
+ <AboutUsStaticContent :html="pageHtml('terms')" />
106
8
  </template>
107
-
108
- <script>
109
- export default {
110
- name: "TermsConditions",
111
- };
112
- </script>