@crm-market/template-shared 1.0.6 → 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 (139) 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 +67 -31
  57. package/composables/useCategoryImages.ts +45 -0
  58. package/composables/useCheckout.ts +302 -97
  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 -83
  64. package/composables/useRecent.ts +30 -0
  65. package/composables/useSanitize.ts +76 -0
  66. package/composables/useSiteConfig.ts +51 -47
  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/image.ts +0 -1
  95. package/utils/seo-urls.ts +70 -0
  96. package/utils/store-origin.ts +36 -0
  97. package/components/AboutUs/AboutUsTuan.vue +0 -25
  98. package/components/AboutUs/Statistics.vue +0 -42
  99. package/components/AboutUs/SubscribeToTheNewsletter.vue +0 -57
  100. package/components/AddAddress/index.vue +0 -68
  101. package/components/BestSellers/Products.vue +0 -1562
  102. package/components/BestSellers/RecentlyViewed.vue +0 -304
  103. package/components/ChangePassword/index.vue +0 -69
  104. package/components/Common/DashboardNavigation.vue +0 -35
  105. package/components/Dashboard/RecentOrder.vue +0 -173
  106. package/components/Dashboard/index.vue +0 -77
  107. package/components/EditAddress/index.vue +0 -117
  108. package/components/EditProfile/index.vue +0 -95
  109. package/components/FeaturedProduct/FeaturedProducts.vue +0 -304
  110. package/components/FeaturedProduct/Products.vue +0 -1562
  111. package/components/ForgotPassword/index.vue +0 -49
  112. package/components/Layout/FooterTwo.vue +0 -159
  113. package/components/Layout/LocationOption.vue +0 -178
  114. package/components/Layout/RTLSwitchBtn.vue +0 -40
  115. package/components/Login/index.vue +0 -89
  116. package/components/MyAccount/index.vue +0 -154
  117. package/components/NewArrivals/Products.vue +0 -1969
  118. package/components/NewArrivals/RecentlyViewed.vue +0 -304
  119. package/components/OrderDetails/index.vue +0 -75
  120. package/components/OrderHistory/index.vue +0 -195
  121. package/components/Products/RecentlyViewed.vue +0 -302
  122. package/components/Register/index.vue +0 -83
  123. package/components/TrendingProducts/FeaturedProducts.vue +0 -304
  124. package/components/TrendingProducts/Products.vue +0 -1564
  125. package/components/Wishlist/ProductQuantity.vue +0 -29
  126. package/layouts/inner.vue +0 -70
  127. package/locales/ar.json +0 -234
  128. package/locales/ru.json +0 -234
  129. package/pages/address.vue +0 -11
  130. package/pages/change-password.vue +0 -11
  131. package/pages/dashboard.vue +0 -11
  132. package/pages/edit-address.vue +0 -11
  133. package/pages/edit-profile.vue +0 -11
  134. package/pages/forgot-password.vue +0 -11
  135. package/pages/login.vue +0 -11
  136. package/pages/my-account.vue +0 -11
  137. package/pages/order-details.vue +0 -11
  138. package/pages/order-history.vue +0 -11
  139. package/pages/register.vue +0 -11
@@ -0,0 +1,66 @@
1
+ <template>
2
+ <label class="dm-card" :class="{ active: selected }" :for="id">
3
+ <input :id="id" class="dm-radio" type="radio" :checked="selected" @change="$emit('select')" />
4
+ <span class="dm-icon"><i :class="icon"></i></span>
5
+ <span class="dm-body">
6
+ <span class="dm-title">{{ title }}</span>
7
+ <span v-if="time" class="dm-time">{{ time }}</span>
8
+ </span>
9
+ <span class="dm-price" :class="{ free: !price }">{{ price ? formatUah(price) : t('checkout.free') }}</span>
10
+ </label>
11
+ </template>
12
+
13
+ <script setup lang="ts">
14
+ // Картка способу доставки (вся картка клікабельна)
15
+ const props = defineProps<{ id: string; provider: string; title: string; time?: string; price?: number; selected: boolean }>();
16
+ defineEmits<{ select: [] }>();
17
+ const { t } = useI18n();
18
+
19
+ const ICONS: Record<string, string> = {
20
+ nova_poshta: 'ri-send-plane-line',
21
+ ukrposhta: 'ri-mail-send-line',
22
+ meest: 'ri-map-pin-line',
23
+ courier: 'ri-truck-line',
24
+ pickup: 'ri-store-2-line',
25
+ };
26
+ const icon = computed(() => ICONS[props.provider] || 'ri-truck-line');
27
+ </script>
28
+
29
+ <style scoped lang="scss">
30
+ .dm-card {
31
+ position: relative;
32
+ display: flex;
33
+ align-items: center;
34
+ gap: 12px;
35
+ height: 100%;
36
+ margin: 0;
37
+ padding: 14px 16px;
38
+ border: 2px solid #e7e7e7;
39
+ border-radius: 10px;
40
+ background: #fff;
41
+ cursor: pointer;
42
+ transition: border-color 0.15s, box-shadow 0.15s;
43
+ &:hover { border-color: var(--secondary-color); }
44
+ &.active {
45
+ border-color: var(--primary-color);
46
+ box-shadow: 0 0 0 3px rgba(74, 141, 22, 0.12);
47
+ }
48
+ }
49
+ .dm-radio { position: absolute; opacity: 0; pointer-events: none; }
50
+ .dm-icon {
51
+ flex: 0 0 40px;
52
+ height: 40px;
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: center;
56
+ border-radius: 50%;
57
+ font-size: 20px;
58
+ color: var(--primary-color);
59
+ background: rgba(117, 179, 68, 0.15);
60
+ }
61
+ .active .dm-icon { color: #fff; background: var(--primary-color); }
62
+ .dm-body { flex: 1; display: flex; flex-direction: column; min-width: 0; }
63
+ .dm-title { font-weight: 600; line-height: 1.2; }
64
+ .dm-time { font-size: 13px; color: #777; }
65
+ .dm-price { font-weight: 600; white-space: nowrap; &.free { color: var(--primary-color); } }
66
+ </style>
@@ -0,0 +1,148 @@
1
+ <template>
2
+ <div class="delivery-options checkout-card" data-field="delivery">
3
+ <h3>{{ t('checkout.deliveryTitle') }}</h3>
4
+
5
+ <div class="row g-2">
6
+ <div v-for="m in methods" :key="methodKey(m)" class="col-12 col-md-6">
7
+ <CheckoutDeliveryMethodCard
8
+ :id="`dl-${methodKey(m)}`"
9
+ :provider="methodKey(m)"
10
+ :title="m.label || providerLabel(methodKey(m))"
11
+ :time="m.time"
12
+ :price="methodPrice(m)"
13
+ :selected="model.provider === methodKey(m)"
14
+ @select="model.provider = methodKey(m)"
15
+ />
16
+ </div>
17
+ </div>
18
+ <!-- Поки опції не завантажені (SSR/перший рендер) — не лякаємо «не налаштовано» -->
19
+ <p v-if="!options" class="text-muted mt-2 mb-0"><span class="spinner-border spinner-border-sm me-2" role="status"></span>{{ t('checkout.loadingOptions') }}</p>
20
+ <p v-else-if="!methods.length" class="text-muted mt-2 mb-0">{{ t('checkout.noDelivery') }}</p>
21
+ <div v-if="errors.delivery" class="invalid-feedback d-block">{{ errors.delivery }}</div>
22
+
23
+ <!-- Панель полів вибраного способу -->
24
+ <div v-if="model.provider" class="dl-panel mt-3">
25
+ <!-- Нова Пошта: місто -> відділення -->
26
+ <template v-if="isWarehouse">
27
+ <div class="form-group mb-3" data-field="city">
28
+ <label class="label">{{ t('checkout.city') }} *</label>
29
+ <CheckoutCityAutocomplete
30
+ v-model="model.cityQuery"
31
+ :items="cityItems"
32
+ :loading="searchingCities"
33
+ :error="citiesError"
34
+ :invalid="!!errors.city"
35
+ :show-empty="cityQueryReady"
36
+ :placeholder="t('checkout.cityPlaceholder')"
37
+ @input="onCityInput"
38
+ @pick="pickCity"
39
+ />
40
+ <div v-if="errors.city" class="invalid-feedback d-block">{{ errors.city }}</div>
41
+ </div>
42
+ <div class="form-group mb-0" data-field="warehouse">
43
+ <label class="label">{{ t('checkout.warehouse') }} *</label>
44
+ <CheckoutCityAutocomplete
45
+ v-model="model.warehouseQuery"
46
+ :items="warehouseItems"
47
+ :loading="loadingWarehouses"
48
+ :invalid="!!errors.warehouse"
49
+ :show-empty="!!model.city"
50
+ :placeholder="model.city ? t('checkout.warehousePlaceholder') : t('checkout.pickCityFirst')"
51
+ @input="model.warehouse = null"
52
+ @pick="pickWarehouse"
53
+ />
54
+ <div v-if="errors.warehouse" class="invalid-feedback d-block">{{ errors.warehouse }}</div>
55
+ </div>
56
+ </template>
57
+
58
+ <!-- Самовивіз -->
59
+ <div v-else-if="model.provider === 'pickup'" data-field="pickup">
60
+ <CheckoutPickupPoints v-model="model.pickupPoint" :points="pickupPoints" :error="errors.pickup" />
61
+ </div>
62
+
63
+ <!-- Кур'єр / Укрпошта / інше -->
64
+ <template v-else>
65
+ <CheckoutAddressFields v-model="model" :errors="errors" :postcode="needsPostcode(model.provider)" :street-mode="model.provider === 'courier'" />
66
+ </template>
67
+ </div>
68
+ </div>
69
+ </template>
70
+
71
+ <script setup lang="ts">
72
+ import { computed, watch } from 'vue';
73
+ import type { DeliveryState, FieldErrors } from './types';
74
+
75
+ // Спосіб доставки (картки) + панель полів саме для нього
76
+ defineProps<{ errors: FieldErrors }>();
77
+ const model = defineModel<DeliveryState>({ required: true });
78
+ const { t } = useI18n();
79
+ const {
80
+ options, providerLabel, hasWarehouseFlow, needsPostcode,
81
+ cities, searchCities, searchingCities, citiesError, warehouses, loadWarehouses, loadingWarehouses,
82
+ } = useCheckout();
83
+
84
+ const methods = computed(() => options.value?.delivery || []);
85
+ const methodKey = (m: any): string => m.value || m.provider;
86
+ // Для кур'єра ціна — courierDeliveryPrice, якщо задана
87
+ const methodPrice = (m: any): number =>
88
+ methodKey(m) === 'courier' && m.courierDeliveryPrice != null ? m.courierDeliveryPrice : m.price || 0;
89
+ const isWarehouse = computed(() => hasWarehouseFlow(model.value.provider));
90
+ const pickupPoints = computed(
91
+ () => (methods.value.find((m) => methodKey(m) === 'pickup')?.points as any[]) || [],
92
+ );
93
+
94
+ const cityItems = computed(() =>
95
+ (cities.value || []).map((c: any) => ({ key: c.ref, title: c.name, sub: c.region, raw: c })),
96
+ );
97
+ const cityQueryReady = computed(
98
+ () => !model.value.city && model.value.cityQuery.trim().length >= 2 && !searchingCities.value && !citiesError.value,
99
+ );
100
+
101
+ // Відділень може бути тисячі — фільтруємо на клієнті, показуємо до 60
102
+ const warehouseItems = computed(() => {
103
+ const q = model.value.warehouseQuery.trim().toLowerCase();
104
+ const list = warehouses.value || [];
105
+ const res = q && !model.value.warehouse ? list.filter((w: any) => (w.description || '').toLowerCase().includes(q)) : list;
106
+ return res.slice(0, 60).map((w: any) => ({ key: w.ref, title: w.description, raw: w }));
107
+ });
108
+
109
+ // Зміна способу — скидаємо залежні поля
110
+ watch(
111
+ () => model.value.provider,
112
+ () => {
113
+ const m = model.value;
114
+ m.cityQuery = ''; m.city = null; m.warehouseQuery = ''; m.warehouse = null;
115
+ m.pickupPoint = null; cities.value = []; warehouses.value = [];
116
+ },
117
+ );
118
+
119
+ let timer: ReturnType<typeof setTimeout> | null = null;
120
+ const onCityInput = () => {
121
+ const m = model.value;
122
+ m.city = null; m.warehouse = null; m.warehouseQuery = ''; warehouses.value = [];
123
+ if (timer) clearTimeout(timer);
124
+ timer = setTimeout(() => searchCities(m.provider, m.cityQuery), 350);
125
+ };
126
+
127
+ const pickCity = async (c: any) => {
128
+ const m = model.value;
129
+ m.city = { ref: c.ref, name: c.name };
130
+ m.cityQuery = c.name + (c.region ? `, ${c.region}` : '');
131
+ m.warehouse = null; m.warehouseQuery = '';
132
+ await loadWarehouses(m.provider, c.ref);
133
+ };
134
+
135
+ const pickWarehouse = (w: any) => {
136
+ model.value.warehouse = { ref: w.ref, description: w.description };
137
+ model.value.warehouseQuery = w.description;
138
+ };
139
+ </script>
140
+
141
+ <style scoped lang="scss">
142
+ .dl-panel {
143
+ padding: 16px;
144
+ border-radius: 10px;
145
+ background: #f8faf5;
146
+ border: 1px solid rgba(117, 179, 68, 0.3);
147
+ }
148
+ </style>
@@ -0,0 +1,116 @@
1
+ <template>
2
+ <aside class="order-summary">
3
+ <h3>{{ t('checkout.yourOrder') }}</h3>
4
+ <ul class="os-items list-unstyled">
5
+ <li v-for="item in cart.items" :key="item.productId" class="os-item">
6
+ <div class="os-thumb">
7
+ <img v-if="item.image" :src="item.image" :alt="item.name" loading="lazy" />
8
+ <i v-else class="ri-image-line"></i>
9
+ <span class="os-qty">{{ item.quantity }}</span>
10
+ </div>
11
+ <div class="os-name">
12
+ <span>{{ item.name }}</span>
13
+ <small class="text-muted d-block">
14
+ {{ formatUah(unitPrice(item)) }}
15
+ <del v-if="unitPrice(item) < item.price" class="ms-1">{{ formatUah(item.price) }}</del>
16
+ × {{ item.quantity }}{{ item.unit ? ' ' + item.unit : '' }}
17
+ </small>
18
+ </div>
19
+ <div class="os-sum">{{ formatUah(unitPrice(item) * item.quantity) }}</div>
20
+ </li>
21
+ </ul>
22
+
23
+ <CheckoutPromoCode :buyer="buyer" />
24
+
25
+ <dl class="os-totals">
26
+ <div class="os-row"><dt>{{ t('checkout.sumGoods') }}</dt><dd>{{ formatUah(subtotal + totalDiscount) }}</dd></div>
27
+ <div v-if="totalDiscount > 0" class="os-row os-discount">
28
+ <dt>{{ t('checkout.itemsDiscount') }}</dt><dd>−{{ formatUah(totalDiscount) }}</dd>
29
+ </div>
30
+ <div v-if="promoDiscount > 0" class="os-row os-discount">
31
+ <dt>{{ t('checkout.promoApplied', { code: promo!.code }) }}</dt><dd>−{{ formatUah(promoDiscount) }}</dd>
32
+ </div>
33
+ <div class="os-row">
34
+ <dt>{{ t('checkout.sumDelivery') }}</dt>
35
+ <dd v-if="freeShipping" class="os-discount">{{ t('checkout.freeShipping') }}</dd>
36
+ <dd v-else-if="deliveryCost != null">{{ deliveryCost ? formatUah(deliveryCost) : t('checkout.free') }}</dd>
37
+ <dd v-else class="text-muted small">{{ delivery?.provider ? t('checkout.byCarrierTariffs') : '—' }}</dd>
38
+ </div>
39
+ <div class="os-row os-total"><dt>{{ t('checkout.sumTotal') }}</dt><dd>{{ formatUah(total) }}</dd></div>
40
+ </dl>
41
+ <slot />
42
+ </aside>
43
+ </template>
44
+
45
+ <script setup lang="ts">
46
+ import { computed } from 'vue';
47
+ import type { DeliveryState } from './types';
48
+ import type { PromoBuyer } from '../../composables/useCheckout';
49
+
50
+ // Підсумок: товари, знижки (акції + промокод), доставка (відома лише для кур'єра/фіксованої ціни), разом; слот — помилки та кнопка
51
+ const props = defineProps<{ delivery?: DeliveryState; buyer?: PromoBuyer }>();
52
+ const { t } = useI18n();
53
+ const { cart, subtotal, totalDiscount, unitPrice } = useCart();
54
+ const { options, promo } = useCheckout();
55
+
56
+ // Промокод: знижка не більша за суму товарів; freeShipping обнуляє доставку
57
+ const promoDiscount = computed(() => Math.min(promo.value?.discountAmount || 0, subtotal.value));
58
+ const freeShipping = computed(() => !!promo.value?.freeShipping);
59
+
60
+ const deliveryCost = computed<number | null>(() => {
61
+ const p = props.delivery?.provider;
62
+ if (!p) return null;
63
+ const m: any = options.value?.delivery.find((d: any) => (d.value || d.provider) === p);
64
+ if (!m) return null;
65
+ if (p === 'pickup') return 0;
66
+ if (p === 'courier') return m.courierDeliveryPrice ?? m.price ?? 0;
67
+ return null; // НП/Укрпошта/Meest — за тарифами перевізника
68
+ });
69
+
70
+ const total = computed(() =>
71
+ Math.max(0, subtotal.value - promoDiscount.value) + (freeShipping.value ? 0 : deliveryCost.value || 0),
72
+ );
73
+ </script>
74
+
75
+ <style scoped lang="scss">
76
+ .order-summary {
77
+ padding: 24px;
78
+ border: 1px solid #e7e7e7;
79
+ border-radius: 12px;
80
+ background: #fff;
81
+ }
82
+ .os-items { max-height: 340px; overflow: auto; margin-bottom: 16px; }
83
+ .os-item { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid #f0f0f0; }
84
+ .os-thumb {
85
+ position: relative;
86
+ flex: 0 0 56px;
87
+ height: 56px;
88
+ border: 1px solid #eee;
89
+ border-radius: 8px;
90
+ display: flex;
91
+ align-items: center;
92
+ justify-content: center;
93
+ color: #bbb;
94
+ img { width: 100%; height: 100%; object-fit: cover; border-radius: 7px; }
95
+ }
96
+ .os-qty {
97
+ position: absolute;
98
+ top: -8px;
99
+ right: -8px;
100
+ min-width: 20px;
101
+ height: 20px;
102
+ padding: 0 5px;
103
+ border-radius: 10px;
104
+ background: var(--primary-color);
105
+ color: #fff;
106
+ font-size: 12px;
107
+ line-height: 20px;
108
+ text-align: center;
109
+ }
110
+ .os-name { flex: 1; min-width: 0; line-height: 1.3; }
111
+ .os-sum { font-weight: 600; white-space: nowrap; }
112
+ .os-totals { margin: 0 0 20px; }
113
+ .os-row { display: flex; justify-content: space-between; padding: 6px 0; dt, dd { margin: 0; font-weight: 400; } }
114
+ .os-discount, .os-discount dd { color: var(--primary-color); }
115
+ .os-total { margin-top: 6px; padding-top: 12px; border-top: 2px solid #eee; font-size: 20px; dt, dd { font-weight: 700; } dd { color: var(--primary-color); } }
116
+ </style>
@@ -0,0 +1,76 @@
1
+ <template>
2
+ <div class="payment-options checkout-card">
3
+ <h3>{{ t('checkout.paymentOption') }}</h3>
4
+ <div class="row g-2">
5
+ <div v-if="!options" class="col-12 text-muted"><span class="spinner-border spinner-border-sm me-2" role="status"></span>{{ t('checkout.loadingOptions') }}</div>
6
+ <div v-if="options && options.payment?.cashOnDelivery !== false" class="col-12 col-md-6">
7
+ <label class="pay-card" :class="{ active: payment === 'cod' }" for="pay-cod">
8
+ <input id="pay-cod" v-model="payment" class="pay-radio" type="radio" value="cod" />
9
+ <i class="ri-wallet-3-line pay-ico"></i>
10
+ <span><b class="d-block">{{ t('checkout.cashOnDelivery') }}</b><small class="text-muted">{{ t('checkout.codDesc') }}</small></span>
11
+ </label>
12
+ </div>
13
+ <div v-if="options?.payment?.online" class="col-12 col-md-6">
14
+ <label class="pay-card" :class="{ active: payment === 'online' }" for="pay-online">
15
+ <input id="pay-online" v-model="payment" class="pay-radio" type="radio" value="online" />
16
+ <i class="ri-bank-card-line pay-ico"></i>
17
+ <span>
18
+ <b class="d-block">{{ t('checkout.onlinePayment') }}</b>
19
+ <small class="text-muted">{{ providerNames || t('checkout.onlineDesc') }}</small>
20
+ </span>
21
+ </label>
22
+ </div>
23
+ </div>
24
+
25
+ <!-- Вибір платіжного провайдера, якщо увімкнено кілька -->
26
+ <div v-if="payment === 'online' && providers.length > 1" class="mt-3">
27
+ <div class="label mb-2">{{ t('checkout.chooseProvider') }}</div>
28
+ <div class="d-flex flex-wrap gap-2">
29
+ <label v-for="pp in providers" :key="pp.provider" class="prov-chip" :class="{ active: provider === pp.provider }" :for="`pp-${pp.provider}`">
30
+ <input :id="`pp-${pp.provider}`" v-model="provider" class="pay-radio" type="radio" :value="pp.provider" />
31
+ {{ pp.label }}
32
+ </label>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ </template>
37
+
38
+ <script setup lang="ts">
39
+ import { computed } from 'vue';
40
+
41
+ const payment = defineModel<'cod' | 'online'>('payment', { required: true });
42
+ const provider = defineModel<string>('provider', { default: '' });
43
+ const { t } = useI18n();
44
+ const { options } = useCheckout();
45
+
46
+ const providers = computed(() => options.value?.payment?.onlineProviders || []);
47
+ const providerNames = computed(() => providers.value.map((p) => p.label).join(', '));
48
+ </script>
49
+
50
+ <style scoped lang="scss">
51
+ .pay-card {
52
+ display: flex;
53
+ align-items: center;
54
+ gap: 12px;
55
+ height: 100%;
56
+ margin: 0;
57
+ padding: 14px 16px;
58
+ border: 2px solid #e7e7e7;
59
+ border-radius: 10px;
60
+ background: #fff;
61
+ cursor: pointer;
62
+ transition: border-color 0.15s, box-shadow 0.15s;
63
+ &:hover { border-color: var(--secondary-color); }
64
+ &.active { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(74, 141, 22, 0.12); }
65
+ }
66
+ .pay-radio { position: absolute; opacity: 0; pointer-events: none; }
67
+ .pay-ico { font-size: 26px; color: var(--primary-color); }
68
+ .prov-chip {
69
+ margin: 0;
70
+ padding: 6px 16px;
71
+ border: 2px solid #e7e7e7;
72
+ border-radius: 20px;
73
+ cursor: pointer;
74
+ &.active { border-color: var(--primary-color); color: var(--primary-color); font-weight: 600; }
75
+ }
76
+ </style>
@@ -0,0 +1,46 @@
1
+ <template>
2
+ <div>
3
+ <div v-if="points.length" class="row g-2">
4
+ <div v-for="(p, i) in points" :key="i" class="col-12">
5
+ <label class="pp-card" :class="{ active: model === p }" :for="`pp-${i}`">
6
+ <input :id="`pp-${i}`" v-model="model" class="pp-radio" type="radio" :value="p" />
7
+ <i class="ri-store-2-line pp-ico"></i>
8
+ <span class="d-block">
9
+ <b>{{ p.name }}</b>
10
+ <span v-if="p.city || p.address" class="d-block text-muted small">{{ [p.city, p.address].filter(Boolean).join(', ') }}</span>
11
+ <span v-if="p.workingHours" class="d-block small"><i class="ri-time-line me-1"></i>{{ p.workingHours }}</span>
12
+ </span>
13
+ </label>
14
+ </div>
15
+ </div>
16
+ <p v-else class="text-muted mb-0">{{ t('checkout.pickupDefault') }}</p>
17
+ <div v-if="error" class="invalid-feedback d-block">{{ error }}</div>
18
+ </div>
19
+ </template>
20
+
21
+ <script setup lang="ts">
22
+ import type { DeliveryState } from './types';
23
+
24
+ // Пункти самовивозу як radio-картки
25
+ defineProps<{ points: any[]; error?: string }>();
26
+ const model = defineModel<DeliveryState['pickupPoint']>({ required: true });
27
+ const { t } = useI18n();
28
+ </script>
29
+
30
+ <style scoped lang="scss">
31
+ .pp-card {
32
+ display: flex;
33
+ gap: 12px;
34
+ align-items: flex-start;
35
+ margin: 0;
36
+ padding: 12px 14px;
37
+ border: 2px solid #e7e7e7;
38
+ border-radius: 10px;
39
+ background: #fff;
40
+ cursor: pointer;
41
+ &:hover { border-color: var(--secondary-color); }
42
+ &.active { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(74, 141, 22, 0.12); }
43
+ }
44
+ .pp-radio { position: absolute; opacity: 0; pointer-events: none; }
45
+ .pp-ico { font-size: 22px; color: var(--primary-color); }
46
+ </style>
@@ -0,0 +1,166 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, onBeforeUnmount } from 'vue';
3
+ import type { PromoBuyer } from '../../composables/useCheckout';
4
+
5
+ // Поле «Промокод» на оформленні: перевірка на сервері (POST promo-code/validate) з товарами
6
+ // й уже введеними email/телефоном (персональні коди). Зміна кошика або контактів → повторна
7
+ // перевірка (debounce): покупець бачить «код не для вас» ще до «Підтвердити замовлення».
8
+ const props = defineProps<{ buyer?: PromoBuyer }>();
9
+ const { t } = useI18n();
10
+ const { cart } = useCart();
11
+ const { promo, promoChecking, promoError, promoFromOrder, validatePromo, clearPromo } = useCheckout();
12
+
13
+ const code = ref(promo.value?.code || '');
14
+ const notice = ref('');
15
+ const touched = ref(false);
16
+
17
+ const items = () => cart.value.items.map((i) => ({ productId: i.productId, quantity: i.quantity }));
18
+
19
+ const buyer = (): PromoBuyer => ({
20
+ ...(props.buyer?.email?.trim() ? { email: props.buyer.email.trim() } : {}),
21
+ ...(props.buyer?.phone ? { phone: props.buyer.phone } : {}),
22
+ });
23
+
24
+ // Помилка з бека (у т.ч. відмова при створенні замовлення); без тексту — загальна
25
+ const errorText = computed(() => {
26
+ if (promo.value || promoChecking.value) return '';
27
+ if (promoError.value) return promoError.value;
28
+ return touched.value ? t('checkout.promoInvalid') : '';
29
+ });
30
+
31
+ // Сервер відхилив код при оформленні — повертаємо його в поле поруч із помилкою
32
+ watch(promoFromOrder, (c) => {
33
+ if (c) code.value = c;
34
+ });
35
+
36
+ const apply = async () => {
37
+ if (!code.value.trim() || promoChecking.value) return;
38
+ notice.value = '';
39
+ touched.value = true;
40
+ await validatePromo(code.value, items(), buyer());
41
+ };
42
+
43
+ const onInput = () => {
44
+ touched.value = false;
45
+ promoError.value = '';
46
+ };
47
+
48
+ const remove = () => {
49
+ clearPromo();
50
+ code.value = '';
51
+ touched.value = false;
52
+ notice.value = '';
53
+ };
54
+
55
+ // Кошик або контакти змінились — перевіряємо застосований (чи щойно відхилений) код ще раз
56
+ let timer: ReturnType<typeof setTimeout> | undefined;
57
+ watch(
58
+ () =>
59
+ [
60
+ cart.value.items.map((i) => `${i.productId}:${i.quantity}`).join(','),
61
+ props.buyer?.email?.trim() || '',
62
+ props.buyer?.phone || '',
63
+ ].join('|'),
64
+ () => {
65
+ const pending = promo.value?.code || (touched.value && promoError.value ? code.value.trim() : '');
66
+ if (!pending) return;
67
+ clearTimeout(timer);
68
+ const wasApplied = !!promo.value;
69
+ timer = setTimeout(async () => {
70
+ const res = await validatePromo(pending, items(), buyer());
71
+ if (!res.valid && wasApplied) {
72
+ touched.value = true;
73
+ if (!res.message) notice.value = t('checkout.promoRemoved');
74
+ }
75
+ }, 600);
76
+ },
77
+ );
78
+ onBeforeUnmount(() => clearTimeout(timer));
79
+ </script>
80
+
81
+ <template>
82
+ <div class="promo-code" data-field="promo">
83
+ <label for="checkout-promo" class="form-label promo-code__label">{{ t('checkout.promoCode') }}</label>
84
+
85
+ <div v-if="promo" class="promo-code__applied">
86
+ <span>
87
+ <i class="ri-coupon-3-line"></i>
88
+ <b>{{ promo.code }}</b>
89
+ <template v-if="promo.discountAmount"> · −{{ formatUah(promo.discountAmount) }}</template>
90
+ <template v-if="promo.freeShipping"> · {{ t('checkout.freeShipping') }}</template>
91
+ </span>
92
+ <button type="button" class="promo-code__remove" :aria-label="t('checkout.promoRemove')" @click="remove">
93
+ <i class="ri-close-line"></i>
94
+ </button>
95
+ </div>
96
+
97
+ <div v-else class="promo-code__row">
98
+ <input
99
+ id="checkout-promo"
100
+ v-model="code"
101
+ type="text"
102
+ class="form-control"
103
+ autocomplete="off"
104
+ autocapitalize="characters"
105
+ :placeholder="t('checkout.promoPlaceholder')"
106
+ :aria-invalid="!!errorText"
107
+ aria-describedby="checkout-promo-msg"
108
+ @input="onInput"
109
+ @keydown.enter.prevent="apply"
110
+ />
111
+ <button
112
+ type="button"
113
+ class="btn btn-outline-success promo-code__btn"
114
+ :disabled="!code.trim() || promoChecking"
115
+ @click="apply"
116
+ >
117
+ <span v-if="promoChecking" class="spinner-border spinner-border-sm" aria-hidden="true"></span>
118
+ <span v-else>{{ t('checkout.promoApply') }}</span>
119
+ </button>
120
+ </div>
121
+
122
+ <div id="checkout-promo-msg" aria-live="polite">
123
+ <small v-if="errorText" class="text-danger d-block mt-1">{{ errorText }}</small>
124
+ <small v-else-if="notice" class="text-muted d-block mt-1">{{ notice }}</small>
125
+ <small v-if="promo?.requiresBuyer" class="text-muted d-block mt-1">
126
+ <i class="ri-user-line"></i> {{ t('checkout.promoRequiresBuyer') }}
127
+ </small>
128
+ </div>
129
+ </div>
130
+ </template>
131
+
132
+ <style scoped lang="scss">
133
+ .promo-code { margin-bottom: 16px; }
134
+ .promo-code__label { font-weight: 600; margin-bottom: 6px; }
135
+ .promo-code__row {
136
+ display: flex;
137
+ gap: 8px;
138
+ .form-control { min-height: 44px; text-transform: uppercase; }
139
+ .form-control::placeholder { text-transform: none; }
140
+ .form-control:focus { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(74, 141, 22, 0.15); }
141
+ }
142
+ .promo-code__btn { min-height: 44px; min-width: 120px; white-space: nowrap; }
143
+ .promo-code__applied {
144
+ display: flex;
145
+ align-items: center;
146
+ justify-content: space-between;
147
+ gap: 8px;
148
+ padding: 8px 8px 8px 12px;
149
+ border: 1px dashed var(--primary-color);
150
+ border-radius: 10px;
151
+ background: rgba(74, 141, 22, 0.06);
152
+ color: var(--primary-color);
153
+ i { margin-right: 4px; }
154
+ }
155
+ .promo-code__remove {
156
+ width: 36px;
157
+ height: 36px;
158
+ border: 0;
159
+ border-radius: 8px;
160
+ background: transparent;
161
+ color: #57636c;
162
+ font-size: 18px;
163
+ &:hover { background: #eef0f2; }
164
+ &:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 1px; }
165
+ }
166
+ </style>