Compare commits

..

147 commits

Author SHA1 Message Date
b21bbc8e7b Merge branch 'master' into storefront-nuxt 2026-03-05 16:41:00 +03:00
0194d152fe Merge branch 'master' into storefront-nuxt 2026-03-05 14:14:37 +03:00
114204b105 Merge branch 'master' into storefront-nuxt 2026-03-05 14:01:08 +03:00
1f39535f05 Merge branch 'master' into storefront-nuxt 2026-03-05 11:03:54 +03:00
18bb585a13 refactor(scripts): centralize static files collection in Docker entrypoint
Moved static files collection from platform-specific scripts to the Docker entrypoint script. Simplifies maintenance by eliminating redundant logic across Windows, Unix, and restart scripts.

Updated logo/icon settings to use `static()` for better flexibility.
2026-03-05 00:07:17 +03:00
52687beeec Merge branch 'master' into storefront-nuxt 2026-03-04 20:17:40 +03:00
01fe4192aa Merge branch 'master' into storefront-nuxt 2026-03-04 19:59:52 +03:00
6c1dbe0c5f feat(graphene): add address line fields to CreateAddress mutation
include `address_line_1` and optional `address_line_2` to enhance address creation functionality.
2026-03-04 19:59:01 +03:00
c889c20b61 feat(storefront): enhance error handling and navigation across pages
Added a global error page to improve user experience during navigation issues, with localized messages and redirect options. Enhanced error handling for brand, product, and category slug composables by introducing explicit 404 responses.

- Introduced `/error.vue` template for custom error displays using `NuxtError`.
- Updated `useBrandBySlug`, `useProductBySlug`, `useCategoryBySlug` to throw 404 errors when data is not found.
- Expanded i18n files (`en-gb.json` and `ru-ru.json`) with additional error-related translations.
- Replaced plain text input with a `.search`-scoped class for cleaner styling.

Enhances robustness and user feedback during navigation errors. No breaking changes introduced.
2026-03-04 16:27:52 +03:00
ef8be78d51 Merge branch 'master' into storefront-nuxt 2026-03-03 16:45:44 +03:00
aba67ec177 Merge remote-tracking branch 'origin/storefront-nuxt' into storefront-nuxt 2026-03-03 15:23:19 +03:00
69c224722e feat(storefront): add promocode support in cart with UI and logic updates
Implemented promocode application feature in the cart, allowing users to select and apply discounts during checkout. Updated GraphQL mutation, cart logic, and UI to support this functionality.

- Enhanced `cart.vue` with a new promocode selection section, including dropdown and styling.
- Modified `buyOrder` mutation to accept `promocodeUuid` and `forceBalance` parameters.
- Updated translations (`en-gb.json` and `ru-ru.json`) to include promocode-related strings.

Improves user experience by enabling discount application directly in the cart. No breaking changes.
2026-03-03 15:23:11 +03:00
51e9418e54 Merge branch 'master' into storefront-nuxt 2026-03-03 13:49:55 +03:00
574dc43d06 Merge branch 'master' into storefront-nuxt 2026-03-03 02:40:55 +03:00
f557fa462a Merge branch 'master' into storefront-nuxt 2026-03-03 01:50:06 +03:00
9bf600845a feat(storefront): enhance cart and wishlist handling with cookie-based products support
Introduced `useExactProducts` composable to fetch precise product details for guest cart and wishlist items. Improved cookie-based cart and wishlist fallback handling for unauthenticated users. Updated related components and composables for better synchronization and type safety.

- Added `useExactProducts` composable leveraging the `GET_EXACT_PRODUCTS` query.
- Enhanced `wishlist.vue` and `cart.vue` for reactive updates on guest state changes.
- Improved product synchronization logic in `useOrderSync` and `useWishlistSync`.
- Updated translations and fixed minor typos in localization files.

Improves user experience by ensuring consistent product details, even for guests. No breaking changes.
2026-03-02 23:06:13 +03:00
398e11d748 fix(storefront): rename useBrands to useFeedbacks for correct composable naming
Aligned composable name with its purpose by renaming `useBrands` to `useFeedbacks`. This resolves the naming inconsistency and improves code readability and maintainability.
2026-03-02 13:57:20 +03:00
03dbafaf44 feat(storefront): add feedbacks query and composable for improved data handling
Introduced `GET_FEEDBACKS` GraphQL query and `useFeedbacks` composable to enable retrieval and management of feedback data. Enhanced type safety with new TypeScript interfaces for feedback responses. Updated `Product` fragment to reuse `Feedback` fragment for better modularity.

- Added `feedbacks.ts` query file with `GET_FEEDBACKS`.
- Created `useFeedbacks.ts` composable for reactive feedback fetching and state management.
- Updated GraphQL fragments and `products.fragment.ts` to include `Feedback` reusability.
- Enhanced API type definitions with `IFeedbacksResponse` for response handling.

This improves modularity, type safety, and provides a reusable approach for feedback data integration. No breaking changes.
2026-03-02 13:56:51 +03:00
7e31a80290 Merge branch 'master' into storefront-nuxt 2026-03-02 01:58:36 +03:00
783fca1f28 Merge branch 'master' into storefront-nuxt 2026-03-02 01:31:57 +03:00
e60839b896 Merge branch 'master' into storefront-nuxt 2026-03-02 00:53:35 +03:00
56688c9c09 Merge branch 'master' into storefront-nuxt 2026-03-02 00:46:00 +03:00
2c4e66832f Merge branch 'master' into storefront-nuxt 2026-03-02 00:33:39 +03:00
d4b2839502 feat(storefront): enhance category and product templates with HTML rendering
Updated catalog and product pages to render descriptions using HTML (`v-html`), allowing richer content presentation. Improved characteristics section for products, reintroducing previously commented-out functionality with refined structure and styling.

- Enabled `v-html` in `categorySlug.vue` and `slug.vue` for proper HTML display in descriptions.
- Restored and revamped characteristics section with better styling and layout.
- Adjusted SCSS for consistent typography and spacing in characteristics.

No breaking changes introduced—enhancements improve presentation and maintain code quality.
2026-03-01 22:13:24 +03:00
e8e0675d7d feat(storefront): enhance store filter and price handling for improved UX
Enhanced store filters with a refined price range slider, accommodating category-specific min/max prices and dynamic updates. Optimized reactivity for product filtering by consolidating watchers into a unified approach. Adjusted UI elements for consistent spacing and modern icon usage in filters.

- Added `minMaxPrices` and debounce logic to improve price filtering performance.
- Updated filter UI with collapsible headers and better styling for usability.
- Refactored multiple watchers into a single handler for better efficiency.
- Introduced global constants for currency symbol usage.

Breaking Changes: Components relying on price filters must adapt to new props and event names (`filterMinPrice`, `filterMaxPrice`). Styles may require alignment with refined SCSS rules for filters.
2026-03-01 22:00:34 +03:00
556354a44d feat(storefront): overhaul theming system and unify SCSS variables
Revamped the theming system with new SCSS variables for consistent styling across light and dark themes. Replaced static color values with dynamic variables for maintainability and improved theme adaptability. Updated components and layouts to use the new variables.

- Moved theme plugin logic for optimized handling of theme cookies and attributes.
- Enhanced `useThemes` composable for simplified client-side updates and SSR support.
- Replaced redundant SCSS color definitions with centralized variables.
- Improved page structure by introducing `ui-title` for reusable section headers.
- Unified transitions and border-radius for consistent design language.

Breaking Changes:
Theming system restructured—migrate to `$main`, `$primary`, and related variables for SCSS colors. Remove usage of `--color-*` variables in templates and styles.
2026-03-01 20:16:05 +03:00
8d7685ef67 feat(notification): integrate global notification plugin using ElNotification
Added a global `notify` method via Nuxt plugin to replace `useNotification`. Improved messaging structure by embedding progress bars and handled dynamic durations. Updated usage across composables and components for consistency.

- Replaced `useNotification` with `$notify` in all applicable files.
- Updated `app.config.ts` to support customizable notification positions.
- Refactored affected composables for simplified notification calls.
- Enhanced progress indicator display within notifications.

Breaking Changes:
`useNotification` is removed, requiring migration to the new `$notify` API.
2026-03-01 15:30:47 +03:00
2ea18eb8a6 feat(storefront): refactor i18n and cart/wishlist handling for improved user experience
Refactored i18n configuration, replacing `DEFAULT_LOCALE` with `DEFAULT_LOCALE_FALLBACK` and enhancing environment-based locale validation. Improved cookie persistence for cart and wishlist, ensuring fallback handling for unauthenticated users.

Enhancements:
- Added `createProjectKey` utility for consistent project key generation.
- Reworked cart and wishlist composables (`useOrderOverwrite`, `useWishlistOverwrite`) to decouple product identifier and handle cookies robustly.
- Centralized `DEFAULT_LOCALE` logic for better maintainability.
- Refined `useOrderSync` and `useWishlistSync` for clean synchronization across auth states.
- Updated SCSS in hero and header styles for alignment corrections.

Breaking Changes: `DEFAULT_LOCALE` constant removed; replaced with runtime config and fallback logic. Consumers must adapt to `DEFAULT_LOCALE_FALLBACK` and `$appHelpers.DEFAULT_LOCALE`.
2026-02-28 22:38:45 +03:00
a14be696e7 feat(storefront): add SCHON_LANGUAGE_CODE support to Docker setup
Include SCHON_LANGUAGE_CODE as an environment variable in the `storefront.Dockerfile` and `docker-compose.yml`. This allows for configuring the language code dynamically, improving flexibility for multilingual setups.
2026-02-28 19:15:05 +03:00
e65e7b7d73 **chore(storefront): apply consistent code formatting and improve readability**
Refactored multiple files for code styling consistency, using proper indentation and spacing to align with team standards. Improved readability and maintainability across composables, Apollo plugin, and localization files.

Enhancements:
- Standardized import and function indentation across all composables.
- Updated `biome.json` schema to the latest version (v2.4.4) for tool compatibility.
- Organized code blocks in Apollo plugin for better understandability.

No functional changes introduced—this is a non-breaking, code refinement commit.
2026-02-28 17:41:25 +03:00
c36135d78d feat(storefront): add wishlist and guest cart support with cookie persistence
Enhancements:
- Introduced `wishlist.vue` for displaying and managing the wishlist.
- Added guest cart and wishlist handling via cookies for unauthenticated users.
- Implemented synchronization logic for wishlist and cart (`useOrderSync` and `useWishlistSync`) upon user login.
- Updated `cart.vue` layout with a bulk 'add all to cart' button for wishlist items.
- Enhanced `post.vue` prop handling for improved type safety.

Fixes:
- Fixed breadcrumbs console log removal in `useBreadcrumbs.ts`.
- Corrected and unified translations in `en-gb.json` for cart and wishlist descriptions.
- Fixed stale routes in footer (`terms-and-condition` -> `terms-and-conditions`, etc.).

Extras:
- Refactored composables `useWishlistOverwrite` and `useOrderOverwrite` for cookie-based fallback.
- Applied code styling improvements, organized imports, and optimized API requests in Apollo plugin.
2026-02-28 17:39:17 +03:00
8c4ec23f92 Merge branch 'master' into storefront-nuxt 2026-02-28 09:26:38 +03:00
af477362d2 fix(storefront): update env variables in docker-compose for consistency
Replaced `EVIBES_*` variables with `SCHON_*` to align with naming conventions. Ensures consistent environment variable usage across services.
2026-02-28 00:05:59 +03:00
d5944436ba refactor(docker): rename environment variables for storefront to SCHON
Updated environment variable names from `EVIBES` to `SCHON` in the storefront Dockerfile and docker-compose configuration. Aligns with new naming conventions used across the project for clarity and consistency.
2026-02-27 23:55:09 +03:00
faea55c257 Merge branch 'master' into storefront-nuxt 2026-02-27 23:44:57 +03:00
1e1d0ef397 2026.1 2026-02-27 21:59:51 +03:00
0429b62ba1 Merge branch 'refs/heads/master' into storefront-nuxt 2026-02-27 21:52:34 +03:00
c2052d62fd Merge branch 'refs/heads/master' into storefront-nuxt 2026-02-27 21:47:30 +03:00
cc11da01f9 refactor(storefront): replace evibes logo with schon logo in SVG format
Updated the SVG logo file in storefront to replace evibes branding with schon. Includes enhanced styling and additional view properties for resolution compatibility.
2026-02-05 17:23:59 +03:00
9ae3b4433f Merge branch 'master' into storefront-nuxt 2026-02-05 17:13:57 +03:00
e065075c5a Merge branch 'main' into storefront-nuxt 2026-01-07 20:23:15 +10:00
6b0d1ad1dc Merge branch 'main' into storefront-nuxt 2026-01-06 10:21:00 +03:00
e861b2344b Merge branch 'main' into storefront-nuxt 2026-01-04 20:56:02 +03:00
a560f258fa Merge branch 'main' into storefront-nuxt 2025-12-29 17:37:49 +03:00
c7b09f77f1 Merge branch 'main' into storefront-nuxt 2025-12-29 16:05:27 +03:00
951dc10107 Merge branch 'main' into storefront-nuxt 2025-12-28 17:05:35 +03:00
f6e1e43665 Merge branch 'main' into storefront-nuxt 2025-12-27 01:09:15 +03:00
1c615a987a Update Node.js base image in storefront Dockerfile to v24 for improved compatibility. 2025-12-26 05:07:27 +03:00
7d0b6301ea Merge branch 'main' into storefront-nuxt 2025-12-26 04:01:50 +03:00
3191220ee9 Merge branch 'main' into storefront-nuxt 2025-12-18 19:36:45 +03:00
23fb126574 Merge branch 'main' into storefront-nuxt
# Conflicts:
#	engine/core/templates/admin/dashboard/_filters.html
#	engine/core/templates/admin/index.html
2025-12-15 21:19:22 +03:00
1885937244 Features: None;
Fixes: None;

Extra: 1) Remove redundant comments from `_kpis.html`; 2) Exclude unused `_income_overview.html` include from `index.html`; 3) Comment out GA/Yandex/Ads integration placeholder in `_filters.html`.
2025-12-10 19:45:39 +03:00
f6b1d6d9fc Merge branch 'main' into storefront-nuxt 2025-12-10 19:34:47 +03:00
5464f31ab8 Merge branch 'main' into storefront-nuxt 2025-12-10 18:39:03 +03:00
88572108d5 Merge branch 'main' into storefront-nuxt 2025-12-10 16:37:46 +03:00
5491873585 Merge branch 'main' into storefront-nuxt 2025-12-10 15:57:50 +03:00
3137e9945c Merge branch 'main' into storefront-nuxt 2025-12-10 15:34:31 +03:00
d6dd9fafe7 Merge branch 'main' into storefront-nuxt 2025-12-10 15:14:50 +03:00
90bb8c3326 Merge branch 'main' into storefront-nuxt 2025-12-08 22:01:56 +03:00
d482438cef Merge branch 'main' into storefront-nuxt 2025-12-08 11:00:16 +03:00
18c2f9c154 Merge branch 'main' into storefront-nuxt 2025-12-03 14:45:26 +03:00
90bd5066c8 Merge branch 'main' into storefront-nuxt 2025-12-01 20:23:13 +03:00
bea3efadc9 Merge branch 'main' into storefront-nuxt 2025-12-01 09:55:26 +03:00
375287bbb7 Merge branch 'main' into storefront-nuxt 2025-11-25 14:47:42 +03:00
ae1f291a51 Merge branch 'main' into storefront-nuxt 2025-11-25 14:26:27 +03:00
7be460af67 Merge branch 'main' into storefront-nuxt 2025-11-25 11:29:29 +03:00
a63f02673e Merge branch 'main' into storefront-nuxt 2025-11-24 20:15:10 +03:00
2ceef3cc15 Merge branch 'main' into storefront-nuxt 2025-11-24 16:13:58 +03:00
8f12f9c085 Merge branch 'main' into storefront-nuxt 2025-11-18 22:34:30 +03:00
4750bed093 Merge branch 'main' into storefront-nuxt 2025-11-18 12:53:20 +03:00
3e58d47b08 Merge branch 'main' into storefront-nuxt 2025-11-18 12:07:47 +03:00
e34de24b59 Merge branch 'main' into storefront-nuxt 2025-11-18 11:32:16 +03:00
0f53ac3710 Merge branch 'main' into storefront-nuxt 2025-11-18 11:15:51 +03:00
f571310ec8 Merge branch 'main' into storefront-nuxt 2025-11-17 16:32:51 +03:00
3e20e70bd1 Merge branch 'main' into storefront-nuxt 2025-11-17 16:03:10 +03:00
7907613fdb Merge branch 'main' into storefront-nuxt 2025-11-17 15:50:43 +03:00
b876946a78 Merge branch 'main' into storefront-nuxt 2025-11-17 14:03:14 +03:00
292b26acce Merge branch 'main' into storefront-nuxt 2025-11-17 10:03:53 +03:00
e3d98f2361 Merge branch 'main' into storefront-nuxt 2025-11-16 17:23:07 +03:00
b489405783 Merge branch 'main' into storefront-nuxt 2025-11-16 17:10:38 +03:00
57e5e49059 Merge branch 'main' into storefront-nuxt 2025-11-16 16:26:16 +03:00
244d94831e Merge branch 'main' into storefront-nuxt 2025-11-14 17:22:00 +03:00
e868ec93d5 Merge branch 'main' into storefront-nuxt 2025-11-13 18:11:58 +03:00
30e9bc444a Merge branch 'main' into storefront-nuxt 2025-11-13 15:26:02 +03:00
5dd055b677 Merge branch 'main' into storefront-nuxt 2025-11-12 22:07:01 +03:00
aee8bd2770 Merge branch 'main' into storefront-nuxt 2025-11-12 16:40:45 +03:00
a77d18d33c Merge branch 'main' into storefront-nuxt 2025-11-12 15:59:22 +03:00
9dcd867a19 Merge branch 'main' into storefront-nuxt 2025-11-12 15:36:54 +03:00
137da76bb1 Merge branch 'main' into storefront-nuxt 2025-11-12 15:29:50 +03:00
ddb12b75c4 Merge branch 'main' into storefront-nuxt 2025-11-12 13:15:04 +03:00
252b86636a Merge branch 'main' into storefront-nuxt 2025-11-12 13:08:52 +03:00
d791cbb83a Merge branch 'main' into storefront-nuxt 2025-11-12 12:56:27 +03:00
945767fe01 Merge branch 'main' into storefront-nuxt 2025-11-12 12:53:49 +03:00
6dd7b3568c Merge branch 'main' into storefront-nuxt 2025-11-12 12:21:25 +03:00
b4f5919e81 Merge branch 'main' into storefront-nuxt 2025-11-12 12:12:48 +03:00
28011e3afc Fixes: 1) Remove unused fields small_logo and big_logo from BrandDetailSerializer.
Extra: 1) Cleanup related unused methods `get_small_logo` and `get_big_logo`.
2025-11-12 10:25:33 +03:00
d3e016c8d6 Merge branch 'main' into storefront-nuxt 2025-11-11 23:56:29 +03:00
0f888baa45 Merge branch 'main' into storefront-nuxt 2025-11-11 18:08:15 +03:00
53425b855d Merge branch 'main' into storefront-nuxt
# Conflicts:
#	evibes/settings/jazzmin.py
2025-11-11 17:08:51 +03:00
c88e0d7569 Features: 1) Update "Storefront" and "GraphQL Docs" links to use BASE_DOMAIN and STOREFRONT_DOMAIN constants.
Fixes: 1) Add missing import for `BASE_DOMAIN` and `STOREFRONT_DOMAIN`.

Extra: 1) Remove unused `base_domain` field from `ConfigType`.
2025-11-11 16:23:52 +03:00
dccaa206d6 Merge branch 'main' into storefront-nuxt 2025-11-11 16:17:50 +03:00
7a7d26f7d7 Merge branch 'main' into storefront-nuxt 2025-11-11 16:13:13 +03:00
2ccb812b71 Merge branch 'main' into storefront-nuxt 2025-11-11 16:05:00 +03:00
d7f5ed4141 Merge branch 'main' into storefront-nuxt 2025-11-11 15:38:29 +03:00
607af80235 Merge branch 'main' into storefront-nuxt 2025-11-11 10:45:26 +03:00
48e3380cd0 Merge branch 'main' into storefront-nuxt 2025-10-29 13:08:37 +03:00
d8386fcd93 Merge branch 'main' into storefront-nuxt 2025-10-28 15:31:52 +03:00
0b246bcd3b Merge branch 'main' into storefront-nuxt 2025-10-26 16:22:04 +03:00
ba8914486e Merge branch 'main' into storefront-nuxt 2025-10-25 20:56:11 +03:00
ca6554c6d1 Merge branch 'main' into storefront-nuxt 2025-10-25 19:37:04 +03:00
04fa776623 Merge branch 'main' into storefront-nuxt 2025-10-25 02:50:41 +03:00
1d1213813c Merge branch 'main' into storefront-nuxt 2025-10-24 13:07:15 +03:00
ea53f398a3 Merge branch 'main' into storefront-nuxt 2025-10-21 12:59:12 +03:00
78e3a650ac Merge branch 'main' into storefront-nuxt 2025-10-17 14:28:44 +03:00
f289ea1e8e Merge branch 'main' into storefront-nuxt 2025-10-17 11:16:07 +03:00
6fd21183fe Merge branch 'main' into storefront-nuxt 2025-10-14 19:49:47 +03:00
1a6a9f666e Merge branch 'main' into storefront-nuxt 2025-10-14 16:51:24 +03:00
f370c0872f Features: 1) Add error handling for invalid token scenarios with isTokenInvalidError in useRefresh; 2) Integrate useLogout logic in useRefresh for improved redirection after token expiration; 3) Optimize server-side refresh operations with conditional execution in app.vue; 4) Enhance form behavior in input.vue with dynamic autocapitalize attribute.
Fixes: 1) Improve error notification handling in `useRefresh` with detailed GraphQL message extraction; 2) Address missing token reassignments for `refreshToken` and `accessToken` in `useRefresh`; 3) Resolve redundant refresh execution in non-server environments of `app.vue`.

Extra: 1) Refactor `useRefresh` for cleaner error handling and better modularity; 2) Cleanup unused comments and enhance log messages for easier debugging; 3) Organize imports across updated files for standardization.
2025-10-13 14:21:19 +03:00
85ec39255b Merge branch 'main' into storefront-nuxt 2025-10-07 23:14:14 +03:00
02c402c6de Merge branch 'main' into storefront-nuxt 2025-10-07 15:53:29 +03:00
c9807bd6d4 Features: 1) Add product rating support in types, GraphQL fragments, and UI components; 2) Implement feedback management including GraphQL mutations, composables, and notification handling; 3) Enhance locale switching with improved reactivity, Apollo query clearing, and supported locale validation; 4) Introduce useOrderBuy composable for order purchasing workflow.
Fixes: 1) Correct mutation name from `setlanguage` to `setLanguage` for consistency; 2) Improve product listing reactivity by addressing missing initialization in `useStore`; 3) Replace generic product queries with parametrized `useProducts` for modularity; 4) Resolve minor typos, missing semicolons, and code formatting inconsistencies.

Extra: 1) Refactor feedback-related types, composables, and GraphQL utilities for modularity; 2) Update styles, Vue templates, and related scripts with enhanced formatting; 3) Remove unused methods like `getProducts`, standardizing query reactivity; 4) Cleanup and organize imports across multiple files.
2025-10-06 18:19:19 +03:00
949e077942 Merge branch 'main' into storefront-nuxt 2025-10-03 16:56:42 +03:00
ab35cb0c85 Merge branch 'main' into storefront-nuxt 2025-09-30 17:03:13 +03:00
063123d040 Merge branch 'main' into storefront-nuxt 2025-09-30 11:38:28 +03:00
5f50281029 Merge branch 'main' into storefront-nuxt 2025-09-22 14:11:34 +03:00
4a99d51077 Merge branch 'main' into storefront-nuxt 2025-09-19 17:05:29 +03:00
3d4df235f2 Merge branch 'main' into storefront-nuxt 2025-09-17 18:58:22 +03:00
87b62b32e8 Merge branch 'main' into storefront-nuxt
# Conflicts:
#	storefront/public/robots.txt
2025-09-15 14:23:54 +03:00
492aeb85db Merge branch 'main' into storefront-nuxt 2025-09-13 15:45:33 +03:00
e639e49e7e Merge branch 'main' into storefront-nuxt
# Conflicts:
#	docker-compose.yml
2025-09-13 15:31:06 +03:00
a70967db73 Features: 1) Add support for dynamic EVIBES_BASE_DOMAIN and EVIBES_PROJECT_NAME environment variables in storefront Dockerfile.
Fixes: 1) Ensure `NODE_ENV` is consistently removed from Dockerfile and docker-compose.

Extra: 1) Simplify `NODE_ENV` handling in docker-compose configuration.
2025-09-13 15:12:55 +03:00
b68911006b Features: 1) Add Dockerfile for building and running storefront application; 2) Introduce multi-stage build with separation of development and runtime environments; 3) Include environment variable management and non-root user setup;
Fixes: 1) Update dependencies in `package-lock.json` with the latest versions to address compatibility issues;

Extra: 1) Remove unused dependencies and redundant package references from `package-lock.json` for optimization; 2) Improve scripts and permission setups in Dockerfile for clarity and security.
2025-09-13 14:13:26 +03:00
dc19e1f0a0 Merge remote-tracking branch 'origin/storefront-nuxt' into storefront-nuxt 2025-09-13 13:02:24 +03:00
282c0ae541 Merge branch 'main' into storefront-nuxt 2025-09-13 13:02:00 +03:00
e78d2fc652 Features: 1) Update robots.txt to include improved crawler directives for /profile while maintaining SEO sitemap and host details;
Fixes: 1) Remove redundant `robots_frontend.txt` file and replace with consolidated `robots.txt` from `storefront/public`;

Extra: 1) Standardize formatting of crawler directives in `robots.txt`.
2025-09-13 12:59:53 +03:00
9877633a2c Merge branch 'main' into storefront-nuxt 2025-09-13 12:54:17 +03:00
40ae24a04c Features: 1) Add SEO-related fragments to GraphQL queries including SEOMETA_FRAGMENT usage in brands, categories, and products queries; 2) Enable localized and dynamic SEO metadata handling in category pages with Vue composables and useSeoMeta; 3) Replace obsolete client-only wrapper with native Nuxt components like nuxt-marquee for enhanced rendering;
Fixes: 1) Correct file path imports by removing `.js` extensions in GraphQL fragments; 2) Resolve typo in `usePromocodeStore` composables to ensure consistent store usage; 3) Add missing `:type="submit"` to login form button for proper form submission handling;

Extra: 1) Remove unused `.idea` and `README.md` files for repository cleanup; 2) Delete extraneous dependencies from `package-lock.json` for streamlined package management; 3) Refactor category slug handling with improved composable logic for cleaner route parameters and SEO alignment.
2025-09-13 12:53:06 +03:00
408dee727e Merge branch 'main' into storefront-nuxt 2025-08-19 17:49:44 +03:00
cb8e4fb2ab Features: 1) Add default value and path options for cookieAccess initialization in useRefresh; 2) Implement nextTick usage in useLogin and useRefresh for improved reactivity; 3) Enhance apollo.ts with cookieAccess object to ensure token consistency;
Fixes: 1) Reorder `router.push` in `useLogout` to properly clear cookies before redirection; 2) Resolve issues with inconsistent access token handling during Apollo header configuration;

Extra: 1) Cleanup comments in `useRefresh
2025-08-19 17:48:41 +03:00
64730a1d4e Features: 1) Add appStore integration for managing search overlay state in search.vue; 2) Enhance expiration date formatting in promocodes.vue with detailed and localized time display; 3) Replace avatar image handling in settings.vue with nuxt-img for better performance and format support;
Fixes: 1) Add missing type annotations for `isSearchActive` in `useSearchUi.ts`; 2) Resolve improper conditional rendering in empty state templates across multiple files; 3) Remove unnecessary `console.log` calls in `goTo` function;

Extra: 1) Update SCSS styles including border thickness, colors, and padding tweaks; 2) Refactor `loader.vue` to use `<span>` instead of `<li>` for dots and adjust size; 3) Clean up obsolete TODOs, comments, and unused imports.
2025-07-15 21:25:51 +10:00
4957039fc5 Features: 1) Integrate advanced Apollo link setup including error handling, authentication, and custom link chaining; 2) Replace apollo-upload-link.ts with revised client link configuration in apollo.ts; 3) Add @types/apollo-upload-client and @types/extract-files for enhanced TypeScript support;
Fixes: 1) Remove deprecated and redundant logic from `useAvatarUpload`; 2) Correct non-functional avatar upload and improve template handling in `settings.vue`;

Extra: 1) Cleanup unused imports, comments, and SCSS styles across files; 2) Simplify plugin configuration and migration to consolidated link logic; 3) Update package dependencies with precise resolution in `package-lock.json`.
2025-07-11 19:25:03 +03:00
52b32bd608 Features: 1) Introduce useUserBaseData composable to fetch and manage user's wishlist, orders, and promocodes; 2) Add reusable useOrders and useOrderOverwrite composables with advanced filtering and pagination; 3) Implement order.vue component for detailed order displays with UI enhancements;
Fixes: 1) Replace deprecated context usage in `useAvatarUpload` mutation; 2) Resolve incorrect locale parsing in `useDate` utility and fix non-reactive cart state in `profile/cart.vue`; 3) Update stale imports and standardize type naming across composables;

Extra: 1) Refactor i18n strings including order status and search-related texts; 2) Replace temporary workarounds with `apollo-upload-client` configuration and add `apollo-upload-link.ts` plugin; 3) Cleanup redundant files, comments, and improve SCSS structure with new variables and placeholders.
2025-07-11 18:39:13 +03:00
c60ac13e88 Features: 1) Introduce handleDeposit function with validation logic and deposit transaction flow; 2) Add useDeposit composable and balance.vue page for user account balance management; 3) Enhance wishlist and cart functionality with authentication checks and notification improvements;
Fixes: 1) Replace `ElNotification` with `useNotification` across all components and composables; 2) Add missing semicolons, consistent formatting, and type annotations in multiple files; 3) Resolve non-reactive elements in wishlist and cart state management;

Extra: 1) Update i18n translations with new strings for promocodes, balance, authentication, and profile settings; 2) Refactor SCSS styles including variable additions and component-specific tweaks; 3) Remove redundant queries, unused imports, and `storePage.ts` file for cleanup.
2025-07-08 23:41:31 +03:00
761fecf67f Features: 1) Add useWishlistOverwrite composable for wishlist mutations, including adding, removing, and bulk actions; 2) Introduce new localized UI texts for cart and wishlist operations; 3) Enhance filtering logic with parseAttributesString and route query synchronization;
Fixes: 1) Replace `ElNotification` calls with `useNotification` utility across all authentication and user-related composables; 2) Add missing semicolons in multiple index exports and styled components; 3) Resolve issues with reactivity in `useStore` composable by renaming and restructuring product variables;

Extra: 1) Refactor localized strings and translations for better readability and maintenance; 2) Tweak styles including scoped styles, z-index adjustments, and SCSS mixins; 3) Remove unused components and imports to streamline storefront layout.
2025-07-06 19:49:26 +03:00
53df1f5b88 Merge branch 'main' into storefront-nuxt 2025-06-27 01:59:38 +03:00
a31ee9c6b1 Features: 1) Add Source Code Pro Light font asset for theme enhancement;
Fixes: None;

Extra: None;
2025-06-27 01:59:02 +03:00
129ad1a6fa Features: 1) Build standalone pages for search, contact, catalog, category, brand, product, and home with localized metadata and scoped styles; 2) Add extensive TypeScript definitions for API and app-level structures, including products, orders, brands, and categories; 3) Implement i18n configuration with dynamic browser language detection and fallback system;
Fixes: None;

Extra: 1) Create Pinia stores for app, user, category, and company management; 2) Add utility functions for error handling and category slug lookups; 3) Include German locale file and robots.txt for improved SEO and accessibility; 4) Add SVG assets and improve general folder structure for better maintainability.
2025-06-27 00:10:35 +03:00
309 changed files with 31326 additions and 14 deletions

View file

@ -12,6 +12,7 @@ coverage.*
*.py,cover
nosetests.xml
desktop.ini
tmp/
# Cache directories
__pycache__/
@ -51,6 +52,8 @@ wheels/
share/python-wheels/
pip-log.txt
pip-delete-this-directory.txt
storefront/node_modules/
storefront/.nuxt
# Git
.git/

View file

@ -0,0 +1,40 @@
# syntax=docker/dockerfile:1
FROM node:24-bookworm-slim AS build
WORKDIR /app
ARG SCHON_BASE_DOMAIN
ARG SCHON_PROJECT_NAME
ARG SCHON_LANGUAGE_CODE
ENV SCHON_BASE_DOMAIN=$SCHON_BASE_DOMAIN
ENV SCHON_PROJECT_NAME=$SCHON_PROJECT_NAME
ENV SCHON_LANGUAGE_CODE=$SCHON_LANGUAGE_CODE
COPY ./storefront/package.json ./storefront/package-lock.json ./
RUN npm ci --include=optional
COPY ./storefront ./
RUN npm run build
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV HOST=0.0.0.0
ENV PORT=3000
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
RUN addgroup --system --gid 1001 nodeapp \
&& adduser --system --uid 1001 --ingroup nodeapp --home /home/nodeapp nodeapp
USER nodeapp
COPY --from=build /app/.output/ ./
RUN install -d -m 0755 -o nodeapp -g nodeapp /home/nodeapp \
&& printf '#!/bin/sh\nif [ \"$DEBUG\" = \"1\" ]; then export NODE_ENV=development; else export NODE_ENV=production; fi\nexec node /app/server/index.mjs\n' > /home/nodeapp/start.sh \
&& chown nodeapp:nodeapp /home/nodeapp/start.sh \
&& chmod +x /home/nodeapp/start.sh
USER nodeapp
CMD ["sh", "/home/nodeapp/start.sh"]

View file

@ -126,7 +126,7 @@ services:
container_name: worker
build:
context: .
dockerfile: Dockerfiles/worker.Dockerfile
dockerfile: ./Dockerfiles/worker.Dockerfile
restart: always
volumes:
- .:/app
@ -154,7 +154,7 @@ services:
container_name: stock_updater
build:
context: .
dockerfile: Dockerfiles/stock_updater.Dockerfile
dockerfile: ./Dockerfiles/stock_updater.Dockerfile
restart: always
volumes:
- .:/app
@ -182,7 +182,7 @@ services:
container_name: beat
build:
context: .
dockerfile: Dockerfiles/beat.Dockerfile
dockerfile: ./Dockerfiles/beat.Dockerfile
restart: always
volumes:
- .:/app
@ -214,6 +214,30 @@ services:
ports:
- "9090:9090"
storefront:
container_name: storefront
build:
context: .
dockerfile: ./Dockerfiles/storefront.Dockerfile
args:
- DEBUG=${DEBUG}
- SCHON_BASE_DOMAIN=${SCHON_BASE_DOMAIN}
- SCHON_PROJECT_NAME=${SCHON_PROJECT_NAME}
- SCHON_LANGUAGE_CODE=${SCHON_LANGUAGE_CODE}
restart: always
env_file:
- .env
environment:
- NUXT_HOST=0.0.0.0
- NUXT_PORT=3000
- NUXT_DEVTOOLS_ENABLED=${DEBUG}
ports:
- "3000:3000"
depends_on:
app:
condition: service_started
logging: *default-logging
volumes:
postgres-data:

View file

@ -619,6 +619,8 @@ class CreateAddress(Mutation):
raw_data = String(
required=True, description=_("original address string provided by the user")
)
address_line_1 = String(required=True)
address_line_2 = String(required=False)
address = Field(AddressType)

View file

@ -1,11 +0,0 @@
User-agent: *
Disallow: /*/public/wishlist/
Disallow: /*/public/cart/
Disallow: /*/public/checkout/
Disallow: /*/auth/sign-in/
Disallow: /*/auth/sign-up/
Allow: /
Sitemap: https://schon.wiseless.xyz/sitemap.xml
Host: schon.wiseless.xyz

24
storefront/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

View file

19
storefront/app.config.d.ts vendored Normal file
View file

@ -0,0 +1,19 @@
declare module 'nuxt/schema' {
interface AppConfig {
i18n: {
supportedLocales: Array<{
code: string;
file: string;
default: boolean;
}>;
};
ui: {
showBreadcrumbs: boolean;
showSearchBar: boolean;
isHeaderFixed: boolean;
isAuthModals: boolean;
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
};
}
}
export {};

View file

@ -0,0 +1,14 @@
import { SUPPORTED_LOCALES } from '@appConstants';
export default defineAppConfig({
i18n: {
supportedLocales: SUPPORTED_LOCALES,
},
ui: {
showBreadcrumbs: true,
showSearchBar: true,
isHeaderFixed: true,
isAuthModals: false,
notificationPosition: 'top-right',
},
});

204
storefront/app/app.vue Normal file
View file

@ -0,0 +1,204 @@
<template>
<div
class="main"
:style="{ 'padding-top': uiConfig.isHeaderFixed ? '84px': '0' }"
>
<nuxt-loading-indicator :color="primaryColor" />
<base-header />
<ui-breadcrumbs v-if="uiConfig.showBreadcrumbs && showBreadcrumbs" />
<transition
name="opacity"
mode="out-in"
v-if="uiConfig.isAuthModals"
>
<base-auth v-if="activeState">
<forms-login v-if="appStore.isLogin" />
<forms-register v-if="appStore.isRegister" />
<forms-reset-password v-if="appStore.isForgot" />
<forms-new-password v-if="appStore.isReset" />
</base-auth>
</transition>
<nuxt-page />
<ui-button
:type="'button'"
class="demo__button"
v-if="isDemoMode"
@click="appStore.setDemoSettings(!appStore.isDemoSettings)"
>
<icon
name="material-symbols:settings"
size="30"
/>
</ui-button>
<transition name="opacity" mode="out-in">
<demo-settings />
</transition>
<base-footer />
</div>
</template>
<script setup lang="ts">
import {useRefresh} from '@composables/auth';
import {useLanguages, useLocaleRedirect} from '@composables/languages';
import {useCompanyInfo} from '@composables/company';
import {useCategories} from '@composables/categories';
import {useProjectConfig} from '@composables/config';
const { locale } = useI18n();
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const { $appHelpers } = useNuxtApp();
const { isDemoMode, uiConfig } = useProjectConfig();
const switchLocalePath = useSwitchLocalePath();
const showBreadcrumbs = computed(() => {
const name = typeof route.name === 'string' ? route.name : '';
return ![
'index',
'search',
'profile',
'activate-user',
'reset-password',
'auth-sign-in',
'auth-sign-up',
'auth-reset-password',
'contact',
'blog',
'docs'
].some(prefix => name.startsWith(prefix));
});
const activeState = computed(() => appStore.activeAuthState);
const cookieLocale = useCookie(
$appHelpers.COOKIES_LOCALE_KEY,
{
default: () => $appHelpers.DEFAULT_LOCALE,
path: '/'
}
);
const { refresh } = useRefresh();
const { getCategories } = await useCategories();
const { isSupportedLocale } = useLocaleRedirect();
let refreshInterval: NodeJS.Timeout;
if (import.meta.server) {
await Promise.all([
refresh(),
useLanguages(),
useCompanyInfo(),
getCategories()
]);
}
watch(
() => [appStore.activeAuthState, appStore.isDemoSettings],
([authState, isDemo]) => {
appStore.setOverflowHidden(authState !== '' || isDemo);
},
{ immediate: true }
);
watch(locale, () => {
useHead({
htmlAttrs: {
lang: locale.value
}
});
});
let stopWatcher: VoidFunction = () => {};
if (!cookieLocale.value) {
cookieLocale.value = $appHelpers.DEFAULT_LOCALE;
await router.push({path: switchLocalePath(cookieLocale.value)});
}
if (locale.value !== cookieLocale.value) {
if (isSupportedLocale(cookieLocale.value)) {
await router.push({
path: switchLocalePath(cookieLocale.value),
query: route.query
});
} else {
cookieLocale.value = $appHelpers.DEFAULT_LOCALE;
await router.push({
path: switchLocalePath($appHelpers.DEFAULT_LOCALE),
query: route.query
});
}
}
const primaryColor = ref('');
const getCssVariable = (name: string): string => {
if (import.meta.client) {
return getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim();
}
return '';
};
onMounted( async () => {
refreshInterval = setInterval(async () => {
await refresh();
}, 600000);
stopWatcher = watch(
() => appStore.isOverflowHidden,
(hidden) => {
const root = document.documentElement;
const body = document.body;
if (hidden) {
root.classList.add('lock-scroll');
body.classList.add('lock-scroll');
} else {
root.classList.remove('lock-scroll');
body.classList.remove('lock-scroll');
}
},
{ immediate: true }
);
primaryColor.value = getCssVariable('--primary');
useHead({
htmlAttrs: {
lang: locale.value
}
});
});
onBeforeUnmount(() => {
stopWatcher()
document.documentElement.classList.remove('lock-scroll');
document.body.classList.remove('lock-scroll');
});
</script>
<style lang="scss">
.main {
background-color: $main_hover;
position: relative;
}
.lock-scroll {
overflow: hidden !important;
}
.demo {
&__button {
position: fixed !important;
width: fit-content !important;
bottom: 20px;
right: 20px;
padding: 10px !important;
}
}
</style>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,108 @@
/* ===== SOURCE CODE PRO ===== */
@font-face {
font-family: 'Source Code Pro';
src: url('../../fonts/SourceCodePro/SourceCodePro-Black.ttf');
font-weight: 900;
}
@font-face {
font-family: 'Source Code Pro';
src: url('../../fonts/SourceCodePro/SourceCodePro-ExtraBold.ttf');
font-weight: 800;
}
@font-face {
font-family: 'Source Code Pro';
src: url('../../fonts/SourceCodePro/SourceCodePro-Bold.ttf');
font-weight: 700;
}
@font-face {
font-family: 'Source Code Pro';
src: url('../../fonts/SourceCodePro/SourceCodePro-SemiBold.ttf');
font-weight: 600;
}
@font-face {
font-family: 'Source Code Pro';
src: url('../../fonts/SourceCodePro/SourceCodePro-Medium.ttf');
font-weight: 500;
}
@font-face {
font-family: 'Source Code Pro';
src: url('../../fonts/SourceCodePro/SourceCodePro-Regular.ttf');
font-weight: 400;
}
@font-face {
font-family: 'Source Code Pro';
src: url('../../fonts/SourceCodePro/SourceCodePro-Light.ttf');
font-weight: 300;
}
@font-face {
font-family: 'Source Code Pro';
src: url('../../fonts/SourceCodePro/SourceCodePro-ExtraLight.ttf');
font-weight: 200;
}
/* ===== PLAYFAIR DISPLAY ===== */
@font-face {
font-family: 'Playfair Display';
src: url('../../fonts/PlayfairDisplay/PlayfairDisplay-Black.ttf');
font-weight: 900;
}
@font-face {
font-family: 'Playfair Display';
src: url('../../fonts/PlayfairDisplay/PlayfairDisplay-ExtraBold.ttf');
font-weight: 800;
}
@font-face {
font-family: 'Playfair Display';
src: url('../../fonts/PlayfairDisplay/PlayfairDisplay-Bold.ttf');
font-weight: 700;
}
@font-face {
font-family: 'Playfair Display';
src: url('../../fonts/PlayfairDisplay/PlayfairDisplay-SemiBold.ttf');
font-weight: 600;
}
@font-face {
font-family: 'Playfair Display';
src: url('../../fonts/PlayfairDisplay/PlayfairDisplay-Medium.ttf');
font-weight: 500;
}
@font-face {
font-family: 'Playfair Display';
src: url('../../fonts/PlayfairDisplay/PlayfairDisplay-Regular.ttf');
font-weight: 400;
}
/* ===== INTER ===== */
@font-face {
font-family: 'Inter';
src: url('../../fonts/Inter/Inter-Black.ttf');
font-weight: 900;
}
@font-face {
font-family: 'Inter';
src: url('../../fonts/Inter/Inter-ExtraBold.ttf');
font-weight: 800;
}
@font-face {
font-family: 'Inter';
src: url('../../fonts/Inter/Inter-Bold.ttf');
font-weight: 700;
}
@font-face {
font-family: 'Inter';
src: url('../../fonts/Inter/Inter-SemiBold.ttf');
font-weight: 600;
}
@font-face {
font-family: 'Inter';
src: url('../../fonts/Inter/Inter-Medium.ttf');
font-weight: 500;
}
@font-face {
font-family: 'Inter';
src: url('../../fonts/Inter/Inter-Regular.ttf');
font-weight: 400;
}

View file

@ -0,0 +1,7 @@
@mixin hover {
@media (hover: hover) and (pointer: fine) {
&:hover {
@content;
}
}
}

View file

@ -0,0 +1,105 @@
$font_default: 'Inter', sans-serif;
$default_border_radius: 8px;
$less_border_radius: 4px;
$main: var(--main);
$main_hover: var(--main_hover);
$title_bg: var(--title_bg);
$primary: var(--primary);
$primary_hover: var(--primary_hover);
$primary_dark: var(--primary_dark);
$primary_gradient: var(--primary_gradient);
$primary_shadow: var(--primary_shadow);
$primary_shadow_hover: var(--primary_shadow_hover);
$blackout: var(--blackout);
$secondary: var(--secondary);
$secondary_hover: var(--secondary_hover);
$disabled: var(--disabled);
$disabled_secondary: var(--disabled_secondary);
$border: var(--border);
$border_hover: var(--border_hover);
$text: var(--text);
$link_primary: var(--link_primary);
$link_primary_hover: var(--link_primary_hover);
$link_secondary: var(--link_secondary);
$link_secondary_hover: var(--link_secondary_hover);
$skeleton: var(--skeleton);
$rating: var(--rating);
$success: var(--success);
$error: var(--error);
$info: var(--info);
$warning: var(--warning);
:root {
--main: #fff;
--main_hover: #f9fafb;
--title_bg: #f8f8f8;
--primary: #111827;
--primary_hover: #242c38;
--primary_dark: #1a1a1a;
--primary_gradient: linear-gradient(to bottom, rgba(26, 26, 26, 1) 0%,rgba(31, 41, 55, 1) 100%);
--primary_shadow: rgba(0, 0, 0, 0.1);
--primary_shadow_hover: rgba(0, 0, 0, 0.3);
--blackout: rgba(0, 0, 0, 0.4);
--secondary: #374151;
--secondary_hover: #29323f;
--disabled: #0a0f1a;
--disabled_secondary: #9a9a9a;
--border: #e5e7eb;
--border_hover: #505052;
--text: #4b5563;
--link_primary: #1f2937;
--link_primary_hover: #29323f;
--link_secondary: #c2c7ce;
--link_secondary_hover: #a4a8ad;
--skeleton: rgba(255, 255, 255, 0.61);
--rating: #facc15;
--success: #67c23a;
--error: #f56c6c;
--info: #909399;
--warning: #e6a23c;
}
[data-theme='dark'] {
--main: #1a1a1a;
--main_hover: #242424;
--title_bg: #111111;
--primary: #f3f4f6;
--primary_hover: #ffffff;
--primary_dark: #e5e7eb;
--primary_gradient: linear-gradient(to bottom, rgba(243, 244, 246, 1) 0%, rgba(229, 231, 235, 1) 100%);
--primary_shadow: rgba(0, 0, 0, 0.5);
--primary_shadow_hover: rgba(0, 0, 0, 0.7);
--blackout: rgba(0, 0, 0, 0.5);
--secondary: #9ca3af;
--secondary_hover: #d1d5db;
--disabled: #4b5563;
--disabled_secondary: #6b7280;
--border: #374151;
--border_hover: #4b5563;
--text: #e5e7eb;
--link_primary: #aba7a7;
--link_primary_hover: #858282;
--link_secondary: #9ca3af;
--link_secondary_hover: #cbd5e1;
--skeleton: rgba(255, 255, 255, 0.1);
--rating: #facc15;
--success: #67c23a;
--error: #f56c6c;
--info: #909399;
--warning: #e6a23c;
}

View file

@ -0,0 +1,14 @@
@use "modules/normalize";
@use "modules/transitions";
@use "global/mixins";
@use "global/variables";
// UI
@use "ui/badge";
@use "ui/collapse";
@use "ui/notification";
@use "ui/rating";
@use "ui/select";
@use "ui/slider";

View file

@ -0,0 +1,76 @@
@use "../global/variables" as *;
* {
margin: 0;
padding: 0;
border: none;
box-sizing: border-box;
transition: all 0.2s;
}
html {
overflow-x: hidden;
font-family: $font_default;
}
#app {
overflow-x: hidden;
position: relative;
}
a {
text-decoration: none;
color: inherit;
}
input, textarea, button {
font-family: $font_default;
outline: none;
background-color: transparent;
}
button:focus-visible {
outline: none;
}
.container {
max-width: 1500px;
margin-inline: auto;
}
::-webkit-scrollbar-thumb {
background: $primary;
}
::-webkit-scrollbar-track {
background: $main_hover;
}
* {
scrollbar-color: $primary $main_hover;
}
:root {
--el-color-primary: #{$primary} !important;
}
.el-skeleton__item {
--el-skeleton-color: #c9ccd0 !important;
--el-skeleton-to-color: #c3c3c7 !important;
}
.el-badge__content {
border: none !important;
}
@media (max-width: 1680px) {
.container {
max-width: 1200px;
}
}
@media (max-width: 1300px) {
.container {
width: 90%;
}
}

View file

@ -0,0 +1,28 @@
.opacity-enter-active,
.opacity-leave-active {
transition: 0.3s ease all;
}
.opacity-enter-from,
.opacity-leave-to {
opacity: 0;
}
.fromTop-enter-active,
.fromTop-leave-active {
transition: all 0.3s ease;
}
.fromTop-enter-from,
.fromTop-leave-to {
transform: translateY(-3rem);
opacity: 0;
}
.fromLeft-enter-active,
.fromLeft-leave-active {
transition: all 0.3s ease;
}
.fromLeft-enter-from,
.fromLeft-leave-to {
transform: translateX(-3rem);
opacity: 0;
}

View file

@ -0,0 +1,5 @@
@use "../global/variables" as *;
.el-badge__content {
background-color: $disabled !important;
}

View file

@ -0,0 +1,39 @@
@use "../global/variables" as *;
.el-collapse {
border: none !important;
display: flex;
flex-direction: column;
gap: 20px;
padding-block: 20px
}
.el-collapse-item {
border-radius: $default_border_radius;
border: 1px solid $border;
}
.el-collapse-item__header {
background-color: transparent !important;
border-bottom: none !important;
line-height: 100% !important;
font-size: 14px !important;
font-weight: 600 !important;
padding-inline: 8px !important;
color: $primary_dark !important;
}
.el-collapse-item__header.focusing:focus:not(:hover) {
color: $primary_dark !important;
}
.el-collapse-item__wrap {
border-top: 2px solid $border;
border-bottom: none !important;
background-color: transparent !important;
}
.el-collapse-item__content {
padding: 10px !important;
display: flex;
flex-direction: column;
gap: 5px;
}
.el-collapse .el-icon {
display: none !important;
}

View file

@ -0,0 +1,52 @@
@use "../global/variables" as *;
.el-notification {
border: 2px solid $primary !important;
transition: all 0.3s ease !important;
&__progress {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 3px;
background-color: $primary_dark;
animation: progress-animation linear forwards;
&.success {
background-color: $success;
}
&.error {
background-color: $error;
}
&.info {
background-color: $info;
}
&.warning {
background-color: $warning;
}
}
&__button {
margin-top: 10px;
padding: 6px 12px;
background-color: $primary;
border-radius: $less_border_radius;
color: $main;
border: none;
cursor: pointer;
}
.el-notification__closeBtn {
color: $primary !important;
}
}
@keyframes progress-animation {
0% {
width: 100%;
}
100% {
width: 0;
}
}

View file

@ -0,0 +1,13 @@
@use "../global/variables" as *;
.el-rate {
height: unset !important;
}
.el-rate .el-rate__icon.is-active {
color: $rating !important;
}
.el-rate .el-rate__icon {
color: $rating !important;
font-size: 16px !important;
margin-right: 0 !important;
}

View file

@ -0,0 +1,7 @@
@use "../global/variables" as *;
.el-select__wrapper {
height: 36px !important;
min-height: 36px !important;
background-color: transparent !important;
}

View file

@ -0,0 +1,17 @@
.el-slider {
width: 90% !important;
margin-inline: auto;
height: 30px !important;
}
.el-slider__bar, .el-slider__runway {
height: 4px !important;
}
.el-slider__button-wrapper {
width: 34px !important;
height: 34px !important;
}
.el-slider__button {
width: 16px !important;
height: 16px !important;
}

View file

@ -0,0 +1,63 @@
<template>
<div class="auth">
<div class="auth__content" ref="modalRef">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import {onClickOutside} from '@vueuse/core';
const appStore = useAppStore();
const closeModal = () => {
appStore.unsetActiveAuthState();
};
const modalRef = ref(null);
onClickOutside(modalRef, () => closeModal());
</script>
<style lang="scss" scoped>
.auth {
position: fixed;
z-index: 3;
width: 100vw;
height: 100vh;
top: 0;
right: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(3px);
background-color: rgba(0, 0, 0, 0.4);
&__content {
position: absolute;
z-index: 2;
top: 125px;
background-color: $main;
width: 600px;
padding: 30px;
border-radius: $less_border_radius;
}
}
@media (max-width: 1000px) {
.auth {
&__content {
width: 85%;
}
}
}
@media (max-width: 500px) {
.auth {
&__content {
padding: 20px 30px;
}
}
}
</style>

View file

@ -0,0 +1,133 @@
<template>
<footer class="footer">
<div class="container">
<div class="footer__wrapper">
<div class="footer__main">
<div class="footer__left">
<nuxt-link-locale to="/" class="footer__logo">
SCHON
</nuxt-link-locale>
<p class="footer__text">{{ t('footer.text') }}</p>
</div>
<div class="footer__columns">
<div class="footer__column">
<h6>{{ t('footer.shop') }}</h6>
<nuxt-link-locale to="/shop">{{ t('footer.allProducts') }}</nuxt-link-locale>
<nuxt-link-locale to="/catalog">{{ t('footer.catalog') }}</nuxt-link-locale>
<nuxt-link-locale to="/brands">{{ t('footer.brands') }}</nuxt-link-locale>
</div>
<div class="footer__column">
<h6>{{ t('footer.help') }}</h6>
<nuxt-link-locale to="/contact">{{ t('contact.title') }}</nuxt-link-locale>
<nuxt-link-locale to="/docs/privacy-policy">{{ t('docs.policy.title') }}</nuxt-link-locale>
<nuxt-link-locale to="/docs/terms-and-conditions">{{ t('docs.terms.title') }}</nuxt-link-locale>
<nuxt-link-locale to="/docs/shipping-information">{{ t('docs.shipping.title') }}</nuxt-link-locale>
<nuxt-link-locale to="/docs/return-policy">{{ t('docs.return.title') }}</nuxt-link-locale>
<nuxt-link-locale to="/docs/about-us">{{ t('docs.about.title') }}</nuxt-link-locale>
<nuxt-link-locale to="/docs/faq">{{ t('docs.faq.title') }}</nuxt-link-locale>
</div>
</div>
</div>
<div class="footer__bottom">
<p>© {{ actualYear }} Schon. {{ t('footer.rights') }}</p>
</div>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
const companyStore = useCompanyStore();
const { t } = useI18n();
const companyInfo = computed(() => companyStore.companyInfo);
const actualYear = computed(() => new Date().getFullYear());
const encodedCompanyAddress = computed(() => {
return companyInfo.value?.companyAddress ? encodeURIComponent(companyInfo.value?.companyAddress) : '';
});
</script>
<style scoped lang="scss">
.footer {
background-color: #000;
&__wrapper {
padding-block: 64px;
display: flex;
flex-direction: column;
gap: 50px;
}
&__main {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
&__left {
display: flex;
flex-direction: column;
gap: 20px;
}
&__logo {
color: #fff;
font-size: 24px;
font-weight: 600;
letter-spacing: 6.7px;
font-family: 'Playfair Display', sans-serif;
@include hover {
text-shadow: 0 0 5px #fff;
}
}
&__text {
max-width: 285px;
color: #d1d5db;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__columns {
display: flex;
align-items: flex-start;
gap: 150px;
}
&__column {
display: flex;
flex-direction: column;
gap: 14px;
& h6 {
color: #d2d0d0;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.5px;
}
& p, a {
color: $link_secondary;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
@include hover {
color: $link_secondary_hover;
}
}
}
&__bottom {
& p {
color: $link_secondary;
font-size: 14px;
font-weight: 400;
letter-spacing: -0.5px;
}
}
}
</style>

View file

@ -0,0 +1,264 @@
<template>
<div class="catalog" ref="blockRef">
<button
@click="setBlock(!isBlockOpen)"
class="catalog__button"
:class="[{ active: isBlockOpen }]"
>
{{ t('header.catalog.title') }}
<span></span>
</button>
<div class="container">
<div class="categories" :class="[{active: isBlockOpen}]">
<div class="categories__block" v-if="categories.length > 0">
<div class="categories__left">
<p
v-for="category in categories"
:key="category.node.uuid"
:class="[{ active: category.node.uuid === activeCategory.uuid }]"
@click="setActiveCategory(category.node)"
>
{{ category.node.name }}
</p>
</div>
<div class="categories__main">
<div
class="categories__main-block"
v-for="mainChildren in activeCategory.children"
:key="mainChildren.uuid"
>
<nuxt-link-locale
:to="`/catalog/${mainChildren.slug}`"
class="categories__main-link"
@click="setBlock(false)"
>
{{ mainChildren.name }}
</nuxt-link-locale>
<div class="categories__main-list">
<nuxt-link-locale
v-for="children in mainChildren.children"
:key="children.uuid"
:to="`/catalog/${children.slug}`"
@click="setBlock(false)"
>
{{ children.name }}
</nuxt-link-locale >
</div>
</div>
</div>
</div>
<div class="categories__empty" v-else><p>{{ t('header.catalog.empty') }}</p></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {ICategory} from '@types';
import {onClickOutside} from '@vueuse/core';
const { t } = useI18n();
const categoryStore = useCategoryStore();
const categories = computed(() => categoryStore.categories);
const isBlockOpen = ref<boolean>(false);
const setBlock = (state: boolean) => {
isBlockOpen.value = state;
};
// TODO: add loading state
const blockRef = ref(null);
onClickOutside(blockRef, () => setBlock(false));
const activeCategory = ref<ICategory>(categories.value[0]?.node);
const setActiveCategory = (category: ICategory) => {
activeCategory.value = category;
};
</script>
<style lang="scss" scoped>
.catalog {
&__button {
cursor: pointer;
border-radius: $less_border_radius;
background-color: rgba($primary, 0.2);
border: 1px solid $primary;
padding: 5px 20px;
display: flex;
align-items: center;
gap: 10px;
color: $primary;
font-size: 16px;
font-weight: 600;
@include hover {
background-color: $primary;
color: $main;
}
& span {
font-size: 26px;
}
&.active {
background-color: $primary;
color: $main;
& span {
transform: rotate(-180deg);
}
}
}
}
.container {
position: absolute;
left: 50%;
top: 110%;
transform: translateX(-50%);
width: 100%;
}
.categories {
border-radius: $less_border_radius;
width: 100%;
background-color: $main;
box-shadow: 0 0 15px 1px $secondary;
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
overflow: hidden;
&.active {
grid-template-rows: 1fr;
}
& > * {
min-height: 0;
}
&__block {
display: grid;
grid-template-columns: 20% 80%;
max-height: 50vh;
}
&__columns {
& div {
padding: 20px 50px;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-column-gap: 30px;
grid-row-gap: 5px;
& p {
cursor: pointer;
padding: 5px 20px;
font-size: 16px;
font-weight: 600;
@include hover {
color: $primary;
}
}
}
}
&__left {
flex-shrink: 0;
padding-block: 10px;
overflow: auto;
& p {
cursor: pointer;
flex-shrink: 0;
padding: 10px;
border-left: 3px solid $main;
font-weight: 700;
@include hover {
color: $primary;
}
&.active {
border-color: $primary;
color: $primary;
}
}
}
&__main {
padding: 20px;
border-left: 2px solid $primary_dark;
overflow: auto;
&-block {
padding-bottom: 15px;
border-bottom: 1px solid #eeeeee;
margin-bottom: 15px;
&:last-child {
border-bottom: none;
margin-bottom: 0;
}
}
&-link {
position: relative;
width: fit-content;
cursor: pointer;
font-weight: 600;
font-size: 16px;
&::after {
content: "";
position: absolute;
bottom: -2px;
left: 0;
height: 2px;
width: 0;
transition: all .3s ease;
background-color: $primary;
}
@include hover {
&::after {
width: 100%;
}
}
}
&-list {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(3,1fr);
grid-column-gap: 30px;
grid-row-gap: 5px;
& a {
cursor: pointer;
font-size: 14px;
@include hover {
color: $primary;
}
}
}
}
&__empty {
& p {
padding: 20px;
text-align: center;
font-size: 16px;
font-weight: 600;
}
}
}
</style>

View file

@ -0,0 +1,362 @@
<template>
<header
class="header"
:class="[{
'header__no-search': !uiConfig.showSearchBar,
'header__fixed': uiConfig.isHeaderFixed
}]"
>
<div class="container">
<div class="header__wrapper">
<div class="header__inner">
<nuxt-link-locale to="/" class="header__logo">
SCHON
</nuxt-link-locale>
</div>
<div class="header__inner">
<nav class="header__nav">
<nuxt-link-locale
to="/shop"
class="header__nav-item"
:class="[{ active: route.name?.includes('shop') }]"
>
{{ t('header.nav.shop') }}
</nuxt-link-locale>
<nuxt-link-locale
to="/catalog"
class="header__nav-item"
:class="[{ active: route.name?.includes('catalog') }]"
>
{{ t('header.nav.catalog') }}
</nuxt-link-locale>
<nuxt-link-locale
to="/brands"
class="header__nav-item"
:class="[{ active: route.name?.includes('brands') }]"
>
{{ t('header.nav.brands') }}
</nuxt-link-locale>
<nuxt-link-locale
to="/blog"
class="header__nav-item"
:class="[{ active: route.name?.includes('blog') }]"
>
{{ t('header.nav.blog') }}
</nuxt-link-locale>
<nuxt-link-locale
to="/contact"
class="header__nav-item"
:class="[{ active: route.name?.includes('contact') }]"
>
{{ t('header.nav.contact') }}
</nuxt-link-locale>
</nav>
</div>
<div class="header__inner">
<div class="header__block">
<icon
v-if="uiConfig.showSearchBar"
@click="isSearchVisible = true"
class="header__block-search"
name="tabler:search"
size="20"
/>
<ui-language-switcher />
<ui-theme-toggle />
<el-badge :value="productsInWishlistQuantity">
<nuxt-link-locale to="/wishlist">
<icon class="header__block-wishlist" name="material-symbols:favorite-rounded" size="20" />
</nuxt-link-locale>
</el-badge>
<el-badge :value="productsInCartQuantity">
<nuxt-link-locale to="/cart">
<icon class="header__block-cart" name="bx:bxs-shopping-bag" size="20" />
</nuxt-link-locale>
</el-badge>
<nuxt-link-locale
to="/profile/settings"
class="header__block-item"
v-if="isAuthenticated"
>
<nuxt-img
class="header__block-avatar"
v-if="user?.avatar"
:src="user?.avatar"
alt="avatar"
format="webp"
densities="x1"
/>
<div class="header__block-profile" v-else>
<icon name="clarity:avatar-line" size="16" />
</div>
</nuxt-link-locale>
<nuxt-link-locale
to="/auth/sign-in"
class="header__block-auth"
v-else
>
<p>{{ t('buttons.login') }}</p>
</nuxt-link-locale>
</div>
</div>
</div>
</div>
<div class="header__search" :class="[{ active: isSearchVisible && uiConfig.showSearchBar }]">
<ui-search
ref="searchRef"
/>
</div>
</header>
</template>
<script setup lang="ts">
import { useProjectConfig } from "@composables/config";
import {onClickOutside} from "@vueuse/core";
const { t } = useI18n();
const localePath = useLocalePath();
const route = useRoute();
const appStore = useAppStore();
const userStore = useUserStore();
const wishlistStore = useWishlistStore();
const cartStore = useCartStore();
const { $appHelpers } = useNuxtApp();
const { uiConfig } = useProjectConfig();
const isAuthenticated = computed(() => userStore.isAuthenticated);
const user = computed(() => userStore.user);
const cookieWishlist = useCookie($appHelpers.COOKIES_WISHLIST_KEY, {
default: () => [],
path: '/',
});
const cookieCart = useCookie($appHelpers.COOKIES_CART_KEY, {
default: () => [],
path: '/',
});
const productsInCartQuantity = computed(() => {
if (isAuthenticated.value) {
let count = 0;
cartStore.currentOrder?.orderProducts?.edges.forEach((el) => {
count = count + el.node.quantity;
});
return count;
} else {
return cookieCart.value.reduce((acc, item) => acc + item.quantity, 0);
}
});
const productsInWishlistQuantity = computed(() => {
if (isAuthenticated.value) {
return wishlistStore.wishlist ? wishlistStore.wishlist.products.edges.length : 0;
} else {
return cookieWishlist.value.length
}
});
const isSearchVisible = ref<boolean>(false);
const searchRef = ref(null);
onClickOutside(searchRef, () => isSearchVisible.value = false);
const redirectTo = (to) => {
if (uiConfig.value.isAuthModals) {
appStore.setActiveAuthState(to);
} else {
navigateTo(localePath(`/auth/ + ${to}`));
}
};
</script>
<style lang="scss" scoped>
.header {
position: relative;
z-index: 5;
top: 0;
left: 0;
width: 100vw;
background-color: $main;
border-bottom: 1px solid $border;
&__fixed {
position: fixed;
}
&__wrapper {
display: flex;
align-items: center;
justify-content: space-between;
gap: 25px;
padding-block: 25px;
background-color: $main;
}
&__inner {
width: 33%;
display: flex;
align-items: center;
justify-content: center;
&:first-child {
justify-content: flex-start;
}
&:last-child {
justify-content: flex-end;
}
}
&__logo {
font-size: 24px;
font-weight: 600;
letter-spacing: 6.7px;
font-family: 'Playfair Display', sans-serif;
color: $primary_dark;
@include hover {
text-shadow: 0 0 5px $primary_dark;
}
}
&__nav {
display: flex;
align-items: center;
gap: 40px;
&-item {
position: relative;
color: $link_primary;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.5px;
&::after {
content: "";
position: absolute;
bottom: -3px;
left: 0;
height: 2px;
width: 0;
transition: all .3s ease;
background-color: $link_primary;
}
&.active::after {
width: 100%;
}
@include hover {
&::after {
width: 100%;
}
}
}
}
&__block {
display: flex;
align-items: center;
gap: 16px;
&-block {
display: flex;
align-items: center;
gap: 15px;
}
&-search {
cursor: pointer;
display: block;
color: $secondary;
@include hover {
color: $secondary_hover;
}
}
&-wishlist {
display: block;
cursor: pointer;
color: $secondary;
@include hover {
color: $secondary_hover;
}
}
&-cart {
display: block;
cursor: pointer;
color: $secondary;
@include hover {
color: $secondary_hover;
}
}
&-auth {
border-bottom: 1px solid $secondary;
padding-bottom: 2px;
color: $secondary;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.5px;
@include hover {
color: $secondary_hover;
}
}
&-avatar {
width: 28px;
border-radius: 50%;
border: 1px solid $secondary;
& span {
display: block;
}
@include hover {
opacity: 0.7;
}
}
&-profile {
width: 28px;
border-radius: 50%;
padding: 5px;
border: 1px solid $secondary;
& span {
color: $primary;
display: block;
}
@include hover {
opacity: 0.7;
}
}
}
&__search {
position: relative;
width: 100%;
top: 100%;
left: 0;
display: grid;
grid-template-rows: 0fr;
//transition: grid-template-rows 0.2s ease;
opacity: 0;
visibility: hidden;
&.active {
grid-template-rows: 1fr;
opacity: 1;
visibility: visible;
}
& > * {
min-height: 0;
}
}
}
</style>

View file

@ -0,0 +1,69 @@
<template>
<nuxt-link-locale
class="card"
:to="`/brand/${brand.slug}`"
>
<nuxt-img
v-if="brand.smallLogo"
format="webp"
densities="x1"
:src="brand.smallLogo"
:alt="brand.name"
class="card__image"
loading="lazy"
/>
<div class="card__image-placeholder" v-else />
<p>{{ brand.name }}</p>
</nuxt-link-locale>
</template>
<script setup lang="ts">
import type {IBrand} from '@types';
const props = defineProps<{
brand: IBrand;
}>();
</script>
<style lang="scss" scoped>
.card {
cursor: pointer;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
background-color: $main;
border-radius: $default_border_radius;
border: 1px solid $border;
height: 100%;
padding: 23px;
@include hover {
box-shadow: 0 0 10px 2px $primary_shadow_hover;
border-color: $border_hover;
}
&__image {
width: 100%;
aspect-ratio: 1;
object-fit: contain;
object-position: center;
&-placeholder {
width: 100%;
aspect-ratio: 1;
background-color: $primary;
border-radius: $less_border_radius;
}
}
& p {
color: $primary_dark;
text-align: center;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.5px;
}
}
</style>

View file

@ -0,0 +1,69 @@
<template>
<nuxt-link-locale
class="card"
:to="`/catalog/${category.slug}`"
>
<nuxt-img
v-if="category.image"
format="webp"
densities="x1"
:src="category.image"
:alt="category.name"
class="card__image"
loading="lazy"
/>
<div class="card__image-placeholder" v-else />
<p>{{ category.name }}</p>
</nuxt-link-locale>
</template>
<script setup lang="ts">
import type {ICategory} from '@types';
const props = defineProps<{
category: ICategory;
}>();
</script>
<style lang="scss" scoped>
.card {
cursor: pointer;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
background-color: $main;
border-radius: $default_border_radius;
border: 1px solid $border;
height: 100%;
padding: 23px;
@include hover {
box-shadow: 0 0 10px 2px $primary_shadow_hover;
border-color: $border_hover;
}
&__image {
width: 100%;
aspect-ratio: 1;
object-fit: contain;
object-position: center;
&-placeholder {
width: 100%;
aspect-ratio: 1;
background-color: $primary;
border-radius: $less_border_radius;
}
}
& p {
color: $primary_dark;
text-align: center;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.5px;
}
}
</style>

View file

@ -0,0 +1,258 @@
<template>
<el-collapse-item
class="order"
>
<template #title="{ isActive }">
<div :class="['order__top', { 'is-active': isActive }]">
<div>
<p>{{ t('profile.orders.id') }}: {{ order.humanReadableId }}</p>
<p v-if="order.buyTime">{{ useDate(order.buyTime, locale) }}</p>
<el-tooltip
:content="order.status"
placement="top"
>
<p class="status" :style="[{ backgroundColor: statusColor(order.status) }]">
{{ order.status }}
<icon name="material-symbols:info-outline-rounded" size="14" />
</p>
</el-tooltip>
</div>
<icon
name="material-symbols:keyboard-arrow-down"
size="22"
class="order__top-icon"
:class="[{ active: isActive }]"
/>
</div>
<div class="order__top-bottom" :class="{ active: !isActive }">
<div>
<div>
<div>
<nuxt-img
v-for="product in order.orderProducts.edges"
:key="product.node.uuid"
:src="product.node.product.images.edges[0].node.image"
:alt="product.node.product.name"
format="webp"
densities="x1"
/>
</div>
<p>{{ order.totalPrice }}{{ CURRENCY }}</p>
</div>
</div>
</div>
</template>
<div class="order__main">
<div
class="order__product"
v-for="product in order.orderProducts.edges"
:key="product.node.uuid"
>
<div class="order__product-left">
<nuxt-img
:src="product.node.product.images.edges[0].node.image"
:alt="product.node.product.name"
format="webp"
densities="x1"
/>
<p>{{ product.node.product.name }}</p>
</div>
<div class="order__product-right">
<h6>{{ t('profile.orders.price') }}: {{ product.node.product.price * product.node.quantity }}{{ CURRENCY }}</h6>
<p>{{ product.node.quantity }} X {{ product.node.product.price }}$</p>
</div>
</div>
<div class="order__total">
<p>{{ t('profile.orders.total') }}: {{ order.totalPrice }}$</p>
</div>
</div>
</el-collapse-item>
</template>
<script setup lang="ts">
import {useDate} from '@composables/date';
import {orderStatuses, CURRENCY} from '@appConstants';
import type {IOrder} from '@types';
const props = defineProps<{
order: IOrder;
}>();
const {t, locale} = useI18n();
const statusColor = (status: string) => {
switch (status) {
case orderStatuses.FAILED:
return '#FF0000';
case orderStatuses.PAYMENT:
return '#FFC107';
case orderStatuses.CREATED:
return '#007BFF';
case orderStatuses.DELIVERING:
return '#00C853';
case orderStatuses.FINISHED:
return '#00C853';
case orderStatuses.MOMENTAL:
return '#00C853';
default:
return '#000';
}
};
</script>
<style scoped lang="scss">
.order {
&__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 0 10px 5px 10px;
& div {
display: flex;
align-items: center;
gap: 25px;
& p {
display: flex;
align-items: center;
gap: 5px;
color: $secondary;
font-size: 14px;
font-weight: 400;
&.status {
border-radius: $less_border_radius;
padding: 3px 7px;
}
}
}
&-icon {
color: $secondary;
&.active {
transform: rotate(-180deg);
}
}
&-bottom {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
overflow: hidden;
&.active {
grid-template-rows: 1fr;
}
& > * {
min-height: 0;
}
& div {
& div {
padding-top: 10px;
border-top: 1px solid $border;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 25px;
& div {
padding-top: 0;
border: none;
display: flex;
flex-wrap: wrap;
gap: 10px;
& img {
height: 65px;
border-radius: $less_border_radius;
}
}
& p {
color: $secondary;
font-size: 18px;
font-weight: 600;
}
}
}
}
}
&__main {
display: flex;
flex-direction: column;
gap: 20px;
}
&__product {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding-bottom: 15px;
border-bottom: 2px solid $border;
&-left {
display: flex;
align-items: flex-start;
gap: 10px;
& img {
height: 150px;
border-radius: $less_border_radius;
}
& p {
color: $secondary;
font-size: 18px;
font-weight: 600;
}
}
&-right {
display: flex;
flex-direction: column;
align-items: flex-end;
& h6 {
color: $secondary;
font-size: 18px;
font-weight: 600;
}
& p {
color: $secondary;
font-size: 14px;
font-weight: 400;
}
}
}
&__total {
display: flex;
align-items: flex-end;
justify-content: flex-end;
& p {
font-size: 20px;
font-weight: 600;
}
}
}
:deep(.el-collapse-item__header) {
height: fit-content;
padding-block: 10px;
}
</style>

View file

@ -0,0 +1,67 @@
<template>
<div class="card">
<p class="card__title">{{ post.title }}</p>
<nuxt-link-locale :to="`/blog/${post.slug}`" class="card__button">{{ t('buttons.readMore') }}</nuxt-link-locale>
</div>
</template>
<script setup lang="ts">
import type {IPost} from '@types';
const props = defineProps<{
post: IPost;
}>();
const {t} = useI18n();
</script>
<style lang="scss" scoped>
.card {
display: flex;
flex-direction: column;
gap: 10px;
&__title {
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
color: $primary_dark;
font-family: "Playfair Display", sans-serif;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.5px;
}
&__button {
width: fit-content;
position: relative;
color: $primary_dark;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.5px;
&::after {
content: "";
position: absolute;
bottom: -3px;
left: 0;
height: 2px;
width: 0;
transition: all .3s ease;
background-color: $primary_dark;
}
&.active::after {
width: 100%;
}
@include hover {
&::after {
width: 100%;
}
}
}
}
</style>

View file

@ -0,0 +1,518 @@
<template>
<div
class="card"
:class="{ 'card__list': isList }"
>
<div
class="card__wishlist"
@click="overwriteWishlist({
type: (isProductInWishlist ? 'remove' : 'add'),
productUuid: product.uuid,
productName: product.name,
})"
>
<icon style="color: #dc2626;" name="mdi:cards-heart" size="16" v-if="isProductInWishlist" />
<icon name="mdi:cards-heart-outline" size="16" v-else />
</div>
<div class="card__wrapper">
<nuxt-link-locale
:to="`/product/${product.slug}`"
class="card__link"
>
<div class="card__block">
<client-only>
<swiper
v-if="images.length"
@swiper="onSwiper"
:modules="[EffectFade, Pagination]"
effect="fade"
:slides-per-view="1"
:pagination="paginationOptions"
class="card__swiper"
>
<swiper-slide
v-for="(img, i) in images"
:key="i"
class="card__swiper-slide"
>
<nuxt-img
:src="img"
:alt="product.name"
loading="lazy"
class="card__swiper-image"
format="webp"
densities="x1"
/>
</swiper-slide>
</swiper>
<div class="card__image-placeholder" />
<div
v-for="(image, idx) in images"
:key="idx"
class="card__block-hover"
:style="{ left: `${(100/ images.length) * idx}%`, width: `${100/ images.length}%` }"
@mouseenter="goTo(idx)"
@mouseleave="goTo(0)"
/>
</client-only>
</div>
</nuxt-link-locale>
</div>
<div class="card__content">
<div class="card__content-inner">
<div class="card__brand">{{ product.brand.name }}</div>
<p class="card__name">{{ product.name }}</p>
<el-rate
class="card__rating"
v-model="rating"
size="large"
disabled
/>
<div class="card__price">{{ product.price }} $</div>
</div>
<div class="card__bottom">
<div class="card__bottom-inner">
<div class="tools" v-if="isProductInCart">
<button
class="tools__item tools__item-button"
@click="overwriteOrder({
type: 'remove',
productUuid: product.uuid,
productName: product.name
})"
>
-
</button>
<span class="tools__item tools__item-count" v-text="'X' + productInCartQuantity" />
<button
class="tools__item tools__item-button"
@click="overwriteOrder({
type: 'add',
productUuid: product.uuid,
productName: product.name
})"
>
+
</button>
</div>
<ui-button
v-else
class="card__bottom-button"
@click="overwriteOrder({
type: 'add',
productUuid: product.uuid,
productName: product.name
})"
:type="'button'"
:isLoading="addLoading"
>
{{ t('buttons.addToCart') }}
</ui-button>
<ui-button
:type="'button'"
class="card__bottom-button"
:style="'secondary'"
@click="buyProduct(product.uuid)"
:isLoading="buyLoading"
>
{{ t('buttons.buyNow') }}
</ui-button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {IProduct} from '@types';
import { Swiper, SwiperSlide } from 'swiper/vue';
import { EffectFade, Pagination } from 'swiper/modules';
import {useWishlistOverwrite} from '@composables/wishlist';
import {useOrderOverwrite} from '@composables/orders';
import {useProductBuy} from "@composables/products";
const props = defineProps<{
product: IProduct;
isList?: boolean;
}>();
const {t} = useI18n();
const wishlistStore = useWishlistStore();
const cartStore = useCartStore();
const userStore = useUserStore();
const { $appHelpers } = useNuxtApp();
const { overwriteWishlist } = useWishlistOverwrite();
const { addLoading, removeLoading, overwriteOrder } = useOrderOverwrite();
const { buyProduct, loading: buyLoading } = useProductBuy();
const cookieWishlist = useCookie($appHelpers.COOKIES_WISHLIST_KEY, {
default: () => [],
path: '/',
});
const cookieCart = useCookie($appHelpers.COOKIES_CART_KEY, {
default: () => [],
path: '/',
});
const isAuthenticated = computed(() => userStore.isAuthenticated);
const isProductInWishlist = computed(() => {
if (isAuthenticated.value) {
return !!wishlistStore.wishlist?.products?.edges.find(
(el) => el?.node?.uuid === props.product.uuid
);
} else {
return (cookieWishlist.value ?? []).includes(props.product.uuid);
}
});
const isProductInCart = computed(() => {
if (isAuthenticated.value) {
return !!cartStore.currentOrder?.orderProducts?.edges.find(
(prod) => prod.node.product.uuid === props.product?.uuid
);
} else {
return (cookieCart.value ?? []).some(
(item) => item.productUuid === props.product?.uuid
);
}
});
const productInCartQuantity = computed(() => {
if (isAuthenticated.value) {
const productEdge = cartStore.currentOrder?.orderProducts?.edges.find(
(prod) => prod.node.product.uuid === props.product.uuid
);
return productEdge?.node.quantity ?? 0;
} else {
const cartItem = (cookieCart.value ?? []).find(
(item) => item.productUuid === props.product.uuid
);
return cartItem?.quantity ?? 0;
}
});
const rating = computed(() => {
return props.product.feedbacks.edges[0]?.node?.rating ?? 5;
});
const images = computed(() =>
props.product.images.edges.map(e => e.node.image)
);
const paginationOptions = computed(() =>
images.value.length > 1
? {
clickable: true,
bulletClass: 'swiper-pagination-line',
bulletActiveClass: 'swiper-pagination-line--active'
}
: false
);
const swiperRef = ref<any>(null);
function onSwiper(swiper: any) {
swiperRef.value = swiper;
}
function goTo(index: number) {
swiperRef.value?.slideTo(index);
}
</script>
<style lang="scss" scoped>
.card {
border-radius: $default_border_radius;
border: 1px solid $border;
width: 100%;
background-color: $main;
position: relative;
height: 100%;
display: flex;
flex-direction: column;
&__list {
flex-direction: row;
align-items: stretch;
gap: 50px;
padding: 15px;
& .card__link {
width: fit-content;
padding: 0;
}
& .card__block {
width: 150px;
height: 150px;
}
& .card__content {
width: 100%;
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
padding: 0;
&-inner {
align-self: flex-start;
}
}
& .tools {
width: 136px;
}
& .card__bottom {
margin-top: 0;
width: fit-content;
flex-shrink: 0;
padding: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
&-inner {
flex-direction: column;
align-items: flex-end;
gap: 10px;
}
&-button {
width: fit-content;
padding-inline: 25px;
}
}
& .card__wrapper {
flex-direction: row;
}
}
@include hover {
border-color: $border_hover;
box-shadow: 0 0 10px 1px $border;
}
&__wrapper {
display: flex;
flex-direction: column;
justify-content: space-between;
}
&__link {
display: block;
width: 100%;
}
&__block {
position: relative;
width: 100%;
height: 200px;
overflow: hidden;
&-hover {
position: absolute;
top: 0;
left: 0;
width: 20%;
height: 100%;
z-index: 2;
cursor: pointer;
background: transparent;
}
}
&__swiper {
width: 100%;
height: 100%;
position: relative;
z-index: 1;
padding-bottom: 10px;
&-image {
width: 100%;
height: 100%;
object-fit: contain;
}
}
&__image {
&-placeholder {
width: 100%;
height: 200px;
background-color: $primary;
}
}
&__content {
padding: 10px 20px 20px 20px;
display: flex;
flex-direction: column;
gap: 15px;
&-inner {
display: flex;
flex-direction: column;
gap: 4px;
}
}
&__brand {
font-size: 12px;
font-weight: 400;
letter-spacing: -0.2px;
color: $text;
}
&__price {
font-weight: 600;
font-size: 16px;
letter-spacing: -0.5px;
color: $primary_dark;
}
&__rating {
margin-block: 4px 10px;
}
&__name {
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
color: $primary_dark;
font-weight: 500;
font-size: 14px;
letter-spacing: -0.5px;
}
&__quantity {
width: fit-content;
background-color: rgba($secondary, 0.5);
border-radius: $less_border_radius;
padding: 5px 10px;
font-size: 14px;
}
&__wishlist {
cursor: pointer;
width: fit-content;
background-color: $main;
box-shadow: 0 2px 4px 0 $primary_shadow;
position: absolute;
top: 16px;
right: 16px;
z-index: 3;
border-radius: 50%;
padding: 12px;
@include hover {
box-shadow: 0 2px 4px 0 $primary_shadow_hover;
}
& span {
color: $secondary;
display: block;
}
}
&__bottom {
&-inner {
display: flex;
flex-direction: column;
gap: 10px;
}
&-button {
padding-block: 10px !important;
border-radius: $less_border_radius;
}
&-wishlist {
cursor: pointer;
width: 34px;
height: 34px;
flex-shrink: 0;
background-color: $primary;
border-radius: $less_border_radius;
display: grid;
place-items: center;
font-size: 22px;
color: $main;
@include hover {
background-color: $primary_hover;
}
}
}
}
.tools {
width: 100%;
border-radius: $less_border_radius;
background-color: $primary;
display: grid;
grid-template-columns: 1fr 2fr 1fr;
height: 40px;
&__item {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
&-count {
border-left: 1px solid $primary_hover;
border-right: 1px solid $primary_hover;
color: $main;
font-size: 16px;
font-weight: 600;
}
&-button {
cursor: pointer;
background-color: $primary;
border-radius: $less_border_radius 0 0 $less_border_radius;
color: $main;
font-size: 18px;
font-weight: 500;
@include hover {
background-color: $primary_hover;
color: $main;
}
&:last-child {
border-radius: 0 $less_border_radius $less_border_radius 0;
}
}
}
}
:deep(.swiper-pagination) {
bottom: 0;
display: flex;
justify-content: center;
gap: 6px;
}
:deep(.swiper-pagination-line) {
display: inline-block;
width: 24px;
height: 2px;
background-color: rgba($primary_dark, 0.3);
border-radius: 0;
opacity: 1;
}
:deep(.swiper-pagination-line--active) {
background-color: $primary_dark;
}
</style>

View file

@ -0,0 +1,317 @@
<template>
<div v-if="isDemoMode && isOpen" class="modal">
<div
class="modal__wrapper"
>
<demo-ui-button
@click="appStore.setDemoSettings(false)"
class="modal__close"
>
<Icon name="material-symbols:close" size="30" />
</demo-ui-button>
<h2 class="modal__title">{{ toConstantCase(t('demo.settings.title')) }}</h2>
<div class="modal__inner">
<div class="modal__block">
<h3 class="modal__block-title">{{ toConstantCase(t('demo.settings.ui')) }}</h3>
<div
v-for="(flag, idx) in availableFlags"
:key="flag.key"
class="modal__block-item"
>
<demo-ui-checkbox
:label="toConstantCase(flag.label)"
v-model="localFlags[flag.key]"
:id="idx"
/>
<p>{{ flag.description }}</p>
</div>
</div>
<demo-ui-button @click="showCodePreview = !showCodePreview">{{ toConstantCase(t('demo.buttons.generateCode')) }}</demo-ui-button>
<div v-if="showCodePreview" class="modal__preview">
<div class="modal__preview-wrapper">
<pre class="modal__preview-code"><code class="language-typescript">{{ codePreview }}</code></pre>
<demo-ui-button
@click="copyConfig"
class="modal__preview-button"
>
<Icon name="material-symbols:content-copy" size="16" />
</demo-ui-button>
</div>
<p class="modal__preview-text">{{ t('demo.preview.text') }}</p>
</div>
</div>
<div class="modal__buttons">
<demo-ui-button @click="resetToDefault">
<Icon name="material-symbols:refresh" size="20" />
{{ toConstantCase(t('demo.buttons.reset')) }}
</demo-ui-button>
<demo-ui-button @click="saveChanges">
<Icon name="material-symbols:save" size="20" />
{{ toConstantCase(t('demo.buttons.save')) }}
</demo-ui-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useProjectConfig } from '@composables/config';
const appStore = useAppStore();
const {t} = useI18n();
const isOpen = computed(() => appStore.isDemoSettings);
const {
isDemoMode,
availableFlags,
setDemoFlag,
resetDemoFlags,
uiConfig,
generateConfigCode,
copyToClipboard
} = useProjectConfig();
const showCodePreview = ref<boolean>(false);
const localFlags = ref<Record<string, boolean>>({});
const previewConfig = computed(() => {
const config: Record<string, boolean> = {};
availableFlags.value.forEach(flag => {
config[flag.key] = localFlags.value[flag.key] !== undefined
? localFlags.value[flag.key]
: flag.value;
});
return config;
});
const formatPreviewConfig = (config: Record<string, boolean>): string => {
const entries = Object.entries(config)
.map(([key, value]) => ` ${key}: ${value}`)
.join(',\n');
return `ui: {\n${entries}\n}`;
};
function toConstantCase(text: string): string {
const placeholders: string[] = [];
let placeholderIndex = 0;
const textWithPlaceholders = text.replace(/(['"])(.*?)\1/g, (match) => {
placeholders.push(match);
return `__QUOTE_PLACEHOLDER_${placeholderIndex++}__`;
});
let result = textWithPlaceholders
.toUpperCase()
.replace(/\s+/g, '_')
.replace(/[^A-Z0-9_]/g, '');
placeholders.forEach((placeholder, index) => {
result = result.replace(`__QUOTE_PLACEHOLDER_${index}__`, placeholder);
});
return result;
}
watch(isOpen, (newVal) => {
if (newVal) {
const flags: Record<string, boolean> = {};
availableFlags.value.forEach(flag => {
flags[flag.key] = flag.value;
});
localFlags.value = flags;
}
}, { immediate: true });
const saveChanges = () => {
availableFlags.value.forEach(flag => {
const newValue = localFlags.value[flag.key];
const oldValue = flag.value;
if (newValue !== oldValue) {
setDemoFlag(flag.key, newValue);
}
});
appStore.setDemoSettings(false);
};
const resetToDefault = () => {
resetDemoFlags();
appStore.setDemoSettings(false);
};
const copyConfig = async () => {
const code = codePreview.value;
await copyToClipboard(code);
};
const codePreview = computed(() => {
return formatPreviewConfig(previewConfig.value);
});
</script>
<style scoped lang="scss">
.modal {
position: fixed;
z-index: 3;
width: 100vw;
height: 100vh;
top: 0;
right: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(3px);
&__wrapper {
position: absolute;
z-index: 2;
inset: 100px;
background-color: $primary_dark;
padding: 50px;
border-radius: $less_border_radius;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 50px;
}
&__close {
position: absolute;
z-index: 1;
top: 15px;
right: 15px;
padding: 7px !important;
}
&__title {
text-align: center;
font-size: 40px;
font-weight: 500;
text-shadow: 0 0 5px $primary_shadow;
text-transform: uppercase;
color: $text;
}
&__inner {
height: 100%;
width: 100%;
overflow: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 50px;
padding: 10px 20px;
scrollbar-color: $text transparent;
}
&__block {
align-self: flex-start;
display: flex;
flex-direction: column;
gap: 15px;
&-title {
width: fit-content;
margin-bottom: 20px;
padding-bottom: 5px;
border-bottom: 2px solid $text;
color: $text;
font-weight: 500;
text-shadow: 0 0 5px $text;
text-transform: uppercase;
font-size: 22px;
}
&-item {
display: flex;
flex-direction: column;
gap: 5px;
& p {
padding-left: 50px;
color: $text;
font-weight: 500;
font-size: 16px;
}
}
}
&__preview {
width: 100%;
display: flex;
flex-direction: column;
gap: 20px;
&-wrapper {
position: relative;
background-color: $primary_dark;
border-radius: $less_border_radius;
padding: 20px;
border: 1px solid rgba($text, 0.3);
}
&-code {
margin: 0;
color: $main_hover;
font-family: 'Fira Code', 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
main-space: pre-wrap;
overflow-x: auto;
.language-typescript {
color: #f8f8f2;
.keyword {
color: lch(69.03% 60.03 345.46);
}
.string {
color: #f1fa8c;
}
.number {
color: #bd93f9;
}
.boolean {
color: #ff79c6;
}
.property {
color: #8be9fd;
}
}
}
&-button {
position: absolute;
top: 10px;
right: 10px;
padding: 6px 12px !important;
font-size: 12px !important;
gap: 6px !important;
}
&-text {
color: $text;
font-weight: 500;
font-size: 14px;
text-align: center;
}
}
&__buttons {
border-top: 1px solid $main;
padding-top: 25px;
display: flex;
align-items: center;
justify-content: center;
gap: 25px;
}
}
</style>

View file

@ -0,0 +1,68 @@
<template>
<button
type="button"
class="button"
:disabled="isDisabled"
:class="[{active: isLoading}]"
>
<ui-loader class="button__loader" v-if="isLoading" />
<slot v-else />
</button>
</template>
<script setup lang="ts">
const props = defineProps<{
isDisabled?: boolean;
isLoading?: boolean;
}>();
</script>
<style lang="scss" scoped>
.button {
position: relative;
width: fit-content;
cursor: pointer;
flex-shrink: 0;
border: 1px solid $text;
box-shadow: 0 0 10px 1px $text;
background-color: transparent;
border-radius: $less_border_radius;
padding: 7px 15px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
z-index: 1;
color: $text;
text-shadow: 0 0 10px $text;
text-align: center;
font-size: 16px;
font-weight: 500;
@include hover {
background-color: $text;
color: $main;
}
&.active {
background-color: $text;
color: $main;
}
&:disabled {
cursor: not-allowed;
background-color: transparent;
color: $main;
}
&:disabled:hover, &.active {
background-color: transparent;
color: $main;
}
&__loader {
margin-block: 4px;
}
}
</style>

View file

@ -0,0 +1,187 @@
<template>
<label class="checkbox">
<input
:id="id"
type="checkbox"
:checked="modelValue"
@change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
/>
<span class="checkbox__box">
<span class="checkbox__mark"></span>
</span>
<span
class="checkbox__label"
:data-text="label"
>
{{ label }}
</span>
</label>
</template>
<script setup lang="ts">
const props = defineProps<{
label: string;
modelValue: boolean;
id: number;
}>();
defineEmits<{
'update:modelValue': [value: boolean]
}>();
</script>
<style lang="scss" scoped>
.checkbox {
width: fit-content;
display: flex;
align-items: center;
gap: 15px;
cursor: pointer;
user-select: none;
position: relative;
--glitch-anim-duration: 0.3s;
& input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
&__box {
width: 1.5em;
height: 1.5em;
border: 2px solid $text;
position: relative;
transition: all 0.3s ease;
clip-path: polygon(
15% 0,
85% 0,
100% 15%,
100% 85%,
85% 100%,
15% 100%,
0 85%,
0 15%
);
}
&__mark {
position: absolute;
top: 50%;
left: 50%;
width: 60%;
height: 60%;
background-color: $text;
transform: translate(-50%, -50%) scale(0);
opacity: 0;
transition: all 0.3s cubic-bezier(0.18, 0.89, 0.32, 1.28);
clip-path: inherit;
}
&__label {
color: $main;
font-weight: 500;
font-size: 18px;
text-transform: uppercase;
position: relative;
transition:
color 0.3s ease,
text-shadow 0.3s ease;
}
}
.checkbox input:checked + .checkbox__box .checkbox__mark {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
animation: glitch-anim-checkbox var(--glitch-anim-duration) both;
}
.checkbox input:checked ~ .checkbox__label {
color: $text;
text-shadow: 0 0 8px $text;
}
.checkbox:hover .checkbox__box {
box-shadow: 0 0 10px $text;
}
.checkbox:hover .checkbox__label::before,
.checkbox:hover .checkbox__label::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: $primary_dark;
}
.checkbox:hover .checkbox__label::before {
color: $text;
animation: glitch-anim-text var(--glitch-anim-duration)
cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
}
.checkbox:hover .checkbox__label::after {
color: $text;
animation: glitch-anim-text var(--glitch-anim-duration)
cubic-bezier(0.25, 0.46, 0.45, 0.94) reverse both;
}
@keyframes glitch-anim-checkbox {
0% {
transform: translate(-50%, -50%);
clip-path: inset(0 0 0 0);
}
20% {
transform: translate(calc(-50% - 3px), calc(-50% + 2px));
clip-path: inset(50% 0 20% 0);
}
40% {
transform: translate(calc(-50% + 2px), calc(-50% - 1px));
clip-path: inset(20% 0 60% 0);
}
60% {
transform: translate(calc(-50% - 2px), calc(-50% + 1px));
clip-path: inset(80% 0 5% 0);
}
80% {
transform: translate(calc(-50% + 2px), calc(-50% - 2px));
clip-path: inset(30% 0 45% 0);
}
100% {
transform: translate(-50%, -50%);
clip-path: inset(0 0 0 0);
}
}
@keyframes glitch-anim-text {
0% {
transform: translate(0);
clip-path: inset(0 0 0 0);
}
20% {
transform: translate(-3px, 2px);
clip-path: inset(50% 0 20% 0);
}
40% {
transform: translate(2px, -1px);
clip-path: inset(20% 0 60% 0);
}
60% {
transform: translate(-2px, 1px);
clip-path: inset(80% 0 5% 0);
}
80% {
transform: translate(2px, -2px);
clip-path: inset(30% 0 45% 0);
}
100% {
transform: translate(0);
clip-path: inset(0 0 0 0);
}
}
</style>

View file

@ -0,0 +1,106 @@
<template>
<form @submit.prevent="handleContactUs()" class="form">
<h2 class="form__title">{{ t('contact.form.title') }}</h2>
<ui-input
:type="'text'"
:placeholder="t('fields.name')"
:label="t('fields.name')"
:rules="[required]"
v-model="name"
/>
<ui-input
:type="'email'"
:placeholder="t('fields.email')"
:label="t('fields.email')"
:rules="[required]"
v-model="email"
:inputMode="'email'"
/>
<ui-input
:type="'text'"
:placeholder="t('fields.phoneNumber')"
:label="t('fields.phoneNumber')"
:rules="[required]"
v-model="phoneNumber"
:inputMode="'tel'"
/>
<ui-input
:type="'text'"
:placeholder="t('fields.subject')"
:label="t('fields.subject')"
:rules="[required]"
v-model="subject"
/>
<ui-textarea
:placeholder="t('fields.message')"
:label="t('fields.message')"
:rules="[required]"
v-model="message"
/>
<ui-button
:type="'submit'"
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.sendMessage') }}
</ui-button>
</form>
</template>
<script setup lang="ts">
import {useValidators} from '@composables/rules';
import {useContactUs} from '@composables/contact';
const { t } = useI18n();
const { required } = useValidators();
const name = ref<string>('');
const email = ref<string>('');
const phoneNumber = ref<string>('');
const subject = ref<string>('');
const message = ref<string>('');
const isFormValid = computed(() => {
return (
required(name.value) === true &&
required(email.value) === true &&
required(phoneNumber.value) === true &&
required(subject.value) === true &&
required(message.value) === true
);
});
const { contactUs, loading } = useContactUs();
async function handleContactUs() {
await contactUs(
name.value,
email.value,
phoneNumber.value,
subject.value,
message.value,
);
}
</script>
<style lang="scss" scoped>
.form {
width: 585px;
display: flex;
flex-direction: column;
gap: 24px;
border-radius: 16px;
border: 1px solid $border;
padding: 32px;
&__title {
color: $primary_dark;
font-family: "Playfair Display", sans-serif;
font-size: 24px;
font-weight: 700;
letter-spacing: -0.5px;
}
}
</style>

View file

@ -0,0 +1,77 @@
<template>
<form @submit.prevent="handleDeposit()" class="form">
<div class="form__box">
<ui-input
:type="'text'"
:placeholder="''"
v-model="amount"
:numberOnly="true"
:inputMode="'decimal'"
/>
<icon name="ic:baseline-compare-arrows" size="30" />
<ui-input
:type="'text'"
:placeholder="''"
v-model="amount"
:numberOnly="true"
:inputMode="'decimal'"
/>
</div>
<ui-button
:type="'submit'"
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.topUp') }}
</ui-button>
</form>
</template>
<script setup lang="ts">
import {useDeposit} from '@composables/user';
const { t } = useI18n();
const companyStore = useCompanyStore();
const { paymentMin, paymentMax } = usePaymentLimits();
const amount = ref<number>(5);
const isFormValid = computed(() => {
return (
amount.value >= paymentMin.value &&
amount.value <= paymentMax.value
);
});
const { deposit, loading } = useDeposit();
async function handleDeposit() {
await deposit(amount.value);
}
</script>
<style lang="scss" scoped>
.form {
display: flex;
flex-direction: column;
gap: 20px;
&__box {
display: flex;
align-items: flex-start;
gap: 20px;
& span {
flex-shrink: 0;
align-self: center;
}
}
&__button {
width: fit-content;
padding-inline: 20px;
}
}
</style>

View file

@ -0,0 +1,148 @@
<template>
<form @submit.prevent="handleLogin()" class="form">
<div class="form__top">
<h2 class="form__title">{{ t('forms.login.title') }}</h2>
<p class="form__subtitle">{{ t('forms.login.subtitle') }}</p>
</div>
<div class="form__main">
<ui-input
:type="'email'"
:placeholder="t('fields.email')"
:label="t('fields.email')"
:rules="[isEmail]"
v-model="email"
:inputMode="'email'"
/>
<ui-input
:type="'password'"
:placeholder="t('fields.password')"
:label="t('fields.password')"
:rules="[required]"
v-model="password"
/>
<div class="form__main-block">
<ui-checkbox
v-model="isStayLogin"
>
{{ t('checkboxes.remember') }}
</ui-checkbox>
<ui-link
@click="redirectTo('reset-password')"
>
{{ t('forms.login.forgot') }}
</ui-link>
</div>
<ui-button
:type="'submit'"
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.login') }}
</ui-button>
<p class="form__or">{{ t('forms.login.or') }}</p>
<ui-button
@click="redirectTo('sign-up')"
:type="'button'"
:style="'secondary'"
class="form__button"
>
{{ t('buttons.createAccount') }}
</ui-button>
</div>
</form>
</template>
<script setup lang="ts">
import {useLogin} from '@composables/auth';
import {useValidators} from '@composables/rules';
import {useProjectConfig} from "@composables/config";
const { t } = useI18n();
const appStore = useAppStore();
const route = useRoute();
const localePath = useLocalePath();
const { uiConfig } = useProjectConfig();
const { required, isEmail } = useValidators();
const email = ref<string>('');
const password = ref<string>('');
const isStayLogin = ref<boolean>(false);
const redirectTo = (to) => {
if (uiConfig.value.isAuthModals) {
appStore.setActiveAuthState(to);
} else {
navigateTo(localePath(`/auth/${to}`));
}
};
const isFormValid = computed(() => {
return (
isEmail(email.value) === true &&
required(password.value) === true
);
});
const { login, loading } = useLogin();
async function handleLogin() {
await login(email.value, password.value, isStayLogin.value);
}
</script>
<style lang="scss" scoped>
.form {
width: 450px;
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
&__top {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
&__title {
color: $primary;
font-family: "Playfair Display", sans-serif;
font-size: 30px;
font-weight: 700;
letter-spacing: -0.5px;
}
&__subtitle {
text-align: center;
color: $text;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__main {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
&-block {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
}
&__or {
text-align: center;
font-size: 14px;
font-weight: 400;
letter-spacing: -0.5px;
color: $text;
}
}
</style>

View file

@ -0,0 +1,110 @@
<template>
<form @submit.prevent="handleReset()" class="form">
<div class="form__top">
<h2 class="form__title">{{ t('forms.newPassword.title') }}</h2>
</div>
<ui-input
:type="'password'"
:placeholder="t('fields.newPassword')"
:label="t('fields.newPassword')"
:rules="[isPasswordValid]"
v-model="password"
/>
<ui-input
:type="'password'"
:placeholder="t('fields.confirmNewPassword')"
:label="t('fields.confirmNewPassword')"
:rules="[compareStrings]"
v-model="confirmPassword"
/>
<ui-button
:type="'submit'"
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.save') }}
</ui-button>
</form>
</template>
<script setup lang="ts">
import {useValidators} from '@composables/rules';
import {useNewPassword} from '@composables/auth';
const { t } = useI18n();
const { isPasswordValid } = useValidators();
const password = ref<string>('');
const confirmPassword = ref<string>('');
const compareStrings = (v: string) => {
if (v === password.value) return true;
return t('errors.compare');
};
const isFormValid = computed(() => {
return (
isPasswordValid(password.value) === true &&
compareStrings(confirmPassword.value) === true
);
});
const { newPassword, loading } = useNewPassword();
async function handleReset() {
await newPassword(
password.value,
confirmPassword.value,
);
}
</script>
<style lang="scss" scoped>
.form {
width: 450px;
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
&__top {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
&__title {
color: $primary;
font-family: "Playfair Display", sans-serif;
font-size: 30px;
font-weight: 700;
letter-spacing: -0.5px;
}
&__subtitle {
text-align: center;
color: $text;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__main {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
&-block {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
}
}
</style>

View file

@ -0,0 +1,243 @@
<template>
<form @submit.prevent="handleRegister()" class="form">
<div class="form__top">
<h2 class="form__title">{{ t('forms.register.title') }}</h2>
<p class="form__subtitle">{{ t('forms.register.subtitle') }}</p>
</div>
<div class="form__main">
<div class="form__main-box">
<ui-input
:type="'text'"
:placeholder="t('fields.firstName')"
:label="t('fields.firstName')"
:rules="[required]"
v-model="firstName"
/>
<ui-input
:type="'text'"
:placeholder="t('fields.lastName')"
:label="t('fields.lastName')"
:rules="[required]"
v-model="lastName"
/>
</div>
<div class="form__main-box">
<ui-input
:type="'text'"
:placeholder="t('fields.phoneNumber')"
:label="t('fields.phoneNumber')"
:rules="[required]"
v-model="phoneNumber"
:inputMode="'tel'"
/>
<ui-input
:type="'email'"
:placeholder="t('fields.email')"
:label="t('fields.email')"
:rules="[isEmail]"
v-model="email"
:inputMode="'email'"
/>
</div>
<ui-input
:type="'password'"
:placeholder="t('fields.password')"
:label="t('fields.password')"
:rules="[isPasswordValid]"
v-model="password"
/>
<ui-input
:type="'password'"
:placeholder="t('fields.confirmPassword')"
:label="t('fields.confirmPassword')"
:rules="[compareStrings]"
v-model="confirmPassword"
/>
<ui-checkbox
v-model="isSubscribed"
>
{{ t('checkboxes.subscribe') }}
</ui-checkbox>
<ui-checkbox
v-model="isAgree"
>
<i18n-t tag="p" scope="global" keypath="checkboxes.agree">
<template #terms>
<nuxt-link-locale
class="form__link"
to="/docs/terms-of-use"
>
{{ t('docs.terms.title') }}
</nuxt-link-locale>
</template>
<template #policy>
<nuxt-link-locale
class="form__link"
to="/docs/privacy-policy"
>
{{ t('docs.policy.title') }}
</nuxt-link-locale>
</template>
</i18n-t>
</ui-checkbox>
<ui-button
:type="'submit'"
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.createAccount') }}
</ui-button>
<div class="form__login">
<p>{{ t('forms.register.login') }}</p>
<ui-link
@click="redirectTo('sign-in')"
>
{{ t('buttons.login') }}
</ui-link>
</div>
</div>
</form>
</template>
<script setup lang="ts">
import {useValidators} from '@composables/rules';
import {useRegister} from '@composables/auth';
import {useRouteQuery} from '@vueuse/router';
import {useProjectConfig} from "@composables/config";
const { t } = useI18n();
const appStore = useAppStore();
const route = useRoute();
const localePath = useLocalePath();
const { uiConfig } = useProjectConfig();
const { required, isEmail, isPasswordValid } = useValidators();
const isAgree = ref<boolean>(false);
const isSubscribed = ref<boolean>(false);
const firstName = ref<string>('');
const lastName = ref<string>('');
const phoneNumber = ref<string>('');
const email = ref<string>('');
const password = ref<string>('');
const confirmPassword = ref<string>('');
const referrer = useRouteQuery('referrer', '');
const redirectTo = (to) => {
if (uiConfig.value.isAuthModals) {
appStore.setActiveAuthState(to);
} else {
navigateTo(localePath(`/auth/${to}`));
}
};
const compareStrings = (v: string) => {
if (v === password.value) return true;
return t('errors.compare');
};
const isFormValid = computed(() => {
return (
required(firstName.value) === true &&
required(lastName.value) === true &&
required(phoneNumber.value) === true &&
isEmail(email.value) === true &&
isPasswordValid(password.value) === true &&
compareStrings(confirmPassword.value) === true &&
isAgree.value === true
);
});
const { register, loading } = useRegister();
async function handleRegister() {
await register({
firstName: firstName.value,
lastName: lastName.value,
phoneNumber: phoneNumber.value,
email: email.value,
password: password.value,
confirmPassword: confirmPassword.value,
referrer: referrer.value,
isSubscribed: isSubscribed.value
});
}
</script>
<style lang="scss" scoped>
.form {
width: 450px;
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
&__top {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
&__title {
color: $primary;
font-family: "Playfair Display", sans-serif;
font-size: 30px;
font-weight: 700;
letter-spacing: -0.5px;
}
&__subtitle {
text-align: center;
color: $text;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__main {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
&-block {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
&-box {
display: flex;
align-items: flex-start;
gap: 15px;
}
}
&__link {
color: $primary;
@include hover {
color: $primary_hover;
}
}
&__login {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
& p {
text-align: center;
color: $text;
font-size: 14px;
font-weight: 400;
letter-spacing: -0.5px;
}
}
}
</style>

View file

@ -0,0 +1,115 @@
<template>
<form @submit.prevent="handleReset()" class="form">
<div class="form__top">
<h2 class="form__title">{{ t('forms.reset.title') }}</h2>
<p class="form__subtitle">{{ t('forms.reset.subtitle') }}</p>
</div>
<div class="form__main">
<ui-input
:type="'email'"
:placeholder="t('fields.email')"
:label="t('fields.email')"
:rules="[isEmail]"
v-model="email"
:inputMode="'email'"
/>
<ui-button
:type="'submit'"
class="form__button"
:isDisabled="!isFormValid"
:isLoading="loading"
>
{{ t('buttons.sendLink') }}
</ui-button>
<ui-link
@click="redirectTo('sign-in')"
>
<icon name="material-symbols:arrow-left-alt" size="20" />
{{ t('forms.reset.backToLogin') }}
</ui-link>
</div>
</form>
</template>
<script setup lang="ts">
import {useValidators} from '@composables/rules';
import {usePasswordReset} from '@composables/auth';
import {useProjectConfig} from "@composables/config";
const { t } = useI18n();
const appStore = useAppStore();
const localePath = useLocalePath();
const { uiConfig } = useProjectConfig();
const { isEmail } = useValidators();
const email = ref<string>('');
const redirectTo = (to) => {
if (uiConfig.value.isAuthModals) {
appStore.setActiveAuthState(to);
} else {
navigateTo(localePath(`/auth/${to}`));
}
};
const isFormValid = computed(() => {
return (
isEmail(email.value) === true
);
});
const { resetPassword, loading } = usePasswordReset();
async function handleReset() {
await resetPassword(email.value);
}
</script>
<style lang="scss" scoped>
.form {
width: 450px;
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
&__top {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
&__title {
color: $primary;
font-family: "Playfair Display", sans-serif;
font-size: 30px;
font-weight: 700;
letter-spacing: -0.5px;
}
&__subtitle {
text-align: center;
color: $text;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__main {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
&-block {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
}
}
</style>

View file

@ -0,0 +1,122 @@
<template>
<form class="form" @submit.prevent="handleUpdate">
<div class="form__box">
<ui-input
:type="'text'"
:placeholder="t('fields.firstName')"
:label="t('fields.firstName')"
:rules="[required]"
v-model="firstName"
/>
<ui-input
:type="'text'"
:placeholder="t('fields.lastName')"
:label="t('fields.lastName')"
:rules="[required]"
v-model="lastName"
/>
</div>
<div class="form__box">
<ui-input
:type="'email'"
:placeholder="t('fields.email')"
:label="t('fields.email')"
:rules="[isEmail]"
v-model="email"
/>
<ui-input
:type="'text'"
:placeholder="t('fields.phoneNumber')"
:label="t('fields.phoneNumber')"
:rules="[required]"
v-model="phoneNumber"
/>
</div>
<div class="form__box">
<ui-input
:type="'password'"
:placeholder="t('fields.newPassword')"
:label="t('fields.newPassword')"
:rules="[isPasswordValid]"
v-model="password"
/>
<ui-input
:type="'password'"
:placeholder="t('fields.confirmNewPassword')"
:label="t('fields.confirmNewPassword')"
:rules="[compareStrings]"
v-model="confirmPassword"
/>
</div>
<ui-button
:type="'submit'"
class="form__button"
:isLoading="loading"
>
{{ t('buttons.saveChanges') }}
</ui-button>
</form>
</template>
<script setup lang="ts">
import {useValidators} from '@composables/rules';
import {useUserUpdating} from '@composables/user';
const { t } = useI18n();
const userStore = useUserStore();
const { required, isEmail, isPasswordValid } = useValidators();
const user = computed(() => userStore.user);
const firstName = ref<string>('');
const lastName = ref<string>('');
const email = ref<string>('');
const phoneNumber = ref<string>('');
const password = ref<string>('');
const confirmPassword = ref<string>('');
const compareStrings = (v: string) => {
if (v === password.value) return true;
return t('errors.compare');
};
const { updateUser, loading } = useUserUpdating();
watchEffect(() => {
firstName.value = user.value?.firstName || '';
lastName.value = user.value?.lastName || '';
email.value = user.value?.email || '';
phoneNumber.value = user.value?.phoneNumber || '';
});
async function handleUpdate() {
await updateUser(
firstName.value,
lastName.value,
email.value,
phoneNumber.value,
password.value,
confirmPassword.value,
);
}
</script>
<style lang="scss" scoped>
.form {
display: flex;
flex-direction: column;
gap: 20px;
&__box {
display: flex;
align-items: flex-start;
gap: 20px;
}
&__button {
width: fit-content;
padding-inline: 20px;
}
}
</style>

View file

@ -0,0 +1,85 @@
<template>
<div class="ad">
<div class="container">
<div class="ad__wrapper">
<div class="ad__block">
<h2 class="ad__title">{{ t('home.ad.title') }}</h2>
<p class="ad__subtext">{{ t('home.ad.text1') }}</p>
<p class="ad__text">{{ t('home.ad.text2') }}</p>
<nuxt-link-locale to="/shop" class="ad__button">{{ t('buttons.shopTheSale') }}</nuxt-link-locale>
</div>
<nuxt-img
format="webp"
densities="x1"
src="/images/saleImage.png"
alt="ad"
loading="lazy"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const {t} = useI18n();
</script>
<style lang="scss" scoped>
.ad {
&__wrapper {
background: $primary_gradient;
border-radius: $default_border_radius;
padding: 32px 32px 32px 50px;
display: flex !important;
align-items: center;
justify-content: space-between;
}
&__block {
display: flex;
flex-direction: column;
gap: 10px;
}
&__title {
margin-bottom: 10px;
color: $main;
font-family: "Playfair Display", sans-serif;
font-size: 36px;
font-weight: 700;
letter-spacing: -0.5px;
}
&__subtext {
color: $main;
font-size: 20px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__text {
color: $border;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
}
&__button {
margin-top: 20px;
width: fit-content;
background-color: $main;
padding: 15px 35px;
text-transform: uppercase;
color: $primary_dark;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.1px;
@include hover {
background-color: $primary_dark;
color: $main;
}
}
}
</style>

View file

@ -0,0 +1,57 @@
<template>
<div class="blog">
<div class="container">
<div class="blog__wrapper">
<h2 class="blog__title">{{ t('home.blog.title') }}</h2>
<div class="blog__posts">
<cards-post
v-for="post in filteredPosts.slice(0, 3)"
:key="post.node.id"
:post="post.node"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {IPost} from '@types';
import { docsSlugs } from '@appConstants';
const props = defineProps<{
posts: { node: IPost }[];
}>();
const {t} = useI18n();
const filteredPosts = computed(() => {
const excludedSlugs = Object.values(docsSlugs);
return props.posts?.filter(post => !excludedSlugs.includes(post.node.slug));
});
</script>
<style lang="scss" scoped>
.blog {
&__wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 50px;
}
&__title {
color: $primary_dark;
font-family: "Playfair Display", sans-serif;
font-size: 30px;
font-weight: 600;
letter-spacing: -0.5px;
}
&__posts {
display: flex;
align-items: stretch;
gap: 32px;
}
}
</style>

View file

@ -0,0 +1,47 @@
<template>
<div class="brands">
<nuxt-marquee
class="brands__marquee"
id="marquee-slider"
:speed="50"
:pauseOnHover="true"
>
<nuxt-link-locale
class="brands__item"
v-for="brand in brands"
:key="brand.node.uuid"
:to="`/brand/${brand.node.slug}`"
>
<p>{{ brand.node.name }}</p>
</nuxt-link-locale>
</nuxt-marquee>
</div>
</template>
<script setup lang="ts">
import type {IBrand} from '@types';
const props = defineProps<{
brands: { node: IBrand }[];
}>();
</script>
<style lang="scss" scoped>
.brands {
background-color: $border;
padding-block: 65px;
&__item {
margin-right: 65px;
color: $text;
font-family: "Playfair Display", sans-serif;
font-size: 24px;
font-weight: 400;
letter-spacing: 1.9px;
@include hover {
text-shadow: 0 0 10px $primary_dark;
}
}
}
</style>

View file

@ -0,0 +1,93 @@
<template>
<div class="categories">
<div class="container">
<div class="categories__wrapper">
<h2 class="categories__title">{{ t('home.categories.title') }}</h2>
<swiper
class="swiper"
:modules="[Pagination]"
:spaceBetween="24"
:breakpoints="{
200: {
slidesPerView: 3
}
}"
>
<swiper-slide
class="swiper__slide"
v-for="category in categories"
:key="category.node.uuid"
>
<nuxt-link-locale :to="`/catalog/${category.node.slug}`">
<nuxt-img
format="webp"
densities="x1"
:src="category.node.image"
:alt="category.node.name"
loading="lazy"
/>
<div>
<p>{{ category.node.name }}</p>
</div>
</nuxt-link-locale>
</swiper-slide>
</swiper>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {Pagination} from "swiper/modules";
import {Swiper, SwiperSlide} from "swiper/vue";
const {t} = useI18n();
const categoryStore = useCategoryStore();
const categories = computed(() => categoryStore.categories);
</script>
<style lang="scss" scoped>
.categories {
&__wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 50px;
}
&__title {
color: $primary_dark;
font-family: "Playfair Display", sans-serif;
font-size: 30px;
font-weight: 600;
letter-spacing: -0.5px;
}
}
.swiper {
width: 100%;
&__slide {
& a {
position: relative;
& img {
width: 100%;
}
& div {
background-color: rgba(0, 0, 0, 0.7);
padding: 25px;
& p {
color: $secondary;
font-size: 20px;
font-weight: 500;
letter-spacing: -0.5px;
}
}
}
}
}
</style>

View file

@ -0,0 +1,82 @@
<template>
<div class="hero">
<div class="container">
<div class="hero__wrapper">
<h2 class="hero__title">{{ t('home.hero.title') }}</h2>
<p class="hero__text">{{ t('home.hero.text') }}</p>
<nuxt-link-locale to="/shop" class="hero__button">{{ t('buttons.shopNow') }}</nuxt-link-locale>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const {t} = useI18n();
</script>
<style lang="scss" scoped>
.hero {
position: relative;
background-image: url(/images/heroImage.png);
background-repeat: no-repeat;
-webkit-background-size: cover;
background-size: cover;
background-position: top;
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: $blackout;
}
&__wrapper {
position: relative;
z-index: 1;
padding-block: 185px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
width: 675px;
margin-inline: auto;
}
&__title {
color: $main;
text-align: center;
font-family: "Playfair Display", sans-serif;
font-size: 60px;
font-weight: 700;
letter-spacing: 1px;
}
&__text {
text-align: center;
color: $main;
font-size: 20px;
font-weight: 300;
letter-spacing: -0.5px;
}
&__button {
margin-top: 10px;
background-color: $main;
padding: 10px 35px;
color: $primary_dark;
font-size: 16px;
font-weight: 500;
letter-spacing: -0.1px;
@include hover {
background-color: $primary_dark;
color: $main;
}
}
}
</style>

View file

@ -0,0 +1,117 @@
<template>
<nav class="nav">
<div class="nav__inner">
<nuxt-link-locale
class="nav__item"
:class="[{ active: route.path.includes('settings') }]"
to="/profile/settings"
>
<icon name="ic:baseline-settings" size="20" />
{{ t('profile.settings.title') }}
</nuxt-link-locale>
<nuxt-link-locale
class="nav__item"
:class="[{ active: route.path.includes('orders') }]"
to="/profile/orders"
>
<icon name="material-symbols:order-approve-rounded" size="20" />
{{ t('profile.orders.title') }}
</nuxt-link-locale>
<!-- <nuxt-link-locale-->
<!-- class="nav__item"-->
<!-- :class="[{ active: route.path.includes('balance') }]"-->
<!-- to="/profile/balance"-->
<!-- >-->
<!-- <icon name="ic:outline-attach-money" size="20" />-->
<!-- {{ t('profile.balance.title') }}-->
<!-- </nuxt-link-locale>-->
<nuxt-link-locale
class="nav__item"
:class="[{ active: route.path.includes('promocodes') }]"
to="/profile/promocodes"
>
<icon name="fluent:ticket-20-filled" size="20" />
{{ t('profile.promocodes.title') }}
</nuxt-link-locale>
</div>
<div class="nav__logout" @click="logout">
<icon name="material-symbols:power-settings-new-outline" size="20" />
{{ t('profile.logout') }}
</div>
</nav>
</template>
<script setup lang="ts">
import {useLogout} from '@composables/auth';
const {t} = useI18n();
const route = useRoute();
const { logout } = useLogout();
</script>
<style lang="scss" scoped>
.nav {
position: sticky;
top: 116px;
width: 256px;
flex-shrink: 0;
height: fit-content;
&__inner {
background-color: $main;
border-radius: $default_border_radius;
display: flex;
flex-direction: column;
border: 1px solid $border;
overflow: hidden;
}
&__item {
cursor: pointer;
padding: 16px 24px;
border-right: 2px solid transparent;
border-bottom: 1px solid $border;
display: flex;
align-items: center;
gap: 10px;
color: $text;
font-size: 16px;
font-weight: 500;
@include hover {
color: $primary;
background-color: $main_hover;
}
&.active {
border-right-color: $primary;
color: $primary;
background-color: $main_hover;
}
}
&__logout {
cursor: pointer;
margin-top: 25px;
border-radius: $less_border_radius;
background-color: rgba($primary, 0.2);
border: 1px solid $primary;
padding: 7px 20px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: $primary;
font-size: 16px;
font-weight: 600;
@include hover {
background-color: $primary;
color: $main;
}
}
}
</style>

View file

@ -0,0 +1,48 @@
<template>
<el-skeleton
class="sk"
animated
>
<template #template>
<el-skeleton-item
variant="image"
class="sk__image"
/>
<el-skeleton-item
variant="p"
class="sk__name"
/>
</template>
</el-skeleton>
</template>
<script setup lang="ts">
const props = defineProps<{
isList?: boolean;
}>();
</script>
<style lang="scss" scoped>
.sk {
width: 100%;
background-color: $skeleton;
border-radius: $default_border_radius;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
border: 1px solid $border;
padding: 23px;
&__image {
width: 100%;
height: 233px;
border-radius: $less_border_radius;
}
&__name {
width: 100%;
height: 20px;
}
}
</style>

View file

@ -0,0 +1,88 @@
<template>
<el-skeleton
class="sk"
animated
>
<template #template>
<div class="sk__main">
<el-skeleton-item
variant="p"
class="sk__text"
/>
<el-skeleton-item
variant="p"
class="sk__text"
/>
</div>
<div class="sk__bottom">
<div class="sk__bottom-images">
<el-skeleton-item
variant="image"
class="sk__image"
v-for="idx in 3"
:key="idx"
/>
</div>
<el-skeleton-item
variant="p"
class="sk__price"
/>
</div>
</template>
</el-skeleton>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.sk {
width: 100%;
border-radius: $less_border_radius;
background-color: $skeleton;
border: 1px solid $primary;
display: flex;
flex-direction: column;
align-items: stretch;
padding: 10px 8px;
&__main {
width: 100%;
display: flex;
align-items: center;
gap: 25px;
padding-bottom: 5px;
}
&__text {
width: 100px;
height: 20px;
border-radius: $less_border_radius;
}
&__bottom {
border-top: 1px solid $primary;
padding-top: 10px;
display: flex;
justify-content: space-between;
&-images {
display: flex;
align-items: center;
gap: 10px;
}
}
&__image {
width: 52px;
height: 65px;
border-radius: $less_border_radius;
}
&__price {
width: 60px;
height: 20px;
}
}
</style>

View file

@ -0,0 +1,169 @@
<template>
<el-skeleton
class="sk"
:class="[{'sk__list': isList }]"
animated
>
<template #template>
<div class="sk__content">
<el-skeleton-item
variant="image"
class="sk__image"
/>
<div class="sk__content-wrapper">
<div class="sk__content-inner">
<el-skeleton-item
variant="p"
class="sk__brand"
/>
<el-skeleton-item
variant="p"
class="sk__name"
/>
<el-skeleton-item
variant="p"
class="sk__rating"
/>
<el-skeleton-item
variant="p"
class="sk__price"
/>
</div>
<div class="sk__buttons">
<el-skeleton-item
variant="p"
class="sk__button"
/>
<el-skeleton-item
variant="p"
class="sk__button"
/>
</div>
</div>
</div>
</template>
</el-skeleton>
</template>
<script setup lang="ts">
const props = defineProps<{
isList?: boolean;
}>();
</script>
<style lang="scss" scoped>
.sk {
width: 100%;
border-radius: $default_border_radius;
border: 1px solid $border;
background-color: $skeleton;
display: flex;
flex-direction: column;
&__list {
flex-direction: row;
align-items: flex-start;
padding: 15px;
& .sk__content {
width: 100%;
display: flex;
flex-direction: row;
gap: 50px;
&-wrapper {
width: 100%;
padding: 0;
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
}
&-inner {
align-self: flex-start;
}
}
& .sk__image {
width: 150px;
height: 150px;
}
& .sk__price {
width: 100px;
}
& .sk__quantity {
width: 100px;
}
& .sk__buttons {
width: fit-content;
margin-top: 0;
flex-direction: column;
align-items: flex-end;
}
& .sk__button {
&:first-child {
width: 140px;
}
}
}
&__image {
width: 100%;
height: 200px;
border-radius: $less_border_radius;
}
&__content {
&-wrapper {
padding: 10px 20px 20px 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
&-inner {
display: flex;
flex-direction: column;
gap: 4px;
}
}
&__price {
width: 50px;
height: 20px;
}
&__name {
width: 100%;
height: 17px;
}
&__rating {
margin-block: 4px 10px;
width: 120px;
height: 16px;
}
&__brand {
width: 75px;
height: 15px;
}
&__buttons {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
&__button {
width: 100%;
height: 40px;
}
}
</style>

View file

@ -0,0 +1,46 @@
<template>
<el-skeleton class="sk" animated>
<template #template>
<div class="sk__block" v-for="idx in 3" :key="idx">
<el-skeleton-item variant="p" class="sk__title" />
<el-skeleton-item variant="p" class="sk__text" />
<el-skeleton-item variant="p" class="sk__text" />
</div>
</template>
</el-skeleton>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.sk {
width: 100%;
display: flex;
flex-direction: column;
gap: 20px;
background-color: $skeleton;
&__block {
display: flex;
flex-direction: column;
gap: 10px;
}
&__title {
width: 200px;
height: 30px;
}
&__text {
width: 100%;
height: 21px;
}
}
//:deep(.el-skeleton__item) {
// --el-skeleton-color: #c9ccd0 !important;
// --el-skeleton-to-color: #c3c3c7 !important;
//}
</style>

View file

@ -0,0 +1,31 @@
<template>
<el-skeleton class="sk" animated>
<template #template>
<el-skeleton-item
variant="p"
class="sk__text"
v-for="idx in 5"
:key="idx"
/>
</template>
</el-skeleton>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.sk {
width: 100%;
display: flex;
flex-direction: column;
gap: 15px;
padding: 10px 20px;
&__text {
width: 100%;
height: 32px;
}
}
</style>

View file

@ -0,0 +1,316 @@
<template>
<div class="filters">
<div class="filters__top">
<h2>{{ t('store.filters.title') }}</h2>
<p @click="resetFilters">{{ t('buttons.clearAll') }}</p>
</div>
<client-only>
<el-collapse v-model="collapse">
<el-collapse-item
name="0"
>
<template #title="{ isActive }">
<div class="filters__head">
<h3 class="filters__name" v-text="t('store.filters.price')" />
<icon
name="material-symbols:keyboard-arrow-down"
size="22"
class="filters__head-icon"
:class="[{ active: isActive }]"
/>
</div>
</template>
<div class="filters__price">
<div class="filters__price-inputs">
<ui-input
:model-value="priceMinInput"
type="text"
placeholder="Min"
input-mode="decimal"
@update:model-value="(val) => priceMinInput = val"
@blur="handlePriceBlur(priceMinInput, 'min')"
/>
<span class="filters__separator"></span>
<ui-input
:model-value="priceMaxInput"
type="text"
placeholder="Max"
input-mode="decimal"
@update:model-value="(val) => priceMaxInput = val"
@blur="handlePriceBlur(priceMaxInput, 'max')"
/>
</div>
<el-slider
v-model="priceRange"
:min="categoryMin"
:max="categoryMax"
range
:step="0.01"
:format-tooltip="formatPriceTooltip"
/>
</div>
</el-collapse-item>
<el-collapse-item
v-if="filterableAttributes"
v-for="(attribute, idx) in filterableAttributes"
:key="idx"
:name="`${idx + 2}`"
>
<template #title="{ isActive }">
<div class="filters__head">
<h3 class="filters__name" v-text="attribute.attributeName" />
<icon
name="material-symbols:keyboard-arrow-down"
size="22"
class="filters__head-icon"
:class="[{ active: isActive }]"
/>
</div>
</template>
<ul class="filters__list">
<li
v-for="(value, idx) of attribute.possibleValues"
:key="idx"
class="filters__item"
>
<ui-checkbox
:id="attribute.attributeName + idx"
v-model="selectedMap[attribute.attributeName][value]"
>
{{ value }}
</ui-checkbox>
</li>
</ul>
</el-collapse-item>
</el-collapse>
</client-only>
</div>
</template>
<script setup lang="ts">
import type {IStoreFilters} from '@types';
import {useFilters} from '@composables/store';
import {useRouteQuery} from '@vueuse/router';
import {CURRENCY} from "~/constants";
const appStore = useAppStore();
const { t } = useI18n();
const props = defineProps<{
filterableAttributes?: IStoreFilters[];
initialMinPrice?: number;
initialMaxPrice?: number;
categoryMinPrice?: number;
categoryMaxPrice?: number;
}>();
const emit = defineEmits(["filterMinPrice", "filterMaxPrice", "update:selected"]);
const attributesQuery = useRouteQuery<string>('attributes', '');
const {
selectedMap,
selectedAllMap,
priceRange,
collapse,
toggleAll,
resetFilters,
applyFilters,
parseAttributesString
} = useFilters(
toRef(props, 'filterableAttributes')
);
const priceMinInput = computed({
get: () => String(priceRange.value[0]),
set: (val: string | number) => {
handlePriceInput(val, 'min');
}
});
const priceMaxInput = computed({
get: () => String(priceRange.value[1]),
set: (val: string | number) => {
handlePriceInput(val, 'max');
}
});
const categoryMin = computed(() => props.categoryMinPrice ?? 0);
const categoryMax = computed(() => props.categoryMaxPrice ?? 50000);
onMounted(() => {
initializeInputs();
});
const initializeInputs = () => {
const min = props.initialMinPrice ?? categoryMin.value;
const max = props.initialMaxPrice ?? categoryMax.value;
priceRange.value = [min, max];
};
const handlePriceInput = useDebounceFn((value: string | number, type: 'min' | 'max') => {
const strValue = String(value).replace(',', '.');
const numValue = parseFloat(strValue);
if (isNaN(numValue)) return;
if (type === 'min') {
const clamped = Math.max(categoryMin.value, Math.min(numValue, priceRange.value[1]));
priceRange.value = [clamped, priceRange.value[1]];
} else {
const clamped = Math.max(priceRange.value[0], Math.min(numValue, categoryMax.value));
priceRange.value = [priceRange.value[0], clamped];
}
}, 300);
const handlePriceBlur = (value: string | number, type: 'min' | 'max') => {
const strValue = String(value).trim();
if (strValue === '') {
if (type === 'min') {
priceRange.value = [categoryMin.value, priceRange.value[1]];
} else {
priceRange.value = [priceRange.value[0], categoryMax.value];
}
}
};
const debouncedPriceUpdate = useDebounceFn(() => {
emit("filterMinPrice", priceRange.value[0]);
emit("filterMaxPrice", priceRange.value[1]);
}, 300);
const debouncedFilterApply = useDebounceFn(() => {
const picked = applyFilters();
emit('update:selected', picked);
}, 300);
watch(priceRange, () => {
debouncedPriceUpdate();
}, { deep: true });
watch(selectedMap, () => {
debouncedFilterApply();
}, { deep: true });
watch(
() => [props.categoryMinPrice, props.categoryMaxPrice, props.initialMinPrice, props.initialMaxPrice],
([catMin, catMax, initMin, initMax]) => {
const min = initMin ?? catMin ?? 0;
const max = initMax ?? catMax ?? 50000;
priceRange.value = [min, max];
},
{ immediate: true }
);
watch(
() => attributesQuery.value,
(attrStr) => {
const initial = parseAttributesString(attrStr);
const hasFloatInQuery = initial['float'] && initial['float'].length === 2;
if (!hasFloatInQuery) {
resetFilters();
}
if (!attrStr) return;
Object.entries(initial).forEach(([key, vals]) => {
if (key === 'float' && vals.length === 2) {
const min = parseFloat(vals[0]);
const max = parseFloat(vals[1]);
floatRange.value = [min, max];
} else {
vals.forEach(val => {
if (selectedMap[key] && selectedMap[key][val] !== undefined) {
selectedMap[key][val] = true;
}
});
if (selectedMap[key]) {
selectedAllMap[key] = Object.values(selectedMap[key]).every(v => v);
}
}
});
},
{ immediate: true }
);
const formatPriceTooltip = (value: number) => `${CURRENCY}${value.toFixed(2)}`;
</script>
<style scoped lang="scss">
.filters {
width: 290px;
border: 1px solid $border;
border-radius: $default_border_radius;
display: flex;
flex-direction: column;
gap: 24px;
padding: 20px;
&__top {
display: flex;
align-items: center;
justify-content: space-between;
& h2 {
color: $primary_dark;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.5px;
}
& p {
cursor: pointer;
color: $link_primary;
font-size: 12px;
font-weight: 400;
letter-spacing: -0.5px;
@include hover {
color: $link_primary_hover;
}
}
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
&-icon {
color: $secondary;
&.active {
transform: rotate(-180deg);
}
}
}
&__list {
list-style: none;
}
&__name {
font-size: 16px;
font-weight: 500;
color: $primary_dark;
letter-spacing: -0.5px;
}
&__price {
&-inputs {
display: flex;
align-items: center;
gap: 10px;
}
}
}
:deep(.block__input) {
font-size: 12px;
padding: 5px 10px;
}
</style>

View file

@ -0,0 +1,226 @@
<template>
<div class="store">
<store-filter
v-if="filters.length"
:filterableAttributes="filters"
@update:selected="onFiltersChange"
:initialMinPrice="Number(minPrice)"
:initialMaxPrice="Number(maxPrice)"
:categoryMinPrice="minMaxPrices.minPrice"
:categoryMaxPrice="minMaxPrices.maxPrice"
@filterMinPrice="updateMinPrice"
@filterMaxPrice="updateMaxPrice"
/>
<div class="store__inner">
<store-top
v-model="orderBy"
@toggle-filter="onFilterToggle"
:isFilters="filters.length > 0"
/>
<client-only>
<div
class="store__list"
:class="[
{ 'store__list-grid': productView === 'grid' },
{ 'store__list-list': productView === 'list' }
]"
>
<cards-product
v-if="products.length && !pending"
v-for="product in products"
:key="product.node.uuid"
:product="product.node"
:isList="productView === 'list'"
/>
<skeletons-cards-product
v-if="pending"
v-for="idx in 12"
:key="idx"
:isList="productView === 'list'"
/>
</div>
</client-only>
<div class="store__list-observer" ref="observer"></div>
<p class="store__empty" v-if="!products.length && !pending">{{ t('store.empty') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import {useFilters, useStore} from '@composables/store';
import {useCategoryBySlug} from '@composables/categories';
import {useBrandBySlug} from '@composables/brands';
import {useDefaultSeo} from '@composables/seo';
const { t, locale } = useI18n();
const { $appHelpers } = useNuxtApp();
const productView = useCookie<string>(
$appHelpers.COOKIES_PRODUCT_VIEW_KEY as string,
{
default: () => 'grid',
path: '/',
}
);
const categorySlug = useRouteParams<string>('categorySlug');
const brandSlug = useRouteParams<number>('brandSlug');
const attributes = useRouteQuery<string>('attributes', '');
const orderBy = useRouteQuery<string>('orderBy', 'created');
const minPrice = useRouteQuery<number>('minPrice', 0);
const maxPrice = useRouteQuery<number>('maxPrice', 50000);
const observer = ref(null);
const categoryData = categorySlug.value
? await useCategoryBySlug(categorySlug.value)
: { category: ref(null), seoMeta: ref(null), filters: ref([]) };
const brandData = brandSlug.value
? await useBrandBySlug(brandSlug.value)
: { brand: ref(null), seoMeta: ref(null) };
const { category, seoMeta: categorySeoMeta, filters, minMaxPrices } = categoryData;
const { brand, seoMeta: brandSeoMeta } = brandData;
const seoMeta = computed(() => categorySeoMeta.value || brandSeoMeta.value);
const meta = useDefaultSeo(seoMeta.value || null);
if (meta) {
useSeoMeta({
title: meta.title || $appHelpers.APP_NAME,
description: meta.description || meta.title || $appHelpers.APP_NAME,
ogTitle: meta.og.title || undefined,
ogDescription: meta.og.description || meta.title || $appHelpers.APP_NAME,
ogType: meta.og.type || undefined,
ogUrl: meta.og.url || undefined,
ogImage: meta.og.image || undefined,
twitterCard: meta.twitter.card || undefined,
twitterTitle: meta.twitter.title || undefined,
twitterDescription: meta.twitter.description || undefined,
robots: meta.robots,
});
useHead({
link: [
meta.canonical ? { rel: 'canonical', href: meta.canonical } : {},
].filter(Boolean) as any,
meta: [{ property: 'og:locale', content: locale.value }],
script: meta.jsonLd.map((obj: any) => ({
type: 'application/ld+json',
innerHTML: JSON.stringify(obj),
})),
__dangerouslyDisableSanitizersByTagID: Object.fromEntries(
meta.jsonLd.map((_, i: number) => [`ldjson-${i}`, ['innerHTML']])
),
});
}
watch(
() => category.value,
(cat) => {
if (cat && !useRoute().query.maxPrice) {
maxPrice.value = cat.minMaxPrices.maxPrice;
}
},
{ immediate: true }
);
const { pending, products, pageInfo, variables, getProducts } = useStore({
orderBy: orderBy.value,
categoriesSlugs: categorySlug.value,
productAfter: '',
minPrice: minPrice.value,
maxPrice: maxPrice.value,
brand: brand.value?.name,
attributes: attributes.value
});
await getProducts();
const { buildAttributesString } = useFilters(filters);
const showFilter = ref<boolean>(false);
function onFilterToggle() {
showFilter.value = true;
}
async function onFiltersChange(newFilters: Record<string, string[]>) {
const attrString = buildAttributesString(newFilters);
attributes.value = attrString;
variables.attributes = attrString;
await getProducts();
}
const updateMinPrice = useDebounceFn((filteredPrice) => {
minPrice.value = filteredPrice;
}, 500);
const updateMaxPrice = useDebounceFn((filteredPrice) => {
maxPrice.value = filteredPrice;
}, 500);
useIntersectionObserver(
observer,
async ([{ isIntersecting }]) => {
if (isIntersecting && pageInfo.value?.hasNextPage && !pending.value) {
variables.productAfter = pageInfo.value.endCursor;
await getProducts();
}
},
);
watch(
[orderBy, attributes, minPrice, maxPrice],
async ([newOrder, newAttr, newMin, newMax]) => {
variables.orderBy = newOrder || '';
variables.attributes = newAttr;
variables.minPrice = Number(newMin) || 0;
variables.maxPrice = Number(newMax) || 500000;
variables.productAfter = '';
products.value = [];
await getProducts();
}
);
</script>
<style scoped lang="scss">
.store {
position: relative;
display: flex;
align-items: flex-start;
gap: 32px;
&__inner {
width: 100%;
}
&__list {
margin-top: 32px;
&-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 25px;
}
&-list {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 20px;
}
&-observer {
background-color: transparent;
width: 100%;
height: 10px;
}
}
&__empty {
color: $primary;
font-weight: 500;
font-size: 18px;
}
}
</style>

View file

@ -0,0 +1,136 @@
<template>
<div class="top" :class="[{ filters: isFilters }]">
<div class="top__sorting">
<p>{{ t('store.sorting') }}</p>
<client-only>
<el-select
v-model="select"
size="large"
style="width: 240px"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</client-only>
</div>
<div class="top__view">
<button
class="top__view-button"
:class="{ active: productView === 'list' }"
@click="setView('list')"
>
<icon name="material-symbols:view-list-sharp" size="16" />
</button>
<button
class="top__view-button"
:class="{ active: productView === 'grid' }"
@click="setView('grid')"
>
<icon name="material-symbols:grid-view" size="16" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
const {t} = useI18n();
const { $appHelpers } = useNuxtApp();
const props = defineProps<{
modelValue: string;
isFilters: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'toggle-filter'): void;
}>();
const productView = useCookie($appHelpers.COOKIES_PRODUCT_VIEW_KEY as string);
function setView(view: 'list' | 'grid') {
productView.value = view;
}
const select = ref(props.modelValue || 'created');
const options = [
{
value: 'created',
label: 'New',
},
{
value: 'rating',
label: 'Rating',
},
{
value: 'price',
label: 'Сheap first',
},
{
value: '-price',
label: 'Expensive first',
}
];
watch(select, value => {
emit('update:modelValue', value);
});
</script>
<style scoped lang="scss">
.top {
width: 100%;
position: relative;
z-index: 2;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 40px;
border-radius: $default_border_radius;
border: 1px solid $border;
padding: 16px;
&.filters {
justify-content: flex-end;
}
&__sorting {
display: flex;
align-items: center;
gap: 20px;
& p {
font-weight: 400;
font-size: 14px;
letter-spacing: -0.5px;
color: $text;
}
}
&__view {
display: flex;
align-items: center;
border-radius: $less_border_radius;
border: 1px solid $border;
&-button {
cursor: pointer;
background-color: $main;
display: grid;
place-items: center;
padding: 10px;
color: $primary_dark;
@include hover {
background-color: $main_hover;
}
&.active {
background-color: $link_secondary;
}
}
}
}
</style>

View file

@ -0,0 +1,51 @@
<template>
<client-only>
<el-breadcrumb separator="/" class="breadcrumbs">
<el-breadcrumb-item
v-for="(crumb, idx) in breadcrumbs"
:key="idx"
>
<nuxt-link-locale
v-if="idx !== breadcrumbs.length - 1"
:to="crumb.link"
class="breadcrumbs__link"
>
{{ crumb.text }}
</nuxt-link-locale>
<span v-else class="breadcrumbs__current">
{{ crumb.text }}
</span>
</el-breadcrumb-item>
</el-breadcrumb>
</client-only>
</template>
<script setup lang="ts">
import {useBreadcrumbs} from '@composables/breadcrumbs';
const { breadcrumbs } = useBreadcrumbs();
</script>
<style scoped lang="scss">
.breadcrumbs {
background-color: $title_bg;
padding: 15px 250px 15px 50px;
line-height: 140%;
border-bottom: 1px solid $border;
&__link {
cursor: pointer !important;
color: $primary !important;
font-weight: 600 !important;
@include hover {
color: $primary_dark !important;
}
}
&__current {
font-weight: 600;
color: $primary_dark;
}
}
</style>

View file

@ -0,0 +1,89 @@
<template>
<button
class="button"
:disabled="isDisabled"
:class="[
{ active: isLoading },
{ secondary: style === 'secondary' }
]"
:type="type"
>
<ui-loader class="button__loader" v-if="isLoading" />
<slot v-else />
</button>
</template>
<script setup lang="ts">
const props = defineProps<{
type: 'submit' | 'button';
isDisabled?: boolean;
isLoading?: boolean;
style?: string;
}>();
</script>
<style lang="scss" scoped>
.button {
position: relative;
width: 100%;
cursor: pointer;
flex-shrink: 0;
background-color: $primary;
border-radius: $default_border_radius;
padding-block: 14px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
z-index: 1;
color: $main;
text-align: center;
font-size: 16px;
font-weight: 500;
&.secondary {
background-color: $main;
border: 1px solid $border;
color: $secondary;
@include hover {
background-color: $border;
}
&.active {
background-color: $border;
}
&:disabled {
cursor: not-allowed;
background-color: $disabled_secondary;
}
&:disabled:hover, &.active {
background-color: $disabled_secondary;
}
}
@include hover {
background-color: $primary_hover;
}
&.active {
background-color: $primary_hover;
}
&:disabled {
cursor: not-allowed;
background-color: $disabled;
}
&:disabled:hover, &.active {
background-color: $disabled;
}
&__loader {
margin-block: 4px;
}
}
</style>

View file

@ -0,0 +1,92 @@
<template>
<label class="checkbox" :class="{ isPrimary }">
<input
:id="id"
class="checkbox__input"
type="checkbox"
:checked="modelValue"
@change="onChange"
/>
<span class="checkbox__block"></span>
<span class="checkbox__label">
<slot/>
</span>
</label>
</template>
<script setup lang="ts">
const props = defineProps<{
id?: string;
modelValue: boolean;
isPrimary?: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
}>();
function onChange(e: Event) {
const checked = (e.target as HTMLInputElement).checked;
emit('update:modelValue', checked);
}
</script>
<style lang="scss" scoped>
.checkbox {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
&.isPrimary {
& .checkbox__block {
border: 2px solid $primary_dark;
border-radius: $less_border_radius;
}
& .checkbox__label {
color: $primary_dark;
font-weight: 600;
}
}
&__input {
display: none;
opacity: 0;
}
&__block {
cursor: pointer;
display: inline-block;
width: 16px;
height: 16px;
border: 0.5px solid $primary_dark;
border-radius: 1px;
position: relative;
&::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 10px;
background-color: $primary_dark;
border-radius: 2px;
opacity: 0;
}
}
&__label {
cursor: pointer;
color: $secondary;
font-size: 14px;
font-weight: 400;
letter-spacing: -0.5px;
}
}
.checkbox__input:checked + .checkbox__block::after {
opacity: 1;
}
</style>

View file

@ -0,0 +1,168 @@
<template>
<div class="block">
<div class="block__wrapper">
<label v-if="label" class="block__label">{{ label }}</label>
<div class="block__wrapper-inner">
<input
:placeholder="placeholder"
:type="isPasswordVisible"
:value="modelValue"
@input="onInput"
@keydown="numberOnly ? onlyNumbersKeydown($event) : null"
class="block__input"
:inputmode="inputMode || 'text'"
>
<button
@click.prevent="togglePasswordVisible"
class="block__eyes"
v-if="type === 'password' && String(modelValue).length > 0"
>
<icon v-if="isPasswordVisible === 'password'" name="mdi:eye-off-outline" />
<icon v-else name="mdi:eye-outline" />
</button>
</div>
</div>
<p v-if="!isValid" class="block__error">{{ errorMessage }}</p>
</div>
</template>
<script setup lang="ts">
type Rule = (value: string) => boolean | string;
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number): void;
}>();
const props = defineProps<{
type: string;
placeholder: string;
modelValue?: string | number;
rules?: Rule[];
label?: string;
numberOnly?: boolean;
inputMode?: "text" | "email" | "search" | "tel" | "url" | "none" | "numeric" | "decimal";
}>();
const isPasswordVisible = ref<string>(props.type);
const isValid = ref<boolean>(true);
const errorMessage = ref<string>('');
function togglePasswordVisible() {
isPasswordVisible.value =
isPasswordVisible.value === 'password' ? 'text' : 'password';
}
const onlyNumbersKeydown = (event: KeyboardEvent) => {
if (!/^\d$/.test(event.key) &&
!['ArrowLeft', 'ArrowRight', 'Backspace', 'Delete', 'Tab', 'Home', 'End'].includes(event.key)) {
event.preventDefault();
}
};
function onInput(e: Event) {
const target = e.target as HTMLInputElement;
let value = target.value;
if (props.numberOnly) {
const digitsOnly = value.replace(/\D/g, '');
if (digitsOnly !== value) {
target.value = digitsOnly;
value = digitsOnly;
}
}
let valid = true;
errorMessage.value = '';
props.rules?.forEach((rule) => {
const result = rule(value);
if (result !== true) {
valid = false;
errorMessage.value = String(result);
}
})
isValid.value = valid;
emit('update:modelValue', props.numberOnly ? Number(value) : value);
}
</script>
<style lang="scss" scoped>
.block {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
position: relative;
&__wrapper {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
&-inner {
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
}
}
&__label {
color: $secondary;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.5px;
}
&__input {
width: 100%;
padding: 14px 12px;
border: 1px solid $border;
border-radius: $default_border_radius;
background-color: $main;
color: $primary_dark;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
&::placeholder {
color: $disabled_secondary;
}
}
&__eyes {
cursor: pointer;
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
background-color: transparent;
display: grid;
place-items: center;
font-size: 18px;
color: $disabled_secondary;
}
&__error {
color: $error;
font-size: 12px;
font-weight: 500;
animation: fadeInUp 0.3s ease;
@keyframes fadeInUp {
0% {
opacity: 0;
transform: translateY(-50%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
}
}
</style>

View file

@ -0,0 +1,143 @@
<template>
<div class="switcher" ref="switcherRef">
<div
@click="setSwitcherVisible(!isSwitcherVisible)"
class="switcher__button"
:class="[{ active: isSwitcherVisible }]"
>
<client-only>
<!-- <icon name="fluent:globe-20-filled" size="20" />-->
<nuxt-img
format="webp"
densities="x1"
v-if="currentLocale"
:src="currentLocale.flag"
:alt="currentLocale.code"
/>
<!-- <skeletons-ui-language-switcher v-else />-->
<template #fallback>
<!-- <skeletons-ui-language-switcher />-->
</template>
</client-only>
</div>
<client-only>
<div
class="switcher__menu"
:class="[{active: isSwitcherVisible}]"
>
<div class="switcher__menu-wrapper">
<nuxt-img
class="switcher__menu-button"
v-for="locale of locales"
:key="locale.code"
format="webp"
densities="x1"
@click="uiSwitchLanguage(locale.code)"
:src="locale.flag"
:alt="locale.code"
/>
</div>
</div>
</client-only>
</div>
</template>
<script setup lang="ts">
import {onClickOutside} from '@vueuse/core';
import {useLanguageSwitch} from '@composables/languages';
const languageStore = useLanguageStore();
const locales = computed(() => languageStore.languages);
const currentLocale = computed(() => languageStore.currentLocale);
const isSwitcherVisible = ref<boolean>(false);
const setSwitcherVisible = (state: boolean) => {
isSwitcherVisible.value = state;
};
const switcherRef = ref(null);
onClickOutside(switcherRef, () => isSwitcherVisible.value = false);
const { switchLanguage } = useLanguageSwitch();
const uiSwitchLanguage = (localeCode: string) => {
switchLanguage(localeCode);
setSwitcherVisible(false);
};
</script>
<style lang="scss" scoped>
.switcher {
position: relative;
z-index: 1;
width: 44px;
flex-shrink: 0;
&__button {
width: 100%;
cursor: pointer;
display: flex;
gap: 5px;
padding: 5px;
border-radius: $less_border_radius;
@include hover {
background-color: $primary_shadow;
}
&.active {
background-color: $primary_shadow;
}
& img {
width: 100%;
}
}
&__menu {
position: absolute;
z-index: 3;
top: 110%;
left: 50%;
transform: translateX(-50%);
width: 100%;
overflow: hidden;
border-radius: $less_border_radius;
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
&-wrapper {
display: flex;
flex-direction: column;
}
&.active {
grid-template-rows: 1fr;
}
& > * {
min-height: 0;
}
&-button {
width: 100%;
cursor: pointer;
padding: 3px 5px;
background-color: $link_secondary;
&:first-child {
padding-top: 5px;
}
&:last-child {
padding-bottom: 5px;
}
@include hover {
background-color: $link_secondary_hover;
}
}
}
}
</style>

View file

@ -0,0 +1,55 @@
<template>
<span
class="link"
:class="{ 'link--clickable': isClickable }"
@click="handleClick"
>
<slot></slot>
</span>
</template>
<script setup lang="ts">
interface Props {
routePath?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
click: [];
}>();
const router = useRouter();
const isClickable = computed(() => !!props.routePath);
const handleClick = () => {
if (props.routePath) {
if (import.meta.client) {
router.push(props.routePath);
}
} else {
emit('click');
}
};
</script>
<style lang="scss" scoped>
.link {
width: fit-content;
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
color: $primary;
font-size: 14px;
font-weight: 400;
&--clickable {
cursor: pointer;
}
@include hover {
color: $secondary;
}
}
</style>

View file

@ -0,0 +1,59 @@
<template>
<div class="loader">
<span class="loader__dots" id="dot-1"></span>
<span class="loader__dots" id="dot-2"></span>
<span class="loader__dots" id="dot-3"></span>
</div>
</template>
<script setup>
</script>
<style lang="scss" scoped>
.loader {
display: flex;
gap: 0.6em;
list-style: none;
&__dots {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: $main;
}
}
#dot-1 {
animation: loader-1 0.6s infinite ease-in-out;
}
@keyframes loader-1 {
50% {
opacity: 0;
transform: translateY(-0.3em);
}
}
#dot-2 {
animation: loader-2 0.6s 0.3s infinite ease-in-out;
}
@keyframes loader-2 {
50% {
opacity: 0;
transform: translateY(-0.3em);
}
}
#dot-3 {
animation: loader-3 0.6s 0.6s infinite ease-in-out;
}
@keyframes loader-3 {
50% {
opacity: 0;
transform: translateY(-0.3em);
}
}
</style>

View file

@ -0,0 +1,297 @@
<template>
<div class="search">
<div class="container">
<div class="search__inner">
<div
@click="toggleSearch(true)"
class="search__wrapper"
:class="[{ active: isSearchActive }]"
>
<form class="search__form" @submit.prevent="submitSearch">
<input
type="text"
v-model="query"
:placeholder="t('fields.search')"
inputmode="search"
/>
<div class="search__tools">
<button
type="button"
@click="clearSearch"
v-if="query"
>
<icon name="gridicons:cross" size="16" />
</button>
<div class="search__tools-line" v-if="query"></div>
<button type="submit">
<icon name="tabler:search" size="16" />
</button>
</div>
</form>
<div class="search__results" :class="[{ active: (searchResults && isSearchActive) || loading }]">
<skeletons-header-search v-if="loading" />
<div
class="search__results-inner"
v-for="(blocks, category) in filteredSearchResults"
:key="category"
>
<div class="search__results-title">
<p>{{ getBlockTitle(category) }}:</p>
</div>
<div
class="search__item"
v-for="item in blocks"
:key="item.uuid"
@click.stop="goTo(category, item)"
>
<div class="search__item-left">
<icon name="ic:twotone-search" size="18" />
<p>{{ item.name }}</p>
</div>
<icon name="line-md:external-link" size="18" />
</div>
</div>
<div class="search__results-empty" v-if="!hasResults && query && !loading">
<p>{{ t('header.search.empty') }}</p>
</div>
</div>
</div>
<transition name="opacity" mode="out-in">
<div
class="search__bg"
@click="toggleSearch(false)"
v-if="isSearchActive"
/>
</transition>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useSearchUI } from '@composables/search';
const {t} = useI18n();
const router = useRouter();
const appStore = useAppStore();
const {
query,
isSearchActive,
loading,
searchResults,
filteredSearchResults,
hasResults,
getBlockTitle,
clearSearch,
toggleSearch
} = useSearchUI();
function submitSearch() {
if (query.value) {
router.push({
path: '/search',
query: { q: query.value }
})
toggleSearch(false);
}
}
watch(
() => isSearchActive.value,
(state) => {
appStore.setOverflowHidden(state);
},
{ immediate: true }
);
function goTo(category: string, item: any) {
let path = "/";
switch (category) {
case "products": {
path = `/product/${item.slug}`;
break;
}
case "categories": {
path = `/catalog/${item.slug}`;
break;
}
case "brands": {
path = `/brand/${item.slug}`;
break;
}
case "posts": {
path = "/";
break;
}
}
toggleSearch(false);
router.push(path);
}
</script>
<style lang="scss" scoped>
.search {
width: 100%;
background-color: $main;
&__inner {
padding-block: 10px;
width: 100%;
position: relative;
z-index: 1;
height: 100%;
}
&__bg {
background-color: rgba(0, 0, 0, 0.2);
height: 100vh;
left: 0;
position: fixed;
top: 0;
width: 100vw;
z-index: 1;
}
&__wrapper {
width: 100%;
background-color: $border;
border-radius: $less_border_radius;
position: relative;
z-index: 2;
&.active {
background-color: $main;
box-shadow: 0 0 0 1px #0000000a,0 4px 4px #0000000a,0 20px 40px #00000014;
& .search__wrapper {
border-radius: $less_border_radius $less_border_radius 0 0;
}
& .search__form input {
border-radius: $less_border_radius $less_border_radius 0 0;
}
}
@include hover {
background-color: $main;
box-shadow: 0 0 0 1px #0000000a,0 4px 4px #0000000a,0 20px 40px #00000014;
}
}
&__form {
width: 100%;
height: 40px;
position: relative;
& input {
background-color: transparent;
width: 100%;
height: 100%;
padding-inline: 20px 150px;
border: 1px solid $link_secondary;
border-radius: $less_border_radius;
color: $secondary;
font-size: 16px;
font-weight: 500;
}
}
&__tools {
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 10px;
& button {
cursor: pointer;
display: grid;
place-items: center;
border-radius: $less_border_radius;
padding: 5px 12px;
border: 1px solid $primary;
background-color: transparent;
font-size: 12px;
color: $primary;
@include hover {
background-color: $primary;
color: $main;
}
}
&-line {
background-color: $primary;
height: 15px;
width: 1px;
}
}
&__results {
position: absolute;
z-index: 1;
top: 100%;
width: 100%;
max-height: 0;
overflow: auto;
background-color: $main;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.0392156863), 0 4px 4px rgba(0, 0, 0, 0.0392156863), 0 20px 40px rgba(0, 0, 0, 0.0784313725);
&.active {
max-height: 40vh;
}
&-title {
background-color: rgba($primary, 0.2);
padding: 7px 20px;
font-weight: 600;
}
&-empty {
padding: 10px 20px;
font-size: 14px;
}
}
&__item {
cursor: pointer;
padding: 7px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 30px;
font-size: 14px;
@include hover {
background-color: $main_hover;
}
&-left {
display: flex;
align-items: center;
gap: 15px;
}
& p {
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
& span {
color: $text;
}
}
}
</style>

View file

@ -0,0 +1,112 @@
<template>
<div class="block">
<div class="block__inner">
<label v-if="label" class="block__label">{{ label }}</label>
<textarea
:placeholder="placeholder"
:value="modelValue"
@input="onInput"
class="block__textarea"
/>
</div>
<p v-if="!isValid" class="block__error">{{ errorMessage }}</p>
</div>
</template>
<script setup lang="ts">
type Rule = (value: string) => boolean | string;
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number): void;
}>();
const props = defineProps<{
placeholder: string;
modelValue?: string;
rules?: Rule[];
label?: string;
}>();
const isValid = ref(true);
const errorMessage = ref('');
const onInput = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
const value = target.value;
isValid.value = true;
errorMessage.value = '';
props.rules?.forEach(rule => {
const result = rule(value);
if (result !== true) {
isValid.value = false;
errorMessage.value = String(result);
}
});
emit('update:modelValue', value);
};
</script>
<style lang="scss" scoped>
.block {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
position: relative;
&__inner {
width: 100%;
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
}
&__label {
color: $secondary;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.5px;
}
&__textarea {
width: 100%;
height: 150px;
resize: none;
padding: 6px 12px;
border: 1px solid $border;
border-radius: $less_border_radius;
background-color: $main;
color: $primary_dark;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
&::placeholder {
color: $disabled_secondary;
}
}
&__error {
color: $error;
font-size: 12px;
font-weight: 500;
animation: fadeInUp 0.3s ease;
@keyframes fadeInUp {
0% {
opacity: 0;
transform: translateY(-50%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
}
}
</style>

View file

@ -0,0 +1,34 @@
<template>
<button
@click="toggleTheme"
class="theme"
>
<icon v-if="theme === 'light'" name="line-md:moon-alt-loop" size="22" />
<icon v-else name="line-md:sunny-outline-loop" size="22" />
</button>
</template>
<script setup lang="ts">
import { useThemes } from '@composables/themes';
const { theme, toggleTheme } = useThemes();
</script>
<style scoped lang="scss">
.theme {
background-color: transparent;
border-radius: $less_border_radius;
padding: 5px;
cursor: pointer;
color: $primary;
transition: all 0.3s ease;
@include hover {
background-color: $primary_shadow;
}
& span {
display: block;
}
}
</style>

View file

@ -0,0 +1,70 @@
<template>
<div class="title">
<div class="container">
<div class="title__wrapper">
<slot />
</div>
</div>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped lang="scss">
.title {
padding-block: 50px;
background-color: $title_bg;
&__wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
}
:deep(.title__wrapper h1) {
color: $primary_dark;
font-family: "Playfair Display", sans-serif;
font-weight: 700;
font-size: 36px;
letter-spacing: -0.5px;
}
:deep(.title__wrapper p) {
max-width: 600px;
text-align: center;
color: $text;
font-size: 18px;
font-weight: 400;
letter-spacing: -0.5px;
}
:deep(.title__wrapper .search) {
position: relative;
& span {
position: absolute;
top: 50%;
right: 24px;
transform: translateY(-50%);
color: $disabled_secondary;
}
& input {
background-color: $main;
border: 1px solid $border;
border-radius: 50px;
padding: 18px 70px 18px 50px;
color: $text;
font-size: 16px;
font-weight: 400;
letter-spacing: -0.5px;
&::placeholder {
color: $text;
}
}
}
</style>

View file

@ -0,0 +1,6 @@
export * from './useLogin';
export * from './useLogout';
export * from './useNewPassword';
export * from './usePasswordReset';
export * from './useRefresh';
export * from './useRegister';

View file

@ -0,0 +1,79 @@
import { useLocaleRedirect } from '@composables/languages';
import { useUserBaseData } from '@composables/user';
import { LOGIN } from '@graphql/mutations/auth';
import type { ILoginResponse } from '@types';
export function useLogin() {
const { t } = useI18n();
const userStore = useUserStore();
const localePath = useLocalePath();
const { $appHelpers, $notify } = useNuxtApp();
const { checkAndRedirect } = useLocaleRedirect();
const { loadUserBaseData } = useUserBaseData();
const cookieRefresh = useCookie($appHelpers.COOKIES_REFRESH_TOKEN_KEY, {
default: () => '',
path: '/',
});
const cookieAccess = useCookie($appHelpers.COOKIES_ACCESS_TOKEN_KEY, {
default: () => '',
path: '/',
});
const cookieLocale = useCookie($appHelpers.COOKIES_LOCALE_KEY, {
default: () => $appHelpers.DEFAULT_LOCALE,
path: '/',
});
const { mutate, loading } = useMutation<ILoginResponse>(LOGIN);
async function login(email: string, password: string, isStayLogin: boolean) {
try {
const result = await mutate({
email,
password,
});
const authData = result?.data?.obtainJwtToken;
if (!authData) return;
if (isStayLogin && authData.refreshToken) {
cookieRefresh.value = authData.refreshToken;
}
userStore.setUser(authData.user);
cookieAccess.value = authData.accessToken;
navigateTo(localePath('/'));
$notify({
message: t('popup.success.login'),
type: 'success',
});
if (authData.user.language !== cookieLocale.value) {
await checkAndRedirect(authData.user.language);
}
await loadUserBaseData(authData.user.email);
} catch (err: unknown) {
console.error('useLogin error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
$notify({
message,
type: 'error',
title: t('popup.errors.main'),
});
}
}
return {
loading,
login,
};
}

View file

@ -0,0 +1,33 @@
export function useLogout() {
const userStore = useUserStore();
const cartStore = useCartStore();
const wishlistStore = useWishlistStore();
const router = useRouter();
const { $appHelpers } = useNuxtApp();
const cookieRefresh = useCookie($appHelpers.COOKIES_REFRESH_TOKEN_KEY, {
default: () => '',
path: '/',
});
const cookieAccess = useCookie($appHelpers.COOKIES_ACCESS_TOKEN_KEY, {
default: () => '',
path: '/',
});
async function logout() {
userStore.setUser(null);
cartStore.setCurrentOrders(null);
wishlistStore.setWishlist(null);
cookieRefresh.value = '';
cookieAccess.value = '';
await router.push({
path: '/',
});
}
return {
logout,
};
}

View file

@ -0,0 +1,57 @@
import { NEW_PASSWORD } from '@graphql/mutations/auth.js';
import type { INewPasswordResponse } from '@types';
export function useNewPassword() {
const { t } = useI18n();
const router = useRouter();
const { $notify } = useNuxtApp();
const localePath = useLocalePath();
const token = useRouteQuery('token', '');
const uid = useRouteQuery('uid', '');
const { mutate, loading, error } = useMutation<INewPasswordResponse>(NEW_PASSWORD);
async function newPassword(password: string, confirmPassword: string) {
const result = await mutate({
password,
confirmPassword,
token: token.value,
uid: uid.value,
});
if (result?.data?.confirmResetPassword.success) {
$notify({
message: t('popup.success.newPassword'),
type: 'success',
});
await router.push({
path: '/',
});
navigateTo(localePath('/'));
}
}
watch(error, (err) => {
if (!err) return;
console.error('useNewPassword error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
$notify({
message,
type: 'error',
title: t('popup.errors.main'),
});
});
return {
newPassword,
loading,
};
}

View file

@ -0,0 +1,46 @@
import { RESET_PASSWORD } from '@graphql/mutations/auth.js';
import type { IPasswordResetResponse } from '@types';
export function usePasswordReset() {
const { t } = useI18n();
const { $notify } = useNuxtApp();
const appStore = useAppStore();
const { mutate, loading, error } = useMutation<IPasswordResetResponse>(RESET_PASSWORD);
async function resetPassword(email: string) {
const result = await mutate({
email,
});
if (result?.data?.resetPassword.success) {
$notify({
message: t('popup.success.reset'),
type: 'success',
});
appStore.unsetActiveAuthState();
}
}
watch(error, (err) => {
if (!err) return;
console.error('usePasswordReset error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
$notify({
message,
type: 'error',
title: t('popup.errors.main'),
});
});
return {
resetPassword,
loading,
};
}

View file

@ -0,0 +1,121 @@
import { useLogout } from '@composables/auth';
import { useLocaleRedirect } from '@composables/languages';
import { useUserBaseData } from '@composables/user';
import { REFRESH } from '@graphql/mutations/auth';
export function useRefresh() {
const { t } = useI18n();
const router = useRouter();
const localePath = useLocalePath();
const userStore = useUserStore();
const { $appHelpers, $notify } = useNuxtApp();
const { checkAndRedirect } = useLocaleRedirect();
const { loadUserBaseData } = useUserBaseData();
const { logout } = useLogout();
const { mutate, loading, error } = useMutation(REFRESH);
function isTokenInvalidError(error: unknown): boolean {
if (isGraphQLError(error)) {
const message = error.graphQLErrors?.[0]?.message?.toLowerCase() || '';
return (
message.includes('invalid refresh token') ||
message.includes('blacklist') ||
message.includes('expired') ||
message.includes('revoked')
);
}
return false;
}
async function refresh() {
const cookieRefresh = useCookie($appHelpers.COOKIES_REFRESH_TOKEN_KEY, {
default: () => '',
path: '/',
});
const cookieAccess = useCookie($appHelpers.COOKIES_ACCESS_TOKEN_KEY, {
default: () => '',
path: '/',
});
const cookieLocale = useCookie($appHelpers.COOKIES_LOCALE_KEY, {
default: () => $appHelpers.DEFAULT_LOCALE,
path: '/',
});
if (!cookieRefresh.value) {
return;
}
try {
const result = await mutate({
refreshToken: cookieRefresh.value,
});
const data = result?.data?.refreshJwtToken;
if (!data) {
return;
}
userStore.setUser(data.user);
cookieRefresh.value = data.refreshToken;
cookieAccess.value = data.accessToken;
if (data.user.language !== cookieLocale.value) {
await checkAndRedirect(data.user.language);
}
await loadUserBaseData(data.user.email);
} catch (err) {
if (isTokenInvalidError(err)) {
await logout();
await router.push(localePath('/'));
return;
}
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else if (err instanceof Error) {
message = err.message;
} else if (typeof err === 'string') {
message = err;
}
$notify({
message,
type: 'error',
title: t('popup.errors.main'),
});
}
}
watch(error, async (err) => {
if (!err) return;
if (isTokenInvalidError(err)) {
await logout();
await router.push(localePath('/'));
return;
}
console.error('useRefresh error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
$notify({
message,
type: 'error',
title: t('popup.errors.main'),
});
});
return {
refresh,
loading,
};
}

View file

@ -0,0 +1,83 @@
import { useMailClient } from '@composables/utils';
import { REGISTER } from '@graphql/mutations/auth.js';
import type { IRegisterResponse } from '@types';
interface IRegisterArguments {
firstName: string;
lastName: string;
phoneNumber: string;
email: string;
password: string;
confirmPassword: string;
referrer: string;
isSubscribed: boolean;
}
export function useRegister() {
const { t } = useI18n();
const { $notify } = useNuxtApp();
const appStore = useAppStore();
const { mailClientUrl, detectMailClient, openMailClient } = useMailClient();
const { mutate, loading, error } = useMutation<IRegisterResponse>(REGISTER);
async function register(payload: IRegisterArguments) {
const result = await mutate({
firstName: payload.firstName,
lastName: payload.lastName,
phoneNumber: payload.phoneNumber,
email: payload.email,
password: payload.password,
confirmPassword: payload.confirmPassword,
referrer: payload.referrer,
isSubscribed: payload.isSubscribed,
});
if (result?.data?.createUser?.success) {
detectMailClient(payload.email);
$notify({
message: h('div', [
h('p', t('popup.success.register')),
mailClientUrl.value
? h(
'button',
{
class: 'el-notification__button',
onClick: () => {
openMailClient();
},
},
t('buttons.goEmail'),
)
: '',
]),
type: 'success',
});
appStore.unsetActiveAuthState();
}
}
watch(error, (err) => {
if (!err) return;
console.error('useRegister error:', err);
let message = t('popup.errors.defaultError');
if (isGraphQLError(err)) {
message = err.graphQLErrors?.[0]?.message || message;
} else {
message = err.message;
}
$notify({
message,
type: 'error',
title: t('popup.errors.main'),
});
});
return {
register,
loading,
};
}

View file

@ -0,0 +1,2 @@
export * from './useBrandBySlug';
export * from './useBrands';

View file

@ -0,0 +1,29 @@
import { GET_BRAND_BY_SLUG } from '@graphql/queries/standalone/brands';
import type { IBrandsResponse } from '@types';
export async function useBrandBySlug(slug: string) {
const brand = computed(() => data.value?.brands.edges[0]?.node ?? null);
const { data, error } = await useAsyncQuery<IBrandsResponse>(GET_BRAND_BY_SLUG, {
slug,
});
if (!data.value?.brands?.edges?.length) {
throw createError({
status: 404,
statusText: 'Brand not found',
fatal: true
});
}
watch(error, (err) => {
if (err) {
console.error('useBrandsBySlug error:', err);
}
});
return {
brand,
seoMeta: computed(() => brand.value?.seoMeta),
};
}

View file

@ -0,0 +1,83 @@
import { GET_BRANDS } from '@graphql/queries/standalone/brands';
import type { IBrand, IBrandsResponse } from '@types';
interface IBrandArgs {
brandAfter?: string;
brandOrderBy?: string;
brandName?: string;
brandSearch?: string;
}
interface IBrandVars {
brandFirst: number;
brandAfter?: string;
brandOrderBy?: string;
brandName?: string;
brandSearch?: string;
}
export function useBrands(args: IBrandArgs = {}) {
const variables = reactive<IBrandVars>({
brandFirst: 45,
brandAfter: args.brandAfter,
brandOrderBy: args.orderBy,
brandName: args.brandName,
brandSearch: args.brandSearch,
});
const pending = ref<boolean>(false);
const brands = ref<IBrand[]>([]);
const pageInfo = ref<{
hasNextPage: boolean;
endCursor: string;
}>({
hasNextPage: false,
endCursor: '',
});
const error = ref<string | null>(null);
const getBrands = async (): Promise<void> => {
pending.value = true;
const queryVariables = {
brandFirst: variables.first,
brandAfter: variables.brandAfter || undefined,
brandOrderBy: variables.orderBy || undefined,
brandName: variables.brandName || undefined,
brandSearch: variables.brandSearch || undefined,
};
const { data, error: mistake } = await useAsyncQuery<IBrandsResponse>(GET_BRANDS, queryVariables);
if (data.value?.brands?.edges) {
pageInfo.value = data.value?.brands.pageInfo;
if (variables.brandAfter) {
brands.value = [
...brands.value,
...data.value.brands.edges,
];
} else {
brands.value = data.value?.brands.edges;
}
}
if (mistake.value) {
error.value = mistake.value;
}
pending.value = false;
};
watch(error, (e) => {
if (e) console.error('useBrands error:', e);
});
return {
pending,
brands,
pageInfo,
variables,
getBrands,
};
}

View file

@ -0,0 +1 @@
export * from './useBreadcrumbs';

View file

@ -0,0 +1,88 @@
import type { ICategory, IProduct } from '@types';
interface Crumb {
text: string;
link?: string;
}
function findCategoryPath(
nodes: ICategory[],
targetSlug: string,
path: ICategory[] = [],
): ICategory[] | null {
for (const node of nodes) {
const newPath = [
...path,
node,
];
if (node.slug === targetSlug) {
return newPath;
}
if (node.children?.length) {
const found = findCategoryPath(node.children, targetSlug, newPath);
if (found) {
return found;
}
}
}
return null;
}
export function useBreadcrumbs() {
const { t } = useI18n();
const route = useRoute();
const pageTitle = useState<string>('pageTitle');
const categoryStore = useCategoryStore();
const product = useState<IProduct | null>('currentProduct');
const breadcrumbs = computed<Crumb[]>(() => {
const crumbs: Crumb[] = [
{
text: t('breadcrumbs.home'),
link: '/',
},
];
if (route.path.includes('/catalog') || route.path.includes('/product')) {
crumbs.push({
text: t('breadcrumbs.catalog'),
link: '/catalog',
});
let categorySlug: string | undefined;
if (route.path.includes('/catalog')) {
categorySlug = route.params.categorySlug as string;
} else if (route.path.includes('/product')) {
categorySlug = product.value?.category?.slug;
}
if (categorySlug) {
const roots = categoryStore.categories.map((e) => e.node);
const path = findCategoryPath(roots, categorySlug);
path?.forEach((node) => {
crumbs.push({
text: node.name,
link: `/catalog/${node.slug}`,
});
});
}
if (route.path.includes('/product') && product.value) {
crumbs.push({
text: product.value.name,
});
}
} else {
const routeNameWithoutLocale = String(route.name).split('___')[0];
crumbs.push({
text: pageTitle.value || t(`breadcrumbs.${routeNameWithoutLocale}`),
});
}
return crumbs;
});
return {
breadcrumbs,
};
}

View file

@ -0,0 +1,4 @@
export * from './useCategories';
export * from './useCategoryBySlug';
export * from './useCategoryBySlugSeo';
export * from './useCategoryTags';

View file

@ -0,0 +1,39 @@
import { GET_CATEGORIES } from '@graphql/queries/standalone/categories';
import type { ICategoriesResponse } from '@types';
export async function useCategories() {
const categoryStore = useCategoryStore();
const { locale } = useI18n();
const getCategories = async (cursor?: string): Promise<void> => {
const { data, error } = await useAsyncQuery<ICategoriesResponse>(GET_CATEGORIES, {
level: 0,
whole: true,
categoryAfter: cursor,
});
if (!error.value && data.value?.categories.edges) {
if (!cursor) {
categoryStore.setCategories(data.value.categories.edges);
} else {
categoryStore.addCategories(data.value.categories.edges);
}
const pageInfo = data.value.categories.pageInfo;
if (pageInfo?.hasNextPage && pageInfo.endCursor) {
await getCategories(pageInfo.endCursor);
}
}
if (error.value) console.error('useCategories error:', error.value);
};
watch(locale, async () => {
categoryStore.setCategories([]);
await getCategories();
});
return {
getCategories,
};
}

Some files were not shown because too many files have changed in this diff Show more