@crm-market/template-shared 1.0.0

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 (160) hide show
  1. package/assets/css/custom.css +70 -0
  2. package/assets/css/remixicon.css +2782 -0
  3. package/assets/css/satoshi-font.css +31 -0
  4. package/assets/fonts/flaticon.css +463 -0
  5. package/assets/fonts/flaticon.eot +0 -0
  6. package/assets/fonts/flaticon.html +2153 -0
  7. package/assets/fonts/flaticon.svg +441 -0
  8. package/assets/fonts/flaticon.ttf +0 -0
  9. package/assets/fonts/flaticon.woff +0 -0
  10. package/assets/fonts/flaticon.woff2 +0 -0
  11. package/assets/fonts/remixicon.eot +0 -0
  12. package/assets/fonts/remixicon.svg +8230 -0
  13. package/assets/fonts/remixicon.ttf +0 -0
  14. package/assets/fonts/remixicon.woff +0 -0
  15. package/assets/fonts/remixicon.woff2 +0 -0
  16. package/assets/scss/_variables.scss +31 -0
  17. package/assets/scss/components/_about.scss +58 -0
  18. package/assets/scss/components/_authentication.scss +124 -0
  19. package/assets/scss/components/_backtoptop.scss +27 -0
  20. package/assets/scss/components/_banner.scss +396 -0
  21. package/assets/scss/components/_best-deals.scss +74 -0
  22. package/assets/scss/components/_blank.scss +40 -0
  23. package/assets/scss/components/_blog.scss +193 -0
  24. package/assets/scss/components/_cart.scss +108 -0
  25. package/assets/scss/components/_categories.scss +82 -0
  26. package/assets/scss/components/_checkout.scss +110 -0
  27. package/assets/scss/components/_dashboard.scss +388 -0
  28. package/assets/scss/components/_faq.scss +22 -0
  29. package/assets/scss/components/_filter-rang.scss +109 -0
  30. package/assets/scss/components/_footer.scss +270 -0
  31. package/assets/scss/components/_global.scss +550 -0
  32. package/assets/scss/components/_header.scss +587 -0
  33. package/assets/scss/components/_hurry.scss +52 -0
  34. package/assets/scss/components/_navbar.scss +1008 -0
  35. package/assets/scss/components/_offers.scss +689 -0
  36. package/assets/scss/components/_pagination.scss +71 -0
  37. package/assets/scss/components/_popup.scss +172 -0
  38. package/assets/scss/components/_preloader.scss +108 -0
  39. package/assets/scss/components/_products.scss +1147 -0
  40. package/assets/scss/components/_rtl.scss +806 -0
  41. package/assets/scss/components/_services.scss +16 -0
  42. package/assets/scss/components/_sidebar.scss +259 -0
  43. package/assets/scss/style.css +6676 -0
  44. package/assets/scss/style.css.map +1 -0
  45. package/assets/scss/style.scss +40 -0
  46. package/assets/webfonts/Satoshi-Bold.eot +0 -0
  47. package/assets/webfonts/Satoshi-Bold.woff +0 -0
  48. package/assets/webfonts/Satoshi-Bold.woff2 +0 -0
  49. package/assets/webfonts/Satoshi-Medium.eot +0 -0
  50. package/assets/webfonts/Satoshi-Medium.woff +0 -0
  51. package/assets/webfonts/Satoshi-Medium.woff2 +0 -0
  52. package/assets/webfonts/Satoshi-Regular.eot +0 -0
  53. package/assets/webfonts/Satoshi-Regular.woff +0 -0
  54. package/assets/webfonts/Satoshi-Regular.woff2 +0 -0
  55. package/components/AboutUs/AboutUsTuan.vue +25 -0
  56. package/components/AboutUs/Statistics.vue +42 -0
  57. package/components/AboutUs/SubscribeToTheNewsletter.vue +57 -0
  58. package/components/AddAddress/index.vue +70 -0
  59. package/components/BestSellers/Products.vue +1562 -0
  60. package/components/BestSellers/RecentlyViewed.vue +304 -0
  61. package/components/Cart/ProductQuantity.vue +29 -0
  62. package/components/Cart/index.vue +167 -0
  63. package/components/Categories/index.vue +305 -0
  64. package/components/ChangePassword/index.vue +71 -0
  65. package/components/Checkout/index.vue +192 -0
  66. package/components/Common/DashboardNavigation.vue +37 -0
  67. package/components/Common/PageBanner.vue +28 -0
  68. package/components/Common/ProductCard.vue +152 -0
  69. package/components/Common/Services.vue +58 -0
  70. package/components/Contact/ContactForm.vue +91 -0
  71. package/components/Contact/ContactInfo.vue +74 -0
  72. package/components/Dashboard/RecentOrder.vue +173 -0
  73. package/components/Dashboard/index.vue +79 -0
  74. package/components/EditAddress/index.vue +119 -0
  75. package/components/EditProfile/index.vue +97 -0
  76. package/components/FAQ/index.vue +121 -0
  77. package/components/FeaturedProduct/FeaturedProducts.vue +304 -0
  78. package/components/FeaturedProduct/Products.vue +1562 -0
  79. package/components/ForgotPassword/index.vue +51 -0
  80. package/components/Layout/BackToUp.vue +38 -0
  81. package/components/Layout/Copyright.vue +25 -0
  82. package/components/Layout/Footer.vue +183 -0
  83. package/components/Layout/FooterTwo.vue +165 -0
  84. package/components/Layout/LocationOption.vue +178 -0
  85. package/components/Layout/MiddleHeader.vue +229 -0
  86. package/components/Layout/MiddleHeaderThree.vue +204 -0
  87. package/components/Layout/MiddleHeaderTwo.vue +240 -0
  88. package/components/Layout/Navbar.vue +185 -0
  89. package/components/Layout/NavbarStyleFour.vue +334 -0
  90. package/components/Layout/NavbarStyleThree.vue +188 -0
  91. package/components/Layout/NavbarStyleTwo.vue +108 -0
  92. package/components/Layout/Preloader.vue +18 -0
  93. package/components/Layout/RTLSwitchBtn.vue +40 -0
  94. package/components/Layout/ResponsiveNavbar.vue +431 -0
  95. package/components/Layout/TopHeader.vue +130 -0
  96. package/components/Login/index.vue +94 -0
  97. package/components/MyAccount/index.vue +154 -0
  98. package/components/NewArrivals/Products.vue +1969 -0
  99. package/components/NewArrivals/RecentlyViewed.vue +304 -0
  100. package/components/OrderDetails/index.vue +77 -0
  101. package/components/OrderHistory/index.vue +197 -0
  102. package/components/PrivacyPolicy/index.vue +112 -0
  103. package/components/ProductDetails/ProductDetailsTab.vue +343 -0
  104. package/components/ProductDetails/ProductQuantity.vue +29 -0
  105. package/components/ProductDetails/RecentlyViewed.vue +304 -0
  106. package/components/ProductDetails/index.vue +268 -0
  107. package/components/Products/RecentlyViewed.vue +304 -0
  108. package/components/Products/index.vue +292 -0
  109. package/components/Register/index.vue +88 -0
  110. package/components/TermsConditions/index.vue +112 -0
  111. package/components/TrendingProducts/FeaturedProducts.vue +304 -0
  112. package/components/TrendingProducts/Products.vue +1564 -0
  113. package/components/Wishlist/ProductQuantity.vue +29 -0
  114. package/components/Wishlist/index.vue +128 -0
  115. package/composables/useCart.ts +149 -0
  116. package/composables/useCategories.ts +87 -0
  117. package/composables/useCheckout.ts +131 -0
  118. package/composables/useProducts.ts +162 -0
  119. package/composables/useSiteConfig.ts +236 -0
  120. package/composables/useTemplateSections.ts +74 -0
  121. package/e2e/cart.spec.ts +71 -0
  122. package/e2e/fixtures/mock-api.ts +166 -0
  123. package/e2e/homepage.spec.ts +65 -0
  124. package/e2e/layout.spec.ts +73 -0
  125. package/e2e/navigation.spec.ts +61 -0
  126. package/e2e/pages/cart.page.ts +44 -0
  127. package/e2e/pages/homepage.page.ts +46 -0
  128. package/e2e/playwright.config.ts +32 -0
  129. package/e2e/products.spec.ts +33 -0
  130. package/layouts/default.vue +94 -0
  131. package/layouts/inner.vue +70 -0
  132. package/nuxt.config.ts +86 -0
  133. package/package.json +38 -0
  134. package/pages/about-us.vue +12 -0
  135. package/pages/address.vue +10 -0
  136. package/pages/cart.vue +10 -0
  137. package/pages/categories.vue +10 -0
  138. package/pages/change-password.vue +10 -0
  139. package/pages/checkout.vue +10 -0
  140. package/pages/contact.vue +11 -0
  141. package/pages/dashboard.vue +10 -0
  142. package/pages/edit-address.vue +10 -0
  143. package/pages/edit-profile.vue +10 -0
  144. package/pages/faq.vue +10 -0
  145. package/pages/forgot-password.vue +10 -0
  146. package/pages/login.vue +10 -0
  147. package/pages/my-account.vue +10 -0
  148. package/pages/order-details.vue +10 -0
  149. package/pages/order-history.vue +10 -0
  150. package/pages/privacy-policy.vue +10 -0
  151. package/pages/product-details.vue +10 -0
  152. package/pages/products.vue +10 -0
  153. package/pages/register.vue +10 -0
  154. package/pages/terms-conditions.vue +10 -0
  155. package/pages/wishlist.vue +10 -0
  156. package/plugins/site-init.client.ts +24 -0
  157. package/plugins/vuetify.ts +18 -0
  158. package/types/index.ts +121 -0
  159. package/utils/image.ts +13 -0
  160. package/utils/store.ts +21 -0
@@ -0,0 +1,236 @@
1
+ import { ref, computed } from 'vue';
2
+
3
+ interface ColorScheme {
4
+ primary?: string;
5
+ secondary?: string;
6
+ accent?: string;
7
+ bodyColor?: string;
8
+ }
9
+
10
+ interface Fonts {
11
+ headingFont?: string;
12
+ bodyFont?: string;
13
+ headingWeight?: string;
14
+ bodyWeight?: string;
15
+ }
16
+
17
+ interface ContactInfo {
18
+ phone?: string;
19
+ email?: string;
20
+ address?: string;
21
+ socialMedia?: {
22
+ facebook?: string;
23
+ instagram?: string;
24
+ twitter?: string;
25
+ };
26
+ }
27
+
28
+ interface SiteConfig {
29
+ id: string;
30
+ organizationId: string;
31
+ publicApiToken: string; // Token for public store API access
32
+ siteName?: string;
33
+ siteDescription?: string;
34
+ logoUrl?: string;
35
+ faviconUrl?: string;
36
+ heroImageUrl?: string;
37
+ contactInfo?: ContactInfo;
38
+ subdomain?: string;
39
+ customDomain?: string;
40
+ isPublished: boolean;
41
+ template: {
42
+ code: string;
43
+ name: string;
44
+ type: string;
45
+ };
46
+ customization?: {
47
+ colors?: ColorScheme;
48
+ fonts?: Fonts;
49
+ customCSS?: string;
50
+ componentSettings?: any;
51
+ content?: any;
52
+ values?: any; // Dynamic settings values
53
+ };
54
+ }
55
+
56
+ const siteConfig = ref<SiteConfig | null>(null);
57
+ const loading = ref(false);
58
+ const error = ref<string | null>(null);
59
+
60
+ export const useSiteConfig = () => {
61
+ const config = useRuntimeConfig();
62
+ const route = useRoute();
63
+
64
+ // Get subdomain from current host or route
65
+ const getSubdomain = () => {
66
+ if (process.client) {
67
+ const hostname = window.location.hostname;
68
+ const parts = hostname.split('.');
69
+ // Assuming format: subdomain.domain.com
70
+ if (parts.length >= 3) {
71
+ return parts[0];
72
+ }
73
+ }
74
+ return null;
75
+ };
76
+
77
+ // Fetch site configuration from API
78
+ const fetchConfig = async (subdomain?: string) => {
79
+ loading.value = true;
80
+ error.value = null;
81
+
82
+ try {
83
+ const sub = subdomain || getSubdomain();
84
+ if (!sub) {
85
+ throw new Error('Subdomain not found');
86
+ }
87
+
88
+ const response = await $fetch<SiteConfig>(
89
+ `/site-template/public/config/subdomain/${sub}`,
90
+ {
91
+ baseURL: config.public.apiBase || 'http://localhost:3001/api',
92
+ }
93
+ );
94
+
95
+ siteConfig.value = response;
96
+ applyCustomization();
97
+ updateMetaTags();
98
+ return response;
99
+ } catch (e: any) {
100
+ error.value = e.message || 'Failed to load site configuration';
101
+ console.error('Error loading site config:', e);
102
+ throw e;
103
+ } finally {
104
+ loading.value = false;
105
+ }
106
+ };
107
+
108
+ // Apply color customization as CSS variables
109
+ const applyCustomization = () => {
110
+ if (!siteConfig.value?.customization) return;
111
+
112
+ const { colors, fonts, customCSS } = siteConfig.value.customization;
113
+
114
+ if (process.client && colors) {
115
+ const root = document.documentElement;
116
+
117
+ if (colors.primary) {
118
+ root.style.setProperty('--primary-color', colors.primary);
119
+ root.style.setProperty('--bs-primary', colors.primary);
120
+ }
121
+ if (colors.secondary) {
122
+ root.style.setProperty('--secondary-color', colors.secondary);
123
+ root.style.setProperty('--bs-secondary', colors.secondary);
124
+ }
125
+ if (colors.accent) {
126
+ root.style.setProperty('--accent-color', colors.accent);
127
+ }
128
+ if (colors.bodyColor) {
129
+ root.style.setProperty('--body-color', colors.bodyColor);
130
+ }
131
+ }
132
+
133
+ if (process.client && fonts) {
134
+ const root = document.documentElement;
135
+
136
+ if (fonts.headingFont) {
137
+ root.style.setProperty('--font-family-heading', fonts.headingFont);
138
+ }
139
+ if (fonts.bodyFont) {
140
+ root.style.setProperty('--font-family-body', fonts.bodyFont);
141
+ }
142
+ if (fonts.headingWeight) {
143
+ root.style.setProperty('--font-weight-heading', fonts.headingWeight);
144
+ }
145
+ if (fonts.bodyWeight) {
146
+ root.style.setProperty('--font-weight-body', fonts.bodyWeight);
147
+ }
148
+ }
149
+
150
+ // Inject custom CSS
151
+ if (process.client && customCSS) {
152
+ let styleEl = document.getElementById('custom-site-css');
153
+ if (!styleEl) {
154
+ styleEl = document.createElement('style');
155
+ styleEl.id = 'custom-site-css';
156
+ document.head.appendChild(styleEl);
157
+ }
158
+ styleEl.textContent = customCSS;
159
+ }
160
+ };
161
+
162
+ // Update meta tags for SEO
163
+ const updateMetaTags = () => {
164
+ if (!siteConfig.value) return;
165
+
166
+ const { siteName, siteDescription, logoUrl } = siteConfig.value;
167
+ const seo = siteConfig.value.customization?.content?.seo;
168
+
169
+ if (process.client) {
170
+ if (siteName) {
171
+ document.title = siteName;
172
+ }
173
+
174
+ // Update or create meta tags
175
+ const updateMetaTag = (name: string, content: string) => {
176
+ let meta = document.querySelector(`meta[name="${name}"]`);
177
+ if (!meta) {
178
+ meta = document.createElement('meta');
179
+ meta.setAttribute('name', name);
180
+ document.head.appendChild(meta);
181
+ }
182
+ meta.setAttribute('content', content);
183
+ };
184
+
185
+ if (siteDescription) {
186
+ updateMetaTag('description', siteDescription);
187
+ }
188
+
189
+ if (seo?.metaTitle) {
190
+ updateMetaTag('title', seo.metaTitle);
191
+ }
192
+
193
+ if (seo?.metaKeywords) {
194
+ updateMetaTag('keywords', seo.metaKeywords);
195
+ }
196
+
197
+ // Open Graph tags
198
+ if (seo?.ogTitle) {
199
+ updateMetaTag('og:title', seo.ogTitle);
200
+ }
201
+ if (seo?.ogDescription) {
202
+ updateMetaTag('og:description', seo.ogDescription);
203
+ }
204
+ if (seo?.ogImage || logoUrl) {
205
+ updateMetaTag('og:image', seo?.ogImage || logoUrl);
206
+ }
207
+ }
208
+ };
209
+
210
+ // Computed properties
211
+ const organizationId = computed(() => siteConfig.value?.organizationId);
212
+ const publicApiToken = computed(() => siteConfig.value?.publicApiToken);
213
+ const siteName = computed(() => siteConfig.value?.siteName);
214
+ const logoUrl = computed(() => siteConfig.value?.logoUrl);
215
+ const contactInfo = computed(() => siteConfig.value?.contactInfo);
216
+ const isPublished = computed(() => siteConfig.value?.isPublished);
217
+ const componentSettings = computed(() => siteConfig.value?.customization?.componentSettings || {});
218
+ const settingsValues = computed(() => siteConfig.value?.customization?.values || {});
219
+
220
+ return {
221
+ siteConfig,
222
+ loading,
223
+ error,
224
+ fetchConfig,
225
+ applyCustomization,
226
+ updateMetaTags,
227
+ organizationId,
228
+ publicApiToken,
229
+ siteName,
230
+ logoUrl,
231
+ contactInfo,
232
+ isPublished,
233
+ componentSettings,
234
+ settingsValues,
235
+ };
236
+ };
@@ -0,0 +1,74 @@
1
+ import { computed } from 'vue';
2
+ import type { TemplateSection, ContentBlock } from '../types';
3
+
4
+ /**
5
+ * Composable для управління секціями homepage.
6
+ * Кожен шаблон реєструє свої секції, а цей composable
7
+ * поєднує їх з contentBlocks з API для визначення порядку та видимості.
8
+ */
9
+
10
+ // Глобальний реєстр секцій (заповнюється кожним шаблоном)
11
+ const registeredSections = ref<TemplateSection[]>([]);
12
+
13
+ export const useTemplateSections = () => {
14
+ const { siteConfig } = useSiteConfig();
15
+
16
+ // Реєстрація секцій шаблоном
17
+ const registerSections = (sections: TemplateSection[]) => {
18
+ registeredSections.value = sections;
19
+ };
20
+
21
+ // Секції з contentBlocks API (кастомізовано організацією)
22
+ const contentBlocks = computed<ContentBlock[]>(() => {
23
+ const blocks = siteConfig.value?.customization?.contentBlocks;
24
+ if (!blocks || !Array.isArray(blocks) || blocks.length === 0) {
25
+ return [];
26
+ }
27
+ return blocks;
28
+ });
29
+
30
+ // Фінальний порядок секцій: API contentBlocks мають пріоритет, інакше — дефолтний порядок
31
+ const orderedSections = computed(() => {
32
+ const sections = registeredSections.value;
33
+ if (sections.length === 0) return [];
34
+
35
+ // Якщо є contentBlocks з API — використовуємо їх
36
+ if (contentBlocks.value.length > 0) {
37
+ return contentBlocks.value
38
+ .filter(block => block.enabled !== false)
39
+ .map(block => {
40
+ const registered = sections.find(s => s.key === block.key);
41
+ return {
42
+ key: block.key,
43
+ component: registered?.component || block.key,
44
+ label: registered?.label || block.key,
45
+ order: block.order,
46
+ enabled: block.enabled,
47
+ data: block.data,
48
+ };
49
+ })
50
+ .filter(s => s.component)
51
+ .sort((a, b) => (a.order || 0) - (b.order || 0));
52
+ }
53
+
54
+ // Інакше — дефолтний порядок з реєстру шаблону
55
+ return sections
56
+ .filter(s => s.defaultEnabled)
57
+ .sort((a, b) => a.defaultOrder - b.defaultOrder)
58
+ .map(s => ({
59
+ key: s.key,
60
+ component: s.component,
61
+ label: s.label,
62
+ order: s.defaultOrder,
63
+ enabled: true,
64
+ data: null,
65
+ }));
66
+ });
67
+
68
+ return {
69
+ registeredSections,
70
+ registerSections,
71
+ orderedSections,
72
+ contentBlocks,
73
+ };
74
+ };
@@ -0,0 +1,71 @@
1
+ import { test, expect } from '@playwright/test';
2
+ import { setupMockApi } from './fixtures/mock-api';
3
+ import { CartPage } from './pages/cart.page';
4
+
5
+ test.describe('Кошик', () => {
6
+ let cartPage: CartPage;
7
+
8
+ test.beforeEach(async ({ page }) => {
9
+ await setupMockApi(page);
10
+ cartPage = new CartPage(page);
11
+ });
12
+
13
+ test('порожній кошик при першому відвідуванні', async () => {
14
+ await cartPage.goto();
15
+ // Кошик має бути порожнім або показувати повідомлення
16
+ const isEmpty = await cartPage.isEmpty();
17
+ const itemCount = await cartPage.getItemCount();
18
+ expect(isEmpty || itemCount === 0).toBeTruthy();
19
+ });
20
+
21
+ test('відображає кошик з товарами після додавання', async ({ page }) => {
22
+ // Додаємо товар у localStorage перед переходом
23
+ await page.goto('/');
24
+ await page.evaluate(() => {
25
+ const cart = {
26
+ items: [{
27
+ productId: 'product-1',
28
+ name: 'Тестовий товар 1',
29
+ price: 1500,
30
+ discountPrice: 1200,
31
+ quantity: 2,
32
+ image: '/media/test/product-1.png',
33
+ }],
34
+ updatedAt: Date.now(),
35
+ };
36
+ localStorage.setItem('shopping_cart', JSON.stringify(cart));
37
+ });
38
+
39
+ await cartPage.goto();
40
+ await page.waitForTimeout(1000);
41
+
42
+ // Має бути хоча б один товар
43
+ const count = await cartPage.getItemCount();
44
+ expect(count).toBeGreaterThanOrEqual(1);
45
+ });
46
+
47
+ test('має кнопку оформлення замовлення', async ({ page }) => {
48
+ // Додаємо товар у localStorage
49
+ await page.goto('/');
50
+ await page.evaluate(() => {
51
+ const cart = {
52
+ items: [{
53
+ productId: 'product-1',
54
+ name: 'Тестовий товар',
55
+ price: 1500,
56
+ quantity: 1,
57
+ }],
58
+ updatedAt: Date.now(),
59
+ };
60
+ localStorage.setItem('shopping_cart', JSON.stringify(cart));
61
+ });
62
+
63
+ await cartPage.goto();
64
+ await page.waitForTimeout(1000);
65
+
66
+ const checkoutBtn = cartPage.checkoutButton;
67
+ if (await checkoutBtn.isVisible()) {
68
+ await expect(checkoutBtn).toBeEnabled();
69
+ }
70
+ });
71
+ });
@@ -0,0 +1,166 @@
1
+ import type { Page } from '@playwright/test';
2
+
3
+ /**
4
+ * Фікстура для моку API відповідей.
5
+ * Використовується для тестування без реального бекенду.
6
+ */
7
+
8
+ export const MOCK_SITE_CONFIG = {
9
+ id: 'test-config-id',
10
+ organizationId: 'test-org-id',
11
+ publicApiToken: 'pub_test_token_12345',
12
+ siteName: 'Тестовий магазин',
13
+ siteDescription: 'Інтернет-магазин для тестування',
14
+ logoUrl: '/media/test/logo.png',
15
+ faviconUrl: '/media/test/favicon.ico',
16
+ heroImageUrl: '/media/test/hero.png',
17
+ contactInfo: {
18
+ phone: '+380501234567',
19
+ email: 'test@example.com',
20
+ address: 'м. Київ, вул. Тестова, 1',
21
+ socialMedia: {
22
+ facebook: 'https://facebook.com/test',
23
+ instagram: 'https://instagram.com/test',
24
+ },
25
+ },
26
+ subdomain: 'test-shop',
27
+ isPublished: true,
28
+ template: {
29
+ code: 'electronics',
30
+ name: 'Electronics',
31
+ type: 'electronics',
32
+ },
33
+ customization: {
34
+ colors: {
35
+ primary: '#5093f7',
36
+ secondary: '#22262a',
37
+ accent: '#f7931e',
38
+ },
39
+ contentBlocks: [
40
+ { key: 'todayBestDeals', order: 1, enabled: true },
41
+ { key: 'thisMonthSales', order: 2, enabled: true },
42
+ ],
43
+ },
44
+ };
45
+
46
+ export const MOCK_PRODUCTS = {
47
+ products: [
48
+ {
49
+ id: 'product-1',
50
+ name: 'Тестовий товар 1',
51
+ price: 1500,
52
+ discountPrice: 1200,
53
+ images: ['/media/test/product-1.png'],
54
+ categoryId: 'cat-1',
55
+ organizationId: 'test-org-id',
56
+ isActive: true,
57
+ stock: 10,
58
+ rating: 4.5,
59
+ reviews: 12,
60
+ },
61
+ {
62
+ id: 'product-2',
63
+ name: 'Тестовий товар 2',
64
+ price: 2500,
65
+ images: ['/media/test/product-2.png'],
66
+ categoryId: 'cat-1',
67
+ organizationId: 'test-org-id',
68
+ isActive: true,
69
+ stock: 5,
70
+ rating: 4.0,
71
+ reviews: 8,
72
+ },
73
+ {
74
+ id: 'product-3',
75
+ name: 'Тестовий товар 3',
76
+ price: 800,
77
+ discountPrice: 600,
78
+ images: ['/media/test/product-3.png'],
79
+ categoryId: 'cat-2',
80
+ organizationId: 'test-org-id',
81
+ isActive: true,
82
+ stock: 20,
83
+ rating: 3.8,
84
+ reviews: 5,
85
+ },
86
+ ],
87
+ total: 3,
88
+ };
89
+
90
+ export const MOCK_CATEGORIES = {
91
+ categories: [
92
+ {
93
+ id: 'cat-1',
94
+ name: 'Електроніка',
95
+ slug: 'electronics',
96
+ image: '/media/test/cat-1.png',
97
+ organizationId: 'test-org-id',
98
+ isActive: true,
99
+ order: 1,
100
+ },
101
+ {
102
+ id: 'cat-2',
103
+ name: 'Аксесуари',
104
+ slug: 'accessories',
105
+ image: '/media/test/cat-2.png',
106
+ organizationId: 'test-org-id',
107
+ isActive: true,
108
+ order: 2,
109
+ },
110
+ ],
111
+ };
112
+
113
+ /**
114
+ * Налаштовує моки API для сторінки.
115
+ */
116
+ export async function setupMockApi(page: Page) {
117
+ // Мок конфігурації сайту
118
+ await page.route('**/site-template/public/config/subdomain/**', async (route) => {
119
+ await route.fulfill({
120
+ status: 200,
121
+ contentType: 'application/json',
122
+ body: JSON.stringify(MOCK_SITE_CONFIG),
123
+ });
124
+ });
125
+
126
+ // Мок товарів
127
+ await page.route('**/site-template/public/store/products*', async (route) => {
128
+ const url = new URL(route.request().url());
129
+ const productId = url.pathname.match(/products\/(.+)/)?.[1];
130
+
131
+ if (productId) {
132
+ const product = MOCK_PRODUCTS.products.find(p => p.id === productId);
133
+ await route.fulfill({
134
+ status: product ? 200 : 404,
135
+ contentType: 'application/json',
136
+ body: JSON.stringify(product ? { product } : { error: 'Not found' }),
137
+ });
138
+ } else {
139
+ await route.fulfill({
140
+ status: 200,
141
+ contentType: 'application/json',
142
+ body: JSON.stringify(MOCK_PRODUCTS),
143
+ });
144
+ }
145
+ });
146
+
147
+ // Мок категорій
148
+ await page.route('**/site-template/public/store/categories*', async (route) => {
149
+ await route.fulfill({
150
+ status: 200,
151
+ contentType: 'application/json',
152
+ body: JSON.stringify(MOCK_CATEGORIES),
153
+ });
154
+ });
155
+
156
+ // Мок замовлень
157
+ await page.route('**/order', async (route) => {
158
+ if (route.request().method() === 'POST') {
159
+ await route.fulfill({
160
+ status: 201,
161
+ contentType: 'application/json',
162
+ body: JSON.stringify({ id: 'order-test-123' }),
163
+ });
164
+ }
165
+ });
166
+ }
@@ -0,0 +1,65 @@
1
+ import { test, expect } from '@playwright/test';
2
+ import { setupMockApi } from './fixtures/mock-api';
3
+ import { HomepagePage } from './pages/homepage.page';
4
+
5
+ test.describe('Головна сторінка', () => {
6
+ let homepage: HomepagePage;
7
+
8
+ test.beforeEach(async ({ page }) => {
9
+ await setupMockApi(page);
10
+ homepage = new HomepagePage(page);
11
+ await homepage.goto();
12
+ });
13
+
14
+ test('відображає header з логотипом', async ({ page }) => {
15
+ // Header має бути видимим
16
+ await expect(page.locator('.middle-header-area, .header-area').first()).toBeVisible();
17
+ });
18
+
19
+ test('відображає банер', async ({ page }) => {
20
+ // Банер або swiper має бути видимим
21
+ await expect(page.locator('.banner-area, .swiper, .banner-wrapper').first()).toBeVisible();
22
+ });
23
+
24
+ test('відображає footer', async ({ page }) => {
25
+ // Скролимо вниз і перевіряємо footer
26
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
27
+ await expect(page.locator('.footer-area, footer').first()).toBeVisible();
28
+ });
29
+
30
+ test('має коректний title з конфігурації', async ({ page }) => {
31
+ // Чекаємо поки title оновиться з API конфігу
32
+ await page.waitForTimeout(2000);
33
+ const title = await page.title();
34
+ expect(title).toBeTruthy();
35
+ });
36
+
37
+ test('навігація до сторінки товарів', async ({ page }) => {
38
+ // Шукаємо посилання на товари
39
+ const productsLink = page.locator('a[href*="products"], a:has-text("Products"), a:has-text("Товари")').first();
40
+ if (await productsLink.isVisible()) {
41
+ await productsLink.click();
42
+ await expect(page).toHaveURL(/products/);
43
+ }
44
+ });
45
+
46
+ test('навігація до кошика', async ({ page }) => {
47
+ const cartLink = page.locator('a[href*="cart"], .cart-btn').first();
48
+ if (await cartLink.isVisible()) {
49
+ await cartLink.click();
50
+ await expect(page).toHaveURL(/cart/);
51
+ }
52
+ });
53
+ });
54
+
55
+ test.describe('SSR', () => {
56
+ test('сторінка має серверний HTML контент', async ({ page }) => {
57
+ await setupMockApi(page);
58
+ // Перевіряємо що HTML рендериться server-side (не порожній body)
59
+ const response = await page.goto('/');
60
+ const html = await response?.text();
61
+ expect(html).toContain('<!DOCTYPE html>');
62
+ // Має містити хоча б базову розмітку
63
+ expect(html).toContain('<div');
64
+ });
65
+ });
@@ -0,0 +1,73 @@
1
+ import { test, expect } from '@playwright/test';
2
+ import { setupMockApi } from './fixtures/mock-api';
3
+
4
+ test.describe('Layout — шари візуалізації', () => {
5
+ test.beforeEach(async ({ page }) => {
6
+ await setupMockApi(page);
7
+ });
8
+
9
+ test('головна сторінка: правильний порядок шарів', async ({ page }) => {
10
+ await page.goto('/');
11
+
12
+ // Перевіряємо порядок: TopHeader -> MiddleHeader -> Navbar -> Content -> Footer
13
+ const body = page.locator('body');
14
+ const html = await body.innerHTML();
15
+
16
+ // TopHeader має бути перед MiddleHeader
17
+ const topHeaderPos = html.indexOf('top-header');
18
+ const middleHeaderPos = html.indexOf('middle-header');
19
+ const navbarPos = html.indexOf('navbar-area');
20
+
21
+ if (topHeaderPos > -1 && middleHeaderPos > -1) {
22
+ expect(topHeaderPos).toBeLessThan(middleHeaderPos);
23
+ }
24
+ if (middleHeaderPos > -1 && navbarPos > -1) {
25
+ expect(middleHeaderPos).toBeLessThan(navbarPos);
26
+ }
27
+ });
28
+
29
+ test('внутрішня сторінка: має page banner', async ({ page }) => {
30
+ await page.goto('/about-us');
31
+
32
+ // Inner layout має мати page banner замість основного banner
33
+ const pageBanner = page.locator('.page-banner-area, .breadcrumb-area').first();
34
+ await expect(pageBanner).toBeVisible({ timeout: 5000 });
35
+ });
36
+
37
+ test('footer містить контактну інформацію', async ({ page }) => {
38
+ await page.goto('/');
39
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
40
+
41
+ const footer = page.locator('.footer-area, footer').first();
42
+ await expect(footer).toBeVisible();
43
+ });
44
+
45
+ test('back-to-top кнопка з\'являється при скролі', async ({ page }) => {
46
+ await page.goto('/');
47
+
48
+ // Скролимо вниз
49
+ await page.evaluate(() => window.scrollTo(0, 1000));
50
+ await page.waitForTimeout(1000);
51
+
52
+ const backToTop = page.locator('.back-to-top-btn, .scroll-top, #scrollTop').first();
53
+ if (await backToTop.count() > 0) {
54
+ await expect(backToTop).toBeVisible({ timeout: 3000 });
55
+ }
56
+ });
57
+ });
58
+
59
+ test.describe('Layout — динамічний вибір компонентів', () => {
60
+ test('CSS змінні застосовуються з конфігурації', async ({ page }) => {
61
+ await setupMockApi(page);
62
+ await page.goto('/');
63
+ await page.waitForTimeout(2000);
64
+
65
+ // Перевіряємо що CSS змінні встановлені
66
+ const primaryColor = await page.evaluate(() => {
67
+ return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim();
68
+ });
69
+
70
+ // Має бути або з API (#5093f7) або дефолтне значення
71
+ expect(primaryColor).toBeTruthy();
72
+ });
73
+ });