@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
@@ -0,0 +1,113 @@
1
+ <template>
2
+ <div>
3
+ <CommonPageBanner :pageTitle="t('checkout.successPageTitle')" />
4
+ <div class="ptb-60">
5
+ <div class="container text-center" style="max-width: 560px">
6
+ <ClientOnly>
7
+ <template v-if="checking">
8
+ <h3 class="mb-3">{{ t('checkout.checkingPayment') }}</h3>
9
+ <p class="text-muted">{{ t('checkout.waitFewSeconds') }}</p>
10
+ </template>
11
+ <template v-else>
12
+ <h3 class="mb-3">{{ title }}</h3>
13
+ <p v-if="orderNumber" class="mb-1">
14
+ {{ t('checkout.orderNumber') }}: <b>{{ orderNumber }}</b>
15
+ </p>
16
+ <ul v-if="summary && (summary.discount || summary.total)" class="order-result list-unstyled mx-auto mb-3">
17
+ <li v-if="summary.discount">
18
+ <span>{{ summary.promoCode ? t('checkout.promoApplied', { code: summary.promoCode }) : t('checkout.orderDiscount') }}</span>
19
+ <b class="order-result__discount">−{{ formatUah(summary.discount) }}</b>
20
+ </li>
21
+ <li v-if="summary.total">
22
+ <span>{{ t('checkout.orderTotal') }}</span>
23
+ <b>{{ formatUah(summary.total) }}</b>
24
+ </li>
25
+ </ul>
26
+ <p class="text-muted mb-4">{{ text }}</p>
27
+ <NuxtLink :to="status === 'failed' ? '/products' : '/'" class="btn btn-warning text-white">
28
+ {{ status === 'failed' ? t('cart.toCatalog') : t('checkout.toHome') }}
29
+ </NuxtLink>
30
+ </template>
31
+ <template #fallback>
32
+ <p class="text-muted">{{ t('cart.loading') }}</p>
33
+ </template>
34
+ </ClientOnly>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ </template>
39
+
40
+ <script setup lang="ts">
41
+ import { ref, computed, onMounted } from 'vue';
42
+ const { t } = useI18n();
43
+ const route = useRoute();
44
+ const { fetchOrderStatus, readOrderResult } = useCheckout();
45
+ const { clearCart } = useCart();
46
+
47
+ const orderNumber = ref(String(route.query.n || ''));
48
+ const online = route.query.paid !== undefined;
49
+ const checking = ref(online);
50
+ const status = ref<'cod' | 'paid' | 'pending' | 'failed'>(online ? 'pending' : 'cod');
51
+
52
+ const title = computed(() => {
53
+ if (status.value === 'paid') return t('checkout.paidTitle');
54
+ if (status.value === 'failed') return t('checkout.failedTitle');
55
+ return t('checkout.successTitle');
56
+ });
57
+ const text = computed(() => {
58
+ if (status.value === 'paid') return t('checkout.paidText');
59
+ if (status.value === 'failed') return t('checkout.failedText');
60
+ if (status.value === 'pending') return t('checkout.pendingText');
61
+ return t('checkout.successText');
62
+ });
63
+
64
+ // Webhook може оновити статус із затримкою — опитуємо кілька разів
65
+ const pollStatus = async (orderId: string) => {
66
+ for (let i = 0; i < 5; i++) {
67
+ try {
68
+ const s = await fetchOrderStatus(orderId);
69
+ if (s?.orderNumber) orderNumber.value = s.orderNumber;
70
+ if (s?.paymentStatus === 'paid') { status.value = 'paid'; return; }
71
+ if (s?.paymentStatus === 'failed') { status.value = 'failed'; return; }
72
+ status.value = 'pending';
73
+ } catch {
74
+ status.value = 'pending';
75
+ }
76
+ if (i < 4) await new Promise((r) => setTimeout(r, 2000));
77
+ }
78
+ };
79
+
80
+ // Знижка/сума зі збереженої відповіді створення замовлення (лише якщо це те саме замовлення)
81
+ const summary = ref<ReturnType<typeof readOrderResult>>(null);
82
+
83
+ onMounted(async () => {
84
+ summary.value = readOrderResult(String(route.query.order || ''));
85
+ if (!online) return;
86
+ // Повернення з платіжки: кошик уже порожній, на всяк випадок чистимо
87
+ clearCart();
88
+ const oid = String(route.query.order || localStorage.getItem('lastOrderId') || '');
89
+ if (oid) await pollStatus(oid);
90
+ checking.value = false;
91
+ });
92
+
93
+ useHead({ title: t('checkout.successPageTitle') });
94
+ </script>
95
+
96
+ <style scoped>
97
+ .order-result {
98
+ max-width: 360px;
99
+ padding: 12px 16px;
100
+ border: 1px solid #e7e7e7;
101
+ border-radius: 12px;
102
+ text-align: left;
103
+ }
104
+ .order-result li {
105
+ display: flex;
106
+ justify-content: space-between;
107
+ gap: 12px;
108
+ padding: 4px 0;
109
+ }
110
+ .order-result__discount {
111
+ color: var(--primary-color);
112
+ }
113
+ </style>
@@ -1,11 +1,11 @@
1
+ <script setup lang="ts">
2
+ const { t } = useI18n();
3
+ useHead({ title: () => t('pages.privacy.title') });
4
+ </script>
5
+
1
6
  <template>
2
7
  <div>
3
- <CommonPageBanner :pageTitle="t('common.privacyPolicy')" />
8
+ <CommonPageBanner :pageTitle="t('pages.privacy.title')" />
4
9
  <PrivacyPolicy />
5
10
  </div>
6
11
  </template>
7
-
8
- <script setup lang="ts">
9
- definePageMeta({ layout: 'inner' });
10
- const { t } = useI18n();
11
- </script>
@@ -0,0 +1,82 @@
1
+ <template>
2
+ <div>
3
+ <CommonPageBanner v-if="product" :pageTitle="product.title" :crumbs="crumbs" />
4
+
5
+ <ProductDetails v-if="product" :key="product.id" :product="product" />
6
+ <ProductDetailsRelatedProducts v-if="product" :key="`related-${product.id}`" :product="product" />
7
+ <ProductDetailsRecentlyViewed v-if="product" :key="`recent-${product.id}`" :currentId="product.id" />
8
+ </div>
9
+ </template>
10
+
11
+ <script setup lang="ts">
12
+ import { computed, watch } from 'vue';
13
+ import { getProductImage, htmlToText } from '../../composables/useProducts';
14
+
15
+ const { t } = useI18n();
16
+ const route = useRoute();
17
+ const { fetchProductById } = useProducts();
18
+ const { track } = useRecent();
19
+
20
+ const slug = computed(() => String(route.params.slug || ''));
21
+ const productId = computed(() => parseIdFromSlug(slug.value));
22
+
23
+ // SSR + клієнт: товар за id з кінця slug; null — не знайдено
24
+ const { data: product, error } = await useAsyncData(
25
+ () => `product-${productId.value}`,
26
+ async () => (productId.value ? await fetchProductById(productId.value) : null),
27
+ { watch: [productId] },
28
+ );
29
+
30
+ // Збій API — це не «немає товару»: віддаємо 502, а не 404 (щоб пошуковики не викинули сторінку)
31
+ if (error.value) {
32
+ throw createError({ statusCode: 502, statusMessage: 'Не вдалося завантажити товар', fatal: false });
33
+ }
34
+ if (!product.value) {
35
+ if (import.meta.server) setResponseStatus(useRequestEvent()!, 404);
36
+ throw createError({ statusCode: 404, statusMessage: 'Товар не знайдено', fatal: false });
37
+ }
38
+
39
+ // Назву змінили — переспрямовуємо на канонічну адресу (301)
40
+ const canonical = productPath(product.value);
41
+ if (decodeURIComponent(route.path) !== canonical) {
42
+ await navigateTo({ path: canonical, query: route.query }, { redirectCode: 301, replace: true });
43
+ }
44
+
45
+ // Хлібні крихти: Головна → Категорія → Товар
46
+ const crumbs = computed(() => {
47
+ const cat = product.value?.categories?.[0];
48
+ return cat ? [{ label: cat.name, to: categoryPath(cat) }] : [{ label: t('nav.products'), to: '/products' }];
49
+ });
50
+
51
+ // Запам'ятовуємо перегляд (лише клієнт)
52
+ watch(
53
+ () => product.value?.id,
54
+ (id) => { if (id && import.meta.client) track(id); },
55
+ { immediate: true },
56
+ );
57
+
58
+ // Опис без HTML, до 160 символів (з обрізкою по слову)
59
+ const description = computed(() => {
60
+ const text = htmlToText(product.value?.description);
61
+ if (text.length <= 160) return text;
62
+ return text.slice(0, 157).replace(/\s+\S*$/, '') + '...';
63
+ });
64
+ const ogImage = computed(() => getProductImage(product.value));
65
+ const { canonicalUrl } = useStoreOrigin();
66
+ const canonicalHref = computed(() => (product.value ? canonicalUrl(productPath(product.value)) : ''));
67
+
68
+ useHead({
69
+ title: () => product.value?.title || t('product.notFound'),
70
+ link: () => (canonicalHref.value ? [{ key: 'canonical', rel: 'canonical', href: canonicalHref.value }] : []),
71
+ });
72
+ useSeoMeta({
73
+ description: () => description.value || undefined,
74
+ ogTitle: () => product.value?.title,
75
+ ogDescription: () => description.value || undefined,
76
+ ogImage: () => ogImage.value || undefined,
77
+ ogUrl: () => canonicalHref.value || undefined,
78
+ ogType: 'website' as any, // og:product не входить у типи Nuxt, виставляємо нижче
79
+ });
80
+ useHead({ meta: [{ key: 'og:type', property: 'og:type', content: 'product' }] });
81
+ useProductJsonLd(product);
82
+ </script>
@@ -1,11 +1,19 @@
1
1
  <template>
2
- <div>
3
- <CommonPageBanner :pageTitle="t('product.productDetails')" />
4
- <ProductDetails />
5
- </div>
2
+ <div></div>
6
3
  </template>
7
4
 
8
5
  <script setup lang="ts">
9
- definePageMeta({ layout: 'inner' });
10
- const { t } = useI18n();
6
+ // Стара адреса /product-details?id=<uuid>: 301 на /product/<slug>-<id> або 404
7
+ const route = useRoute();
8
+ const { fetchProductById } = useProducts();
9
+
10
+ const id = String(route.query.id || '').trim();
11
+ const product = id ? await fetchProductById(id).catch(() => null) : null;
12
+
13
+ if (product) {
14
+ await navigateTo(productPath(product), { redirectCode: 301, replace: true });
15
+ } else {
16
+ if (import.meta.server) setResponseStatus(useRequestEvent()!, 404);
17
+ throw createError({ statusCode: 404, statusMessage: 'Товар не знайдено', fatal: false });
18
+ }
11
19
  </script>
@@ -6,6 +6,17 @@
6
6
  </template>
7
7
 
8
8
  <script setup lang="ts">
9
- definePageMeta({ layout: 'inner' });
10
9
  const { t } = useI18n();
10
+ const route = useRoute();
11
+ const { getCategoryById } = useCategories();
12
+
13
+ // Стара адреса /products?categoryId=<id> без інших фільтрів → 301 на сторінку категорії.
14
+ // Із фільтрами (search, ціна, сортування) лишається каталогом-фільтром.
15
+ const q = route.query;
16
+ const onlyCategory = q.categoryId && !q.search && !q.minPrice && !q.maxPrice && !q.sortBy;
17
+ const category = onlyCategory ? getCategoryById(String(q.categoryId)) : undefined;
18
+ if (category) {
19
+ const query = q.page ? { page: q.page } : undefined;
20
+ await navigateTo({ path: categoryPath(category), query }, { redirectCode: 301, replace: true });
21
+ }
11
22
  </script>
@@ -0,0 +1,12 @@
1
+ <script setup lang="ts">
2
+ const { t } = useI18n();
3
+ const { pageHtml } = useStaticPages();
4
+ useHead({ title: () => t('pages.returns.title') });
5
+ </script>
6
+
7
+ <template>
8
+ <div>
9
+ <CommonPageBanner :pageTitle="t('pages.returns.title')" />
10
+ <AboutUsStaticContent :html="pageHtml('returns')" />
11
+ </div>
12
+ </template>
@@ -1,11 +1,11 @@
1
+ <script setup lang="ts">
2
+ const { t } = useI18n();
3
+ useHead({ title: () => t('pages.terms.title') });
4
+ </script>
5
+
1
6
  <template>
2
7
  <div>
3
- <CommonPageBanner :pageTitle="t('common.termsConditions')" />
8
+ <CommonPageBanner :pageTitle="t('pages.terms.title')" />
4
9
  <TermsConditions />
5
10
  </div>
6
11
  </template>
7
-
8
- <script setup lang="ts">
9
- definePageMeta({ layout: 'inner' });
10
- const { t } = useI18n();
11
- </script>
@@ -1,11 +1,11 @@
1
1
  <template>
2
2
  <div>
3
- <CommonPageBanner :pageTitle="t('product.wishlist')" />
3
+ <CommonPageBanner :pageTitle="t('wishlist.title')" />
4
4
  <Wishlist />
5
5
  </div>
6
6
  </template>
7
7
 
8
8
  <script setup lang="ts">
9
- definePageMeta({ layout: 'inner' });
10
9
  const { t } = useI18n();
10
+ useHead({ title: () => t('wishlist.title'), meta: [{ name: 'robots', content: 'noindex' }] });
11
11
  </script>
@@ -0,0 +1,70 @@
1
+ /**
2
+ * SEO-адреси: людські URL для товарів і категорій.
3
+ * Формат: /product/<slug>-<uuid>, /category/<slug>-<uuid>.
4
+ * Id (uuid) завжди в кінці — за ним сторінка знаходить сутність, slug лише для краси.
5
+ */
6
+
7
+ // Транслітерація за офіційною таблицею (КМУ №55, 2010) + російські літери
8
+ const MAP: Record<string, string> = {
9
+ а: 'a', б: 'b', в: 'v', г: 'h', ґ: 'g', д: 'd', е: 'e', ж: 'zh', з: 'z', и: 'y', і: 'i',
10
+ к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f',
11
+ х: 'kh', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'shch',
12
+ // російські
13
+ ы: 'y', э: 'e',
14
+ };
15
+ // Літери, що пишуться інакше на початку слова: [початок, решта]
16
+ const WORD_START: Record<string, [string, string]> = {
17
+ є: ['ye', 'ie'], ї: ['yi', 'i'], й: ['y', 'i'], ю: ['yu', 'iu'], я: ['ya', 'ia'],
18
+ ё: ['yo', 'e'],
19
+ };
20
+ // Знак м'якшення, апостроф та тверді знаки відкидаються
21
+ const DROP = new Set(['ь', 'ъ', "'", '’', 'ʼ', '`', '´']);
22
+
23
+ const MAX_SLUG = 80;
24
+ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
25
+
26
+ /** Перетворює текст на slug: латиниця нижнім регістром, цифри та одиночні дефіси. */
27
+ export const slugify = (text: string | null | undefined): string => {
28
+ const src = String(text ?? '').toLowerCase();
29
+ let out = '';
30
+ let prevIsLetter = false;
31
+ for (const ch of src) {
32
+ if (DROP.has(ch)) continue; // апостроф не розриває слово
33
+ const start = WORD_START[ch];
34
+ if (start) {
35
+ out += prevIsLetter ? start[1] : start[0];
36
+ prevIsLetter = true;
37
+ } else if (MAP[ch] !== undefined) {
38
+ out += MAP[ch];
39
+ prevIsLetter = true;
40
+ } else if (/[a-z0-9]/.test(ch)) {
41
+ out += ch;
42
+ prevIsLetter = /[a-z]/.test(ch);
43
+ } else {
44
+ out += '-';
45
+ prevIsLetter = false;
46
+ }
47
+ }
48
+ let slug = out.replace(/-+/g, '-').replace(/^-|-$/g, '');
49
+ if (slug.length > MAX_SLUG) slug = slug.slice(0, MAX_SLUG).replace(/-+$/, '');
50
+ return slug;
51
+ };
52
+
53
+ const build = (base: string, name: string | undefined, id: string) => {
54
+ const slug = slugify(name);
55
+ return slug ? `${base}/${slug}-${id}` : `${base}/${id}`;
56
+ };
57
+
58
+ /** Канонічна адреса товару. */
59
+ export const productPath = (product: { id: string; title?: string; name?: string }): string =>
60
+ build('/product', product.title || product.name, product.id);
61
+
62
+ /** Канонічна адреса категорії. */
63
+ export const categoryPath = (category: { id: string; name?: string }): string =>
64
+ build('/category', category.name, category.id);
65
+
66
+ /** Дістає uuid з кінця slug-а; null, якщо його немає. */
67
+ export const parseIdFromSlug = (slug: string | null | undefined): string | null => {
68
+ const m = String(slug ?? '').match(UUID_RE);
69
+ return m ? m[0].toLowerCase() : null;
70
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Чисті хелпери основного домену магазину (працюють і на сервері, і в застосунку).
3
+ */
4
+ export interface StoreDomainInfo {
5
+ subdomain?: string | null;
6
+ customDomain?: string | null;
7
+ customDomainVerified?: boolean | null;
8
+ }
9
+
10
+ /** Локальна розробка: *.localhost / localhost / IP — нічого не редіректимо. */
11
+ export const isDevHostname = (hostname: string): boolean =>
12
+ hostname === 'localhost' || hostname.endsWith('.localhost') || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname);
13
+
14
+ /**
15
+ * Основний origin магазину: верифікований власний домен, інакше <subdomain>.<baseDomain>.
16
+ * У dev (або без даних про домен) повертає fallbackOrigin — поточний origin запиту.
17
+ */
18
+ export const computeStoreOrigin = (
19
+ info: StoreDomainInfo | null | undefined,
20
+ baseDomain: string,
21
+ requestHostname: string,
22
+ fallbackOrigin: string,
23
+ ): string => {
24
+ if (!requestHostname || isDevHostname(requestHostname)) return fallbackOrigin;
25
+ const custom = (info?.customDomain || '').trim().toLowerCase();
26
+ if (info?.customDomainVerified && custom) return `https://${custom}`;
27
+ if (info?.subdomain && baseDomain) return `https://${info.subdomain}.${baseDomain}`;
28
+ return fallbackOrigin;
29
+ };
30
+
31
+ /** Канонічна адреса: origin + path без query; ?page=N лише для N>1. */
32
+ export const buildCanonicalUrl = (origin: string, path: string, page?: number | string | null): string => {
33
+ const clean = (path || '/').split('?')[0].split('#')[0] || '/';
34
+ const n = Number(page);
35
+ return origin.replace(/\/$/, '') + clean + (Number.isFinite(n) && n > 1 ? `?page=${Math.floor(n)}` : '');
36
+ };
@@ -1,25 +0,0 @@
1
- <template>
2
- <div class="about-us-area ptb-60">
3
- <div class="container">
4
- <div class="about-content">
5
- <h2>About Us Tuan</h2>
6
- <p>
7
- With more than 18 years of expertise, we have created thousands of
8
- strategic discovery processes that allow us to connect and understand
9
- by removing layers.
10
- </p>
11
- <div class="about-video">
12
- <video loop="" muted="" autoplay="" poster="#">
13
- <source src="~/assets/images/about.mp4" type="video/mp4" />
14
- </video>
15
- </div>
16
- </div>
17
- </div>
18
- </div>
19
- </template>
20
-
21
- <script>
22
- export default {
23
- name: "AboutUsTuan",
24
- };
25
- </script>
@@ -1,42 +0,0 @@
1
- <template>
2
- <div class="pb-35">
3
- <div class="container">
4
- <v-row class="justify-content-center">
5
- <v-col cols="12" lg="3" sm="6">
6
- <div class="single-counter-item">
7
- <h2>50M</h2>
8
- <h3>Goods for Purchase</h3>
9
- <p>Tacite sociosqu litora torquent per conubia class aptent.</p>
10
- </div>
11
- </v-col>
12
- <v-col cols="12" lg="3" sm="6">
13
- <div class="single-counter-item">
14
- <h2>5.5K</h2>
15
- <h3>Contented Client</h3>
16
- <p>Tacite sociosqu litora torquent per conubia class aptent.</p>
17
- </div>
18
- </v-col>
19
- <v-col cols="12" lg="3" sm="6">
20
- <div class="single-counter-item">
21
- <h2>14K</h2>
22
- <h3>Associated Brand</h3>
23
- <p>Tacite sociosqu litora torquent per conubia class aptent.</p>
24
- </div>
25
- </v-col>
26
- <v-col cols="12" lg="3" sm="6">
27
- <div class="single-counter-item">
28
- <h2>35M</h2>
29
- <h3>Expanding Purchasers</h3>
30
- <p>Tacite sociosqu litora torquent per conubia class aptent.</p>
31
- </div>
32
- </v-col>
33
- </v-row>
34
- </div>
35
- </div>
36
- </template>
37
-
38
- <script>
39
- export default {
40
- name: "Statistics",
41
- };
42
- </script>
@@ -1,57 +0,0 @@
1
- <template>
2
- <div class="subscribe-area pb-60">
3
- <div class="container">
4
- <div
5
- class="bg-img subscribe-wrap"
6
- :style="{
7
- 'background-image': `url(${bgImage})`,
8
- }"
9
- >
10
- <v-row class="align-items-center">
11
- <v-col cols="12" lg="6" md="6">
12
- <div class="subscribe-content">
13
- <h2>Subscribe To The Newsletter</h2>
14
- <p>Get 10% off new collection special Investigations</p>
15
- <form class="subscribe-form position-relative">
16
- <input
17
- type="text"
18
- class="form-control"
19
- placeholder="Enter your email"
20
- />
21
- <button
22
- class="btn btn-warning text-white position-absolute top-50 end-0 translate-middle-y"
23
- >
24
- Submit Now
25
- </button>
26
- </form>
27
- </div>
28
- </v-col>
29
- <v-col cols="12" lg="6" md="6">
30
- <div class="subscribe-img">
31
- <img
32
- src="assets/images/subscribe-img.png"
33
- class="rounded-1"
34
- alt="subscribe-img"
35
- />
36
- </div>
37
- </v-col>
38
- </v-row>
39
- </div>
40
- </div>
41
- </div>
42
- </template>
43
-
44
- <script lang="ts">
45
- import { defineComponent } from "vue";
46
-
47
- import bgImage from "~/assets/images/subscribe-bg.png";
48
-
49
- export default defineComponent({
50
- name: "SubscribeToTheNewsletter",
51
- setup() {
52
- return {
53
- bgImage,
54
- };
55
- },
56
- });
57
- </script>
@@ -1,68 +0,0 @@
1
- <template>
2
- <section class="dashboard-area ptb-60">
3
- <div class="container">
4
- <div class="row">
5
- <div class="col-lg-4">
6
- <CommonDashboardNavigation />
7
- </div>
8
-
9
- <div class="col-lg-8">
10
- <div class="address-list">
11
- <h3 class="mb-3">{{ t('account.address') }}</h3>
12
-
13
- <div class="row">
14
- <div class="col-lg-6">
15
- <div class="added-address">
16
- <NuxtLink to="/address" class="text-decoration-none">
17
- <i class="ri-add-line"></i>
18
- <span>{{ t('account.addAddress') }}</span>
19
- </NuxtLink>
20
- </div>
21
- </div>
22
- <div class="col-lg-6">
23
- <div class="billing-address-bar mb-0">
24
- <h3>{{ t('account.billingAddress') }}</h3>
25
- <ul>
26
- <li class="p-0">Doreen McCool</li>
27
- <li>
28
- <span>{{ t('account.address') }}:</span>
29
- 2491 Reel Avenue Albuquerque
30
- </li>
31
- </ul>
32
-
33
- <ul>
34
- <li>
35
- <span>{{ t('account.phone') }}:</span>
36
- <a href="tel:+1-(514)-321-4566">+1 (514) 321-4566</a>
37
- </li>
38
- <li>
39
- <span>{{ t('account.email') }}:</span>
40
- <a href="mailto:hello@tuan.com">hello@tuan.com</a>
41
- </li>
42
- </ul>
43
-
44
- <div class="edit-address">
45
- <NuxtLink
46
- to="/edit-address"
47
- class="btn edit btn-warning text-white"
48
- >
49
- {{ t('common.edit') }}
50
- </NuxtLink>
51
-
52
- <button class="btn btn-warning text-white ms-3">
53
- {{ t('product.remove') }}
54
- </button>
55
- </div>
56
- </div>
57
- </div>
58
- </div>
59
- </div>
60
- </div>
61
- </div>
62
- </div>
63
- </section>
64
- </template>
65
-
66
- <script setup lang="ts">
67
- const { t } = useI18n();
68
- </script>