@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.
- package/assets/css/custom.css +70 -0
- package/assets/css/remixicon.css +2782 -0
- package/assets/css/satoshi-font.css +31 -0
- package/assets/fonts/flaticon.css +463 -0
- package/assets/fonts/flaticon.eot +0 -0
- package/assets/fonts/flaticon.html +2153 -0
- package/assets/fonts/flaticon.svg +441 -0
- package/assets/fonts/flaticon.ttf +0 -0
- package/assets/fonts/flaticon.woff +0 -0
- package/assets/fonts/flaticon.woff2 +0 -0
- package/assets/fonts/remixicon.eot +0 -0
- package/assets/fonts/remixicon.svg +8230 -0
- package/assets/fonts/remixicon.ttf +0 -0
- package/assets/fonts/remixicon.woff +0 -0
- package/assets/fonts/remixicon.woff2 +0 -0
- package/assets/scss/_variables.scss +31 -0
- package/assets/scss/components/_about.scss +58 -0
- package/assets/scss/components/_authentication.scss +124 -0
- package/assets/scss/components/_backtoptop.scss +27 -0
- package/assets/scss/components/_banner.scss +396 -0
- package/assets/scss/components/_best-deals.scss +74 -0
- package/assets/scss/components/_blank.scss +40 -0
- package/assets/scss/components/_blog.scss +193 -0
- package/assets/scss/components/_cart.scss +108 -0
- package/assets/scss/components/_categories.scss +82 -0
- package/assets/scss/components/_checkout.scss +110 -0
- package/assets/scss/components/_dashboard.scss +388 -0
- package/assets/scss/components/_faq.scss +22 -0
- package/assets/scss/components/_filter-rang.scss +109 -0
- package/assets/scss/components/_footer.scss +270 -0
- package/assets/scss/components/_global.scss +550 -0
- package/assets/scss/components/_header.scss +587 -0
- package/assets/scss/components/_hurry.scss +52 -0
- package/assets/scss/components/_navbar.scss +1008 -0
- package/assets/scss/components/_offers.scss +689 -0
- package/assets/scss/components/_pagination.scss +71 -0
- package/assets/scss/components/_popup.scss +172 -0
- package/assets/scss/components/_preloader.scss +108 -0
- package/assets/scss/components/_products.scss +1147 -0
- package/assets/scss/components/_rtl.scss +806 -0
- package/assets/scss/components/_services.scss +16 -0
- package/assets/scss/components/_sidebar.scss +259 -0
- package/assets/scss/style.css +6676 -0
- package/assets/scss/style.css.map +1 -0
- package/assets/scss/style.scss +40 -0
- package/assets/webfonts/Satoshi-Bold.eot +0 -0
- package/assets/webfonts/Satoshi-Bold.woff +0 -0
- package/assets/webfonts/Satoshi-Bold.woff2 +0 -0
- package/assets/webfonts/Satoshi-Medium.eot +0 -0
- package/assets/webfonts/Satoshi-Medium.woff +0 -0
- package/assets/webfonts/Satoshi-Medium.woff2 +0 -0
- package/assets/webfonts/Satoshi-Regular.eot +0 -0
- package/assets/webfonts/Satoshi-Regular.woff +0 -0
- package/assets/webfonts/Satoshi-Regular.woff2 +0 -0
- package/components/AboutUs/AboutUsTuan.vue +25 -0
- package/components/AboutUs/Statistics.vue +42 -0
- package/components/AboutUs/SubscribeToTheNewsletter.vue +57 -0
- package/components/AddAddress/index.vue +70 -0
- package/components/BestSellers/Products.vue +1562 -0
- package/components/BestSellers/RecentlyViewed.vue +304 -0
- package/components/Cart/ProductQuantity.vue +29 -0
- package/components/Cart/index.vue +167 -0
- package/components/Categories/index.vue +305 -0
- package/components/ChangePassword/index.vue +71 -0
- package/components/Checkout/index.vue +192 -0
- package/components/Common/DashboardNavigation.vue +37 -0
- package/components/Common/PageBanner.vue +28 -0
- package/components/Common/ProductCard.vue +152 -0
- package/components/Common/Services.vue +58 -0
- package/components/Contact/ContactForm.vue +91 -0
- package/components/Contact/ContactInfo.vue +74 -0
- package/components/Dashboard/RecentOrder.vue +173 -0
- package/components/Dashboard/index.vue +79 -0
- package/components/EditAddress/index.vue +119 -0
- package/components/EditProfile/index.vue +97 -0
- package/components/FAQ/index.vue +121 -0
- package/components/FeaturedProduct/FeaturedProducts.vue +304 -0
- package/components/FeaturedProduct/Products.vue +1562 -0
- package/components/ForgotPassword/index.vue +51 -0
- package/components/Layout/BackToUp.vue +38 -0
- package/components/Layout/Copyright.vue +25 -0
- package/components/Layout/Footer.vue +183 -0
- package/components/Layout/FooterTwo.vue +165 -0
- package/components/Layout/LocationOption.vue +178 -0
- package/components/Layout/MiddleHeader.vue +229 -0
- package/components/Layout/MiddleHeaderThree.vue +204 -0
- package/components/Layout/MiddleHeaderTwo.vue +240 -0
- package/components/Layout/Navbar.vue +185 -0
- package/components/Layout/NavbarStyleFour.vue +334 -0
- package/components/Layout/NavbarStyleThree.vue +188 -0
- package/components/Layout/NavbarStyleTwo.vue +108 -0
- package/components/Layout/Preloader.vue +18 -0
- package/components/Layout/RTLSwitchBtn.vue +40 -0
- package/components/Layout/ResponsiveNavbar.vue +431 -0
- package/components/Layout/TopHeader.vue +130 -0
- package/components/Login/index.vue +94 -0
- package/components/MyAccount/index.vue +154 -0
- package/components/NewArrivals/Products.vue +1969 -0
- package/components/NewArrivals/RecentlyViewed.vue +304 -0
- package/components/OrderDetails/index.vue +77 -0
- package/components/OrderHistory/index.vue +197 -0
- package/components/PrivacyPolicy/index.vue +112 -0
- package/components/ProductDetails/ProductDetailsTab.vue +343 -0
- package/components/ProductDetails/ProductQuantity.vue +29 -0
- package/components/ProductDetails/RecentlyViewed.vue +304 -0
- package/components/ProductDetails/index.vue +268 -0
- package/components/Products/RecentlyViewed.vue +304 -0
- package/components/Products/index.vue +292 -0
- package/components/Register/index.vue +88 -0
- package/components/TermsConditions/index.vue +112 -0
- package/components/TrendingProducts/FeaturedProducts.vue +304 -0
- package/components/TrendingProducts/Products.vue +1564 -0
- package/components/Wishlist/ProductQuantity.vue +29 -0
- package/components/Wishlist/index.vue +128 -0
- package/composables/useCart.ts +149 -0
- package/composables/useCategories.ts +87 -0
- package/composables/useCheckout.ts +131 -0
- package/composables/useProducts.ts +162 -0
- package/composables/useSiteConfig.ts +236 -0
- package/composables/useTemplateSections.ts +74 -0
- package/e2e/cart.spec.ts +71 -0
- package/e2e/fixtures/mock-api.ts +166 -0
- package/e2e/homepage.spec.ts +65 -0
- package/e2e/layout.spec.ts +73 -0
- package/e2e/navigation.spec.ts +61 -0
- package/e2e/pages/cart.page.ts +44 -0
- package/e2e/pages/homepage.page.ts +46 -0
- package/e2e/playwright.config.ts +32 -0
- package/e2e/products.spec.ts +33 -0
- package/layouts/default.vue +94 -0
- package/layouts/inner.vue +70 -0
- package/nuxt.config.ts +86 -0
- package/package.json +38 -0
- package/pages/about-us.vue +12 -0
- package/pages/address.vue +10 -0
- package/pages/cart.vue +10 -0
- package/pages/categories.vue +10 -0
- package/pages/change-password.vue +10 -0
- package/pages/checkout.vue +10 -0
- package/pages/contact.vue +11 -0
- package/pages/dashboard.vue +10 -0
- package/pages/edit-address.vue +10 -0
- package/pages/edit-profile.vue +10 -0
- package/pages/faq.vue +10 -0
- package/pages/forgot-password.vue +10 -0
- package/pages/login.vue +10 -0
- package/pages/my-account.vue +10 -0
- package/pages/order-details.vue +10 -0
- package/pages/order-history.vue +10 -0
- package/pages/privacy-policy.vue +10 -0
- package/pages/product-details.vue +10 -0
- package/pages/products.vue +10 -0
- package/pages/register.vue +10 -0
- package/pages/terms-conditions.vue +10 -0
- package/pages/wishlist.vue +10 -0
- package/plugins/site-init.client.ts +24 -0
- package/plugins/vuetify.ts +18 -0
- package/types/index.ts +121 -0
- package/utils/image.ts +13 -0
- 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
|
+
};
|
package/e2e/cart.spec.ts
ADDED
|
@@ -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
|
+
});
|