@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
@@ -1,305 +1,102 @@
1
- <template>
2
- <div class="blog-area ptb-60">
3
- <div class="container">
4
- <v-row class="justify-content-center">
5
- <v-col cols="12" lg="8">
6
- <v-row>
7
- <v-col cols="12" lg="12" md="6">
8
- <div class="single-blog-item">
9
- <NuxtLink to="/blog-details" class="blog-img">
10
- <img src="~/assets/images/blog-1.jpg" alt="blog" />
11
- </NuxtLink>
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { useCategoryImages } from '../../composables/useCategoryImages';
12
4
 
13
- <ul class="ps-0 list-unstyled d-flex gap-4 info flex-wrap">
14
- <li>
15
- By: <a href="#" class="text-decoration-none">Admin</a>
16
- </li>
17
- <li>
18
- <i class="ri-calendar-2-line text-warning"></i>
19
- 25 December 2024
20
- </li>
21
- <li>
22
- <i class="ri-question-answer-line text-warning"></i>
23
- <NuxtLink to="/blog-details" class="text-decoration-none">
24
- 03 Comments
25
- </NuxtLink>
26
- </li>
27
- </ul>
5
+ const { t } = useI18n();
6
+ const { topLevelCategories, getSubcategories, fetchCategories, loading, error } = useCategories();
7
+ const { imageFor, ensureFallbacks } = useCategoryImages();
28
8
 
29
- <h2>
30
- <NuxtLink to="/blog-details">
31
- The Future of Tech: A Sneak Peek into Innovation
32
- </NuxtLink>
33
- </h2>
34
- <p>
35
- Express the excitement of looking ahead and exploring the
36
- innovations that will shape the future. Highlight real-world
37
- applications and potential impacts on various industries
38
- </p>
39
- <NuxtLink to="/blog-details" class="read-more">
40
- <span>Read More</span>
41
- <i class="ri-arrow-right-up-line"></i>
42
- </NuxtLink>
43
- </div>
44
- </v-col>
45
- <v-col cols="12" lg="12" md="6">
46
- <div class="single-blog-item">
47
- <NuxtLink to="/blog-details" class="blog-img">
48
- <img src="~/assets/images/blog-2.jpg" alt="blog" />
49
- </NuxtLink>
9
+ // Категорії зазвичай приходять з SSR (плагін); догружаємо, якщо список порожній
10
+ await fetchCategories().catch(() => undefined);
11
+ // Категорії без картинки — фото першого товару
12
+ await useAsyncData('categories-page-images', () => ensureFallbacks(topLevelCategories.value).then(() => true), {
13
+ default: () => false,
14
+ });
50
15
 
51
- <ul class="ps-0 list-unstyled d-flex gap-4 info flex-wrap">
52
- <li>
53
- By: <a href="#" class="text-decoration-none">Admin</a>
54
- </li>
55
- <li>
56
- <i class="ri-calendar-2-line text-warning"></i>
57
- 25 December 2024
58
- </li>
59
- <li>
60
- <i class="ri-question-answer-line text-warning"></i>
61
- <NuxtLink to="/blog-details" class="text-decoration-none">
62
- 03 Comments
63
- </NuxtLink>
64
- </li>
65
- </ul>
16
+ const cards = computed(() =>
17
+ topLevelCategories.value.map((c) => ({
18
+ ...c,
19
+ imageSrc: imageFor(c),
20
+ subs: getSubcategories(c.id),
21
+ })),
22
+ );
66
23
 
67
- <h2>
68
- <NuxtLink to="/blog-details">
69
- The Future of Tech: A Sneak Peek into Innovation
70
- </NuxtLink>
71
- </h2>
72
- <p>
73
- Express the excitement of looking ahead and exploring the
74
- innovations that will shape the future. Highlight real-world
75
- applications and potential impacts on various industries
76
- </p>
77
- <NuxtLink to="/blog-details" class="read-more">
78
- <span>Read More</span>
79
- <i class="ri-arrow-right-up-line"></i>
80
- </NuxtLink>
81
- </div>
82
- </v-col>
83
- <v-col cols="12" lg="12" md="6">
84
- <div class="single-blog-item">
85
- <NuxtLink to="/blog-details" class="blog-img">
86
- <img src="~/assets/images/blog-3.jpg" alt="blog" />
87
- </NuxtLink>
88
-
89
- <ul class="ps-0 list-unstyled d-flex gap-4 info flex-wrap">
90
- <li>
91
- By: <a href="#" class="text-decoration-none">Admin</a>
92
- </li>
93
- <li>
94
- <i class="ri-calendar-2-line text-warning"></i>
95
- 25 December 2024
96
- </li>
97
- <li>
98
- <i class="ri-question-answer-line text-warning"></i>
99
- <NuxtLink to="/blog-details" class="text-decoration-none">
100
- 03 Comments
101
- </NuxtLink>
102
- </li>
103
- </ul>
104
-
105
- <h2>
106
- <NuxtLink to="/blog-details">
107
- The Future of Tech: A Sneak Peek into Innovation
108
- </NuxtLink>
109
- </h2>
110
- <p>
111
- Express the excitement of looking ahead and exploring the
112
- innovations that will shape the future. Highlight real-world
113
- applications and potential impacts on various industries
114
- </p>
115
- <NuxtLink to="/blog-details" class="read-more">
116
- <span>Read More</span>
117
- <i class="ri-arrow-right-up-line"></i>
118
- </NuxtLink>
119
- </div>
120
- </v-col>
121
- <v-col cols="12" lg="12" md="6">
122
- <div class="single-blog-item">
123
- <NuxtLink to="/blog-details" class="blog-img">
124
- <img src="~/assets/images/blog-4.jpg" alt="blog" />
125
- </NuxtLink>
126
-
127
- <ul class="ps-0 list-unstyled d-flex gap-4 info flex-wrap">
128
- <li>
129
- By: <a href="#" class="text-decoration-none">Admin</a>
130
- </li>
131
- <li>
132
- <i class="ri-calendar-2-line text-warning"></i>
133
- 25 December 2024
134
- </li>
135
- <li>
136
- <i class="ri-question-answer-line text-warning"></i>
137
- <NuxtLink to="/blog-details" class="text-decoration-none">
138
- 03 Comments
139
- </NuxtLink>
140
- </li>
141
- </ul>
24
+ const reload = () => fetchCategories(true).catch(() => undefined);
25
+ </script>
142
26
 
143
- <h2>
144
- <NuxtLink to="/blog-details">
145
- The Future of Tech: A Sneak Peek into Innovation
146
- </NuxtLink>
147
- </h2>
148
- <p>
149
- Express the excitement of looking ahead and exploring the
150
- innovations that will shape the future. Highlight real-world
151
- applications and potential impacts on various industries
152
- </p>
153
- <NuxtLink to="/blog-details" class="read-more">
154
- <span>Read More</span>
155
- <i class="ri-arrow-right-up-line"></i>
156
- </NuxtLink>
157
- </div>
158
- </v-col>
159
- <v-col cols="12" lg="12">
160
- <div class="pagination-area">
161
- <a href="#" class="next page-numbers">
162
- <i class="ri-arrow-left-s-line"></i>
163
- </a>
27
+ <template>
28
+ <div class="categories-area ptb-60">
29
+ <div class="container">
30
+ <div class="mb-4 text-center text-md-start">
31
+ <h2 class="fs-3 mb-1">{{ t('catalog.title') }}</h2>
32
+ <p class="text-muted mb-0">{{ t('catalog.subtitle') }}</p>
33
+ </div>
164
34
 
165
- <span class="page-numbers current" aria-current="page">1</span>
166
- <a href="#" class="page-numbers">2</a>
167
- <a href="#" class="page-numbers">3</a>
35
+ <div v-if="cards.length" class="row g-4">
36
+ <div v-for="category in cards" :key="category.id" class="col-12 col-sm-6 col-lg-4 col-xl-3">
37
+ <div class="category-card h-100 border rounded-4 p-3 bg-white">
38
+ <NuxtLink
39
+ :to="categoryPath(category)"
40
+ class="d-flex align-items-center gap-3 text-decoration-none text-reset mb-2"
41
+ >
42
+ <img
43
+ v-if="category.imageSrc"
44
+ :src="category.imageSrc"
45
+ :alt="category.name"
46
+ class="rounded-circle flex-shrink-0"
47
+ style="width: 72px; height: 72px; object-fit: cover"
48
+ loading="lazy"
49
+ />
50
+ <span
51
+ v-else
52
+ class="rounded-circle bg-light d-flex align-items-center justify-content-center flex-shrink-0"
53
+ style="width: 72px; height: 72px"
54
+ >
55
+ <i class="ri-folder-3-line fs-3 text-muted"></i>
56
+ </span>
57
+ <span class="min-w-0">
58
+ <span class="d-block fw-bold text-truncate">{{ category.name }}</span>
59
+ <span v-if="category.productsCount != null" class="d-block text-muted fs-14">
60
+ {{ t('catalog.productsCount', { count: category.productsCount }) }}
61
+ </span>
62
+ </span>
63
+ </NuxtLink>
168
64
 
169
- <a href="#" class="next page-numbers">
170
- <i class="ri-arrow-right-s-line"></i>
171
- </a>
172
- </div>
173
- </v-col>
174
- </v-row>
175
- </v-col>
176
- <v-col cols="12" lg="4" class="mt-4 mt-lg-0">
177
- <div class="sidebar ms-78">
178
- <div class="single-sidebar-widget">
179
- <h3>Search</h3>
180
- <form class="search-form position-relative">
181
- <input
182
- type="text"
183
- class="form-control"
184
- placeholder="Search here"
185
- />
186
- <button
187
- type="submit"
188
- class="position-absolute top-50 end-0 translate-middle-y p-0 bg-transparent border-0 text-warning pe-3"
65
+ <ul v-if="category.subs.length" class="list-unstyled mb-0 ps-0 border-top pt-2">
66
+ <li v-for="sub in category.subs" :key="sub.id">
67
+ <NuxtLink
68
+ :to="categoryPath(sub)"
69
+ class="d-flex justify-content-between text-decoration-none text-muted py-1 fs-14"
189
70
  >
190
- <i class="ri-search-line"></i>
191
- </button>
192
- </form>
193
- </div>
194
- <div class="single-sidebar-widget">
195
- <h3>Popular Posts</h3>
196
-
197
- <div class="d-flex align-items-center popular-item">
198
- <div class="flex-shrink-0">
199
- <NuxtLink to="/blog-details" class="d-block">
200
- <img src="~/assets/images/blog-5.jpg" alt="blog" />
201
- </NuxtLink>
202
- </div>
203
- <div class="flex-grow-1 ms-3">
204
- <h6>
205
- <NuxtLink to="/blog-details">
206
- The Art of Tech Gifting: Finding the Perfect Present
207
- </NuxtLink>
208
- </h6>
209
- <div class="d-flex align-items-center">
210
- <i class="ri-calendar-2-line"></i>
211
- <span>25 December 2024</span>
212
- </div>
213
- </div>
214
- </div>
215
- <div class="d-flex align-items-center popular-item">
216
- <div class="flex-shrink-0">
217
- <NuxtLink to="/blog-details" class="d-block">
218
- <img src="~/assets/images/blog-6.jpg" alt="blog" />
219
- </NuxtLink>
220
- </div>
221
- <div class="flex-grow-1 ms-3">
222
- <h6>
223
- <NuxtLink to="/blog-details">
224
- Mastering the Art of Wearables: A Comprehensive Guide
225
- </NuxtLink>
226
- </h6>
227
- <div class="d-flex align-items-center">
228
- <i class="ri-calendar-2-line"></i>
229
- <span>27 December 2024</span>
230
- </div>
231
- </div>
232
- </div>
233
- <div class="d-flex align-items-center popular-item">
234
- <div class="flex-shrink-0">
235
- <NuxtLink to="/blog-details" class="d-block">
236
- <img src="~/assets/images/blog-7.jpg" alt="blog" />
237
- </NuxtLink>
238
- </div>
239
- <div class="flex-grow-1 ms-3">
240
- <h6>
241
- <NuxtLink to="/blog-details">
242
- Tech Trends 2023: What's Shaping the Digital Landscape?
243
- </NuxtLink>
244
- </h6>
245
- <div class="d-flex align-items-center">
246
- <i class="ri-calendar-2-line"></i>
247
- <span>28 December 2024</span>
248
- </div>
249
- </div>
250
- </div>
251
- </div>
252
- <div class="single-sidebar-widget">
253
- <h3>Categories</h3>
71
+ <span>{{ sub.name }}</span>
72
+ <span v-if="sub.productsCount != null">{{ sub.productsCount }}</span>
73
+ </NuxtLink>
74
+ </li>
75
+ </ul>
76
+ </div>
77
+ </div>
78
+ </div>
254
79
 
255
- <ul class="ps-0 mb-0 list-unstyled categories">
256
- <li>
257
- <NuxtLink to="/categories">Smartphone</NuxtLink>
258
- </li>
259
- <li>
260
- <NuxtLink to="/categories">Laptop</NuxtLink>
261
- </li>
262
- <li>
263
- <NuxtLink to="/categories">Headphone</NuxtLink>
264
- </li>
265
- <li>
266
- <NuxtLink to="/categories">Smart Watch</NuxtLink>
267
- </li>
268
- <li>
269
- <NuxtLink to="/categories">iPad</NuxtLink>
270
- </li>
271
- </ul>
272
- </div>
273
- <div class="single-sidebar-widget">
274
- <h3>Tags</h3>
80
+ <div v-else-if="loading" class="text-center text-muted py-5">...</div>
275
81
 
276
- <ul class="ps-0 mb-0 list-unstyled tags">
277
- <li>
278
- <NuxtLink to="/tags">Smartphone</NuxtLink>
279
- </li>
280
- <li>
281
- <NuxtLink to="/tags">Laptop</NuxtLink>
282
- </li>
283
- <li>
284
- <NuxtLink to="/tags">AirPod</NuxtLink>
285
- </li>
286
- <li>
287
- <NuxtLink to="/tags">Tablet</NuxtLink>
288
- </li>
289
- <li>
290
- <NuxtLink to="/tags">Headphone</NuxtLink>
291
- </li>
292
- </ul>
293
- </div>
294
- </div>
295
- </v-col>
296
- </v-row>
82
+ <div v-else class="text-center text-muted py-5">
83
+ <p class="mb-3">{{ error ? t('catalog.loadError') : t('catalog.empty') }}</p>
84
+ <button v-if="error" type="button" class="btn btn-success text-white rounded-pill" @click="reload">
85
+ {{ t('catalog.retry') }}
86
+ </button>
87
+ <NuxtLink v-else to="/products" class="btn btn-success text-white rounded-pill">
88
+ {{ t('catalog.allProducts') }}
89
+ </NuxtLink>
90
+ </div>
297
91
  </div>
298
92
  </div>
299
93
  </template>
300
94
 
301
- <script>
302
- export default {
303
- name: "Tags",
304
- };
305
- </script>
95
+ <style scoped>
96
+ .category-card {
97
+ transition: box-shadow 0.25s;
98
+ }
99
+ .category-card:hover {
100
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
101
+ }
102
+ </style>
@@ -0,0 +1,74 @@
1
+ <template>
2
+ <div>
3
+ <div class="form-group mb-3" data-field="city">
4
+ <label class="label">{{ t('checkout.city') }} *</label>
5
+ <input v-model="model.cityText" class="form-control" :class="{ 'is-invalid': errors.city }" autocomplete="address-level2" :placeholder="t('checkout.city')" />
6
+ <div v-if="errors.city" class="invalid-feedback d-block">{{ errors.city }}</div>
7
+ </div>
8
+
9
+ <!-- Кур'єр: вулиця / будинок / квартира; склеюються в одне поле address для payload -->
10
+ <template v-if="streetMode">
11
+ <div class="row g-3" data-field="address">
12
+ <div class="col-12 col-md-6">
13
+ <label class="label">{{ t('checkout.street') }} *</label>
14
+ <input v-model="street" class="form-control" :class="{ 'is-invalid': errors.address && !street.trim() }" autocomplete="address-line1" :placeholder="t('checkout.streetPlaceholder')" />
15
+ </div>
16
+ <div class="col-6 col-md-3">
17
+ <label class="label">{{ t('checkout.house') }}</label>
18
+ <input v-model="house" class="form-control" :class="{ 'is-invalid': errors.address && !street.trim() }" />
19
+ </div>
20
+ <div class="col-6 col-md-3">
21
+ <label class="label">{{ t('checkout.apartment') }}</label>
22
+ <input v-model="flat" class="form-control" />
23
+ </div>
24
+ </div>
25
+ <div v-if="errors.address" class="invalid-feedback d-block">{{ errors.address }}</div>
26
+ </template>
27
+ <div v-else class="form-group mb-3" data-field="address">
28
+ <label class="label">{{ t('checkout.address') }} *</label>
29
+ <input v-model="model.address" class="form-control" :class="{ 'is-invalid': errors.address }" autocomplete="address-line1" :placeholder="t('checkout.addressPlaceholder')" />
30
+ <div v-if="errors.address" class="invalid-feedback d-block">{{ errors.address }}</div>
31
+ </div>
32
+
33
+ <div v-if="postcode" class="form-group mt-3" data-field="postcode">
34
+ <label class="label">{{ t('checkout.postalCode') }} *</label>
35
+ <input
36
+ :value="model.postcode"
37
+ class="form-control postcode-input"
38
+ :class="{ 'is-invalid': errors.postcode }"
39
+ inputmode="numeric"
40
+ maxlength="5"
41
+ placeholder="01001"
42
+ autocomplete="postal-code"
43
+ @input="model.postcode = ($event.target as HTMLInputElement).value.replace(/\D/g, '').slice(0, 5)"
44
+ />
45
+ <small class="text-muted d-block">{{ t('checkout.postalHint') }}</small>
46
+ <div v-if="errors.postcode" class="invalid-feedback d-block">{{ errors.postcode }}</div>
47
+ </div>
48
+ </div>
49
+ </template>
50
+
51
+ <script setup lang="ts">
52
+ import { ref, watch } from 'vue';
53
+ import type { DeliveryState, FieldErrors } from './types';
54
+
55
+ // Поля адресної доставки (кур'єр, Укрпошта)
56
+ const props = defineProps<{ errors: FieldErrors; postcode?: boolean; streetMode?: boolean }>();
57
+ const model = defineModel<DeliveryState>({ required: true });
58
+ const { t } = useI18n();
59
+
60
+ const street = ref('');
61
+ const house = ref('');
62
+ const flat = ref('');
63
+ watch([street, house, flat, () => props.streetMode], () => {
64
+ if (!props.streetMode) return;
65
+ const parts = [street.value.trim()];
66
+ if (house.value.trim()) parts.push(`буд. ${house.value.trim()}`);
67
+ if (flat.value.trim()) parts.push(`кв. ${flat.value.trim()}`);
68
+ model.value.address = street.value.trim() ? parts.join(', ') : '';
69
+ });
70
+ </script>
71
+
72
+ <style scoped>
73
+ .postcode-input { max-width: 140px; letter-spacing: 2px; }
74
+ </style>
@@ -0,0 +1,75 @@
1
+ <template>
2
+ <div class="checkout-card" data-field="contacts">
3
+ <h3>{{ t('checkout.contactTitle') }}</h3>
4
+ <div class="row g-3">
5
+ <div class="col-12 col-md-6" data-field="name">
6
+ <label class="label">{{ t('checkout.fullName') }} *</label>
7
+ <input
8
+ v-model="model.name"
9
+ type="text"
10
+ class="form-control"
11
+ :class="{ 'is-invalid': errors.name }"
12
+ autocomplete="name"
13
+ :placeholder="t('checkout.fullNamePlaceholder')"
14
+ />
15
+ <div v-if="errors.name" class="invalid-feedback d-block">{{ errors.name }}</div>
16
+ </div>
17
+ <div class="col-12 col-md-6" data-field="phone">
18
+ <label class="label">{{ t('checkout.phone') }} *</label>
19
+ <input
20
+ :value="model.phone"
21
+ type="tel"
22
+ class="form-control"
23
+ :class="{ 'is-invalid': errors.phone }"
24
+ autocomplete="tel"
25
+ inputmode="tel"
26
+ placeholder="+380 XX XXX XX XX"
27
+ @input="onPhone"
28
+ />
29
+ <small v-if="!errors.phone" class="text-muted">{{ t('checkout.phoneHint') }}</small>
30
+ <div v-if="errors.phone" class="invalid-feedback d-block">{{ errors.phone }}</div>
31
+ </div>
32
+ <div class="col-12" data-field="email">
33
+ <label class="label">{{ t('checkout.emailOptional') }}</label>
34
+ <input
35
+ v-model="model.email"
36
+ type="email"
37
+ class="form-control"
38
+ :class="{ 'is-invalid': errors.email }"
39
+ autocomplete="email" required
40
+ placeholder="name@example.com"
41
+ />
42
+ <small v-if="!errors.email" class="text-muted">{{ t('checkout.emailHint') }}</small>
43
+ <div v-if="errors.email" class="invalid-feedback d-block">{{ errors.email }}</div>
44
+ </div>
45
+ <div class="col-12">
46
+ <label class="label">{{ t('checkout.comment') }}</label>
47
+ <textarea v-model="model.comment" class="form-control" :placeholder="t('checkout.notes')" rows="3"></textarea>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </template>
52
+
53
+ <script setup lang="ts">
54
+ import type { ContactState, FieldErrors } from './types';
55
+
56
+ // Контактні дані покупця: мутуємо поля спільного reactive-обʼєкта
57
+ defineProps<{ errors: FieldErrors }>();
58
+ const model = defineModel<ContactState>({ required: true });
59
+ const { t } = useI18n();
60
+
61
+ // Маска телефону: +380 XX XXX XX XX (normalizePhone приймає і цей формат)
62
+ const onPhone = (e: Event) => {
63
+ const el = e.target as HTMLInputElement;
64
+ let d = el.value.replace(/\D/g, '');
65
+ if (!d) { model.value.phone = ''; el.value = ''; return; }
66
+ if (d.startsWith('380')) d = d.slice(3);
67
+ else if (d.startsWith('38')) d = d.slice(2);
68
+ else if (d.startsWith('0')) d = d.slice(1);
69
+ d = d.slice(0, 9);
70
+ const g = [d.slice(0, 2), d.slice(2, 5), d.slice(5, 7), d.slice(7, 9)].filter(Boolean);
71
+ const v = '+380' + (g.length ? ' ' + g.join(' ') : '');
72
+ model.value.phone = v;
73
+ el.value = v;
74
+ };
75
+ </script>
@@ -0,0 +1,68 @@
1
+ <template>
2
+ <div ref="root" class="city-ac position-relative">
3
+ <input
4
+ :value="modelValue"
5
+ class="form-control"
6
+ :class="{ 'is-invalid': invalid }"
7
+ autocomplete="off"
8
+ :placeholder="placeholder"
9
+ @focus="open = true"
10
+ @input="onInput"
11
+ />
12
+ <div v-if="open && (loading || items.length || showEmpty || error)" class="dropdown-menu show w-100 ac-menu">
13
+ <div v-if="loading" class="dropdown-item-text text-muted">
14
+ <span class="spinner-border spinner-border-sm me-2"></span>{{ t('checkout.searchingShort') }}
15
+ </div>
16
+ <div v-else-if="error" class="dropdown-item-text text-danger">{{ error }}</div>
17
+ <template v-else>
18
+ <button v-for="it in items" :key="it.key" type="button" class="dropdown-item" @click="pick(it)">
19
+ {{ it.title }}<span v-if="it.sub" class="text-muted">, {{ it.sub }}</span>
20
+ </button>
21
+ <div v-if="!items.length && showEmpty" class="dropdown-item-text text-muted">{{ t('checkout.nothingFound') }}</div>
22
+ </template>
23
+ </div>
24
+ </div>
25
+ </template>
26
+
27
+ <script setup lang="ts">
28
+ import { onBeforeUnmount, onMounted, ref } from 'vue';
29
+
30
+ // Поле з випадаючим списком (міста / відділення): сам список і пошук надає батьківський компонент
31
+ export interface AcItem { key: string; title: string; sub?: string; raw: any }
32
+ defineProps<{
33
+ modelValue: string;
34
+ items: AcItem[];
35
+ loading?: boolean;
36
+ error?: string;
37
+ invalid?: boolean;
38
+ placeholder?: string;
39
+ showEmpty?: boolean; // показувати «Нічого не знайдено» при порожньому списку
40
+ }>();
41
+ const emit = defineEmits<{ 'update:modelValue': [string]; input: [string]; pick: [any] }>();
42
+ const { t } = useI18n();
43
+
44
+ const open = ref(false);
45
+ const root = ref<HTMLElement | null>(null);
46
+
47
+ const onInput = (e: Event) => {
48
+ const v = (e.target as HTMLInputElement).value;
49
+ open.value = true;
50
+ emit('update:modelValue', v);
51
+ emit('input', v);
52
+ };
53
+ const pick = (it: AcItem) => {
54
+ open.value = false;
55
+ emit('pick', it.raw);
56
+ };
57
+ const onDoc = (e: MouseEvent) => {
58
+ if (root.value && !root.value.contains(e.target as Node)) open.value = false;
59
+ };
60
+ onMounted(() => document.addEventListener('click', onDoc));
61
+ onBeforeUnmount(() => document.removeEventListener('click', onDoc));
62
+ </script>
63
+
64
+ <style scoped>
65
+ .ac-menu { max-height: 260px; overflow: auto; margin-top: 2px; }
66
+ .dropdown-item { white-space: normal; }
67
+ .dropdown-item:hover, .dropdown-item:focus { background: rgba(117, 179, 68, 0.15); color: inherit; }
68
+ </style>