@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.
- 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 +67 -31
- package/composables/useCategoryImages.ts +45 -0
- package/composables/useCheckout.ts +302 -97
- 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 -83
- package/composables/useRecent.ts +30 -0
- package/composables/useSanitize.ts +76 -0
- package/composables/useSiteConfig.ts +51 -47
- 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/image.ts +0 -1
- 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
|
@@ -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>
|
package/pages/privacy-policy.vue
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
10
|
-
const
|
|
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>
|
package/pages/products.vue
CHANGED
|
@@ -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('
|
|
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>
|
package/pages/wishlist.vue
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div>
|
|
3
|
-
<CommonPageBanner :pageTitle="t('
|
|
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>
|
package/utils/image.ts
CHANGED
|
@@ -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>
|