@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.
- package/assets/scss/_variables.scss +3 -3
- package/assets/scss/components/_header.scss +1 -1
- package/assets/scss/style.css +4 -4
- package/components/AboutUs/StaticContent.vue +32 -0
- package/components/Cart/CartEmpty.vue +42 -0
- package/components/Cart/CartItem.vue +145 -0
- package/components/Cart/CartSummary.vue +57 -0
- package/components/Cart/ProductQuantity.vue +23 -13
- package/components/Cart/index.vue +68 -150
- package/components/Categories/index.vue +88 -291
- package/components/Checkout/AddressFields.vue +74 -0
- package/components/Checkout/CheckoutForm.vue +75 -0
- package/components/Checkout/CityAutocomplete.vue +68 -0
- package/components/Checkout/DeliveryMethodCard.vue +66 -0
- package/components/Checkout/DeliveryOptions.vue +148 -0
- package/components/Checkout/OrderSummary.vue +116 -0
- package/components/Checkout/PaymentOptions.vue +76 -0
- package/components/Checkout/PickupPoints.vue +46 -0
- package/components/Checkout/PromoCode.vue +166 -0
- package/components/Checkout/index.vue +235 -173
- package/components/Checkout/types.ts +45 -0
- package/components/Common/PageBanner.vue +22 -20
- package/components/Common/ProductCard.vue +139 -115
- package/components/Common/Services.vue +4 -4
- package/components/Contact/ContactForm.vue +44 -74
- package/components/Contact/ContactInfo.vue +35 -61
- package/components/FAQ/index.vue +6 -119
- package/components/Layout/Copyright.vue +11 -9
- package/components/Layout/Footer.vue +49 -136
- package/components/Layout/HeaderBar.vue +174 -0
- package/components/Layout/HeaderLogo.vue +27 -0
- package/components/Layout/MiddleHeader.vue +1 -228
- package/components/Layout/MiddleHeaderThree.vue +1 -203
- package/components/Layout/MiddleHeaderTwo.vue +1 -239
- package/components/Layout/NavLinks.vue +23 -0
- package/components/Layout/Navbar.vue +38 -151
- package/components/Layout/NavbarStyleFour.vue +1 -332
- package/components/Layout/NavbarStyleThree.vue +1 -188
- package/components/Layout/NavbarStyleTwo.vue +1 -108
- package/components/Layout/Preloader.vue +5 -12
- package/components/Layout/ResponsiveNavbar.vue +32 -418
- package/components/Layout/SitewideBanner.vue +285 -0
- package/components/Layout/TopHeader.vue +17 -83
- package/components/Layout/layoutData.ts +83 -0
- package/components/PrivacyPolicy/index.vue +6 -110
- package/components/ProductDetails/ProductDetailsTab.vue +31 -321
- package/components/ProductDetails/ProductGallery.vue +45 -0
- package/components/ProductDetails/ProductQuantity.vue +8 -14
- package/components/ProductDetails/RecentlyViewed.vue +53 -289
- package/components/ProductDetails/RelatedProducts.vue +64 -0
- package/components/ProductDetails/index.vue +90 -230
- package/components/Products/index.vue +78 -16
- package/components/TermsConditions/index.vue +6 -110
- package/components/Wishlist/index.vue +62 -88
- package/composables/useCart.ts +133 -81
- package/composables/useCategories.ts +66 -29
- package/composables/useCategoryImages.ts +45 -0
- package/composables/useCheckout.ts +302 -95
- package/composables/useContactForm.ts +57 -0
- package/composables/useHomeContent.ts +220 -0
- package/composables/useI18n.ts +12 -24
- package/composables/useJsonLd.ts +162 -0
- package/composables/useProducts.ts +244 -81
- package/composables/useRecent.ts +30 -0
- package/composables/useSanitize.ts +76 -0
- package/composables/useSiteConfig.ts +51 -45
- package/composables/useStaticPages.ts +48 -0
- package/composables/useStoreOrigin.ts +32 -0
- package/composables/useTemplateSections.ts +51 -3
- package/composables/useThemeVars.ts +113 -0
- package/composables/useWishlist.ts +72 -0
- package/layouts/default.vue +80 -19
- package/locales/en.json +255 -86
- package/locales/uk.json +251 -82
- package/modules/template-schema.ts +35 -0
- package/nuxt.config.ts +8 -11
- package/package.json +1 -1
- package/pages/about-us.vue +9 -9
- package/pages/cart.vue +0 -1
- package/pages/categories.vue +0 -1
- package/pages/category/[slug].vue +84 -0
- package/pages/checkout.vue +0 -1
- package/pages/contact.vue +6 -6
- package/pages/delivery.vue +12 -0
- package/pages/faq.vue +6 -6
- package/pages/order-success.vue +113 -0
- package/pages/privacy-policy.vue +6 -6
- package/pages/product/[slug].vue +82 -0
- package/pages/product-details.vue +14 -6
- package/pages/products.vue +12 -1
- package/pages/returns.vue +12 -0
- package/pages/terms-conditions.vue +6 -6
- package/pages/wishlist.vue +2 -2
- package/utils/seo-urls.ts +70 -0
- package/utils/store-origin.ts +36 -0
- package/components/AboutUs/AboutUsTuan.vue +0 -25
- package/components/AboutUs/Statistics.vue +0 -42
- package/components/AboutUs/SubscribeToTheNewsletter.vue +0 -57
- package/components/AddAddress/index.vue +0 -68
- package/components/BestSellers/Products.vue +0 -1562
- package/components/BestSellers/RecentlyViewed.vue +0 -304
- package/components/ChangePassword/index.vue +0 -69
- package/components/Common/DashboardNavigation.vue +0 -35
- package/components/Dashboard/RecentOrder.vue +0 -173
- package/components/Dashboard/index.vue +0 -77
- package/components/EditAddress/index.vue +0 -117
- package/components/EditProfile/index.vue +0 -95
- package/components/FeaturedProduct/FeaturedProducts.vue +0 -304
- package/components/FeaturedProduct/Products.vue +0 -1562
- package/components/ForgotPassword/index.vue +0 -49
- package/components/Layout/FooterTwo.vue +0 -159
- package/components/Layout/LocationOption.vue +0 -178
- package/components/Layout/RTLSwitchBtn.vue +0 -40
- package/components/Login/index.vue +0 -89
- package/components/MyAccount/index.vue +0 -154
- package/components/NewArrivals/Products.vue +0 -1969
- package/components/NewArrivals/RecentlyViewed.vue +0 -304
- package/components/OrderDetails/index.vue +0 -75
- package/components/OrderHistory/index.vue +0 -195
- package/components/Products/RecentlyViewed.vue +0 -302
- package/components/Register/index.vue +0 -83
- package/components/TrendingProducts/FeaturedProducts.vue +0 -304
- package/components/TrendingProducts/Products.vue +0 -1564
- package/components/Wishlist/ProductQuantity.vue +0 -29
- package/layouts/inner.vue +0 -70
- package/locales/ar.json +0 -234
- package/locales/ru.json +0 -234
- package/pages/address.vue +0 -11
- package/pages/change-password.vue +0 -11
- package/pages/dashboard.vue +0 -11
- package/pages/edit-address.vue +0 -11
- package/pages/edit-profile.vue +0 -11
- package/pages/forgot-password.vue +0 -11
- package/pages/login.vue +0 -11
- package/pages/my-account.vue +0 -11
- package/pages/order-details.vue +0 -11
- package/pages/order-history.vue +0 -11
- package/pages/register.vue +0 -11
|
@@ -1,305 +1,102 @@
|
|
|
1
|
-
<
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
<
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}
|
|
305
|
-
|
|
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>
|