diff --git a/libs/base/ui/action/button/button.model.ts b/libs/base/ui/action/button/button.model.ts index 84b5403c22..d1e346d7b8 100644 --- a/libs/base/ui/action/button/button.model.ts +++ b/libs/base/ui/action/button/button.model.ts @@ -118,4 +118,6 @@ export const enum ButtonType { * A button which is rendered as icon. */ Icon = 'icon', + + Tile = 'tile', } diff --git a/libs/base/ui/action/button/button.styles.ts b/libs/base/ui/action/button/button.styles.ts index abd5e80c69..0dbe309010 100644 --- a/libs/base/ui/action/button/button.styles.ts +++ b/libs/base/ui/action/button/button.styles.ts @@ -1,4 +1,5 @@ import { css } from 'lit'; +import { HeadingTag, headingUtil } from '../../structure/heading/src'; const baseStyles = css` :host { @@ -259,9 +260,33 @@ const loadingStyles = css` } `; +const tileStyles = css` + :host([type='tile']) { + color: var(--oryx-button-color, var(--_text-color)); + } + + :host([type='tile']) :is(a, button), + :host([type='tile']) ::slotted(:is(a, button)) { + --oryx-icon-size: 32px; + + ${headingUtil(HeadingTag.Subtitle)} + + text-transform: uppercase; + height: 68px; + width: 62px; + /* max-width: 62px; */ + display: grid; + align-content: center; + justify-items: center; + gap: 5px; + padding: 12px 8px; + } +`; + export const buttonStyles = css` ${baseStyles} ${sizeStyles} ${colorStyles} ${loadingStyles} + ${tileStyles} `; diff --git a/libs/base/ui/action/link/src/styles/storefront.styles.ts b/libs/base/ui/action/link/src/styles/storefront.styles.ts index 3367c483e0..e9fcc3c22f 100644 --- a/libs/base/ui/action/link/src/styles/storefront.styles.ts +++ b/libs/base/ui/action/link/src/styles/storefront.styles.ts @@ -5,6 +5,7 @@ export const storefrontLinkStyles = css` position: relative; display: inline-flex; width: var(--oryx-link-width); + cursor: pointer; } :host([icon]) { diff --git a/libs/base/ui/graphical/icon/src/icon.types.ts b/libs/base/ui/graphical/icon/src/icon.types.ts index f0acefe27e..9d177a3678 100644 --- a/libs/base/ui/graphical/icon/src/icon.types.ts +++ b/libs/base/ui/graphical/icon/src/icon.types.ts @@ -100,6 +100,7 @@ export enum IconTypes { Carrier = 'carrier', Location = 'location_on', Login = 'login', + Logout = 'logout', List = 'list', ViewList = 'view_list', BulletList = 'format_list_bulleted', diff --git a/libs/base/ui/overlays/dropdown/dropdown.styles.ts b/libs/base/ui/overlays/dropdown/dropdown.styles.ts index 34dffbd9b1..608eb1fff2 100644 --- a/libs/base/ui/overlays/dropdown/dropdown.styles.ts +++ b/libs/base/ui/overlays/dropdown/dropdown.styles.ts @@ -5,7 +5,6 @@ import { Position } from './dropdown.model'; export const dropdownBaseStyles = css` :host { - --oryx-popover-top-space: 4px; --oryx-popover-vertical-offset: 10px; --oryx-popover-maxwidth: 206px; @@ -32,7 +31,7 @@ export const dropdownBaseStyles = css` ), var(--oryx-popover-maxheight, ${unsafecss(POPOVER_HEIGHT)}px) ); - width: var(--_oryx-dropdown-width); + width: var(--oryx-dropdown-width, var(--_oryx-dropdown-width)); inset-block-start: 0; inset-inline: var(--_oryx-dropdown-start-offset, auto) var(--_oryx-dropdown-end-offset, auto); @@ -49,9 +48,8 @@ export const dropdownBaseStyles = css` ${featureVersion >= `1.3` ? css` - :host(:not([vertical-align])) oryx-popover { - transform: scaleX(var(--oryx-popover-visible, 0)) - scaleY(var(--oryx-popover-visible, 0)); + :host([vertical-align]) oryx-popover { + transform: scaleY(var(--oryx-popover-visible, 0)); } ` : css``} @@ -95,7 +93,7 @@ export const dropdownBaseStyles = css` } :host([vertical-align]:not([up])) oryx-popover { - inset-block-start: calc(100% + var(--oryx-popover-top-space)); + inset-block-start: calc(100% + var(--oryx-popover-top-space, 4px)); } :host([vertical-align][up]) oryx-popover { diff --git a/libs/base/ui/overlays/popover/src/styles/oryx.styles.ts b/libs/base/ui/overlays/popover/src/styles/oryx.styles.ts index d6a45a083e..7a09ab607b 100644 --- a/libs/base/ui/overlays/popover/src/styles/oryx.styles.ts +++ b/libs/base/ui/overlays/popover/src/styles/oryx.styles.ts @@ -3,8 +3,12 @@ import { css } from 'lit'; export const popoverStyles = css` :host { background-color: var(--oryx-color-neutral-1); - box-shadow: var(--oryx-elevation-2) var(--oryx-color-elevation); - border-radius: var(--oryx-border-radius-small); + box-shadow: var(--oryx-shadow-hovering) var(--oryx-color-elevation); + border-radius: var( + --oryx-popover-border-radius, + var(--oryx-border-radius-small) + ); transition: transform var(--oryx-transition-time) ease-in-out; + width: var(--oryx-popover-width); } `; diff --git a/libs/domain/content/link/link.component.ts b/libs/domain/content/link/link.component.ts index a2f1efe129..f9a08682a2 100644 --- a/libs/domain/content/link/link.component.ts +++ b/libs/domain/content/link/link.component.ts @@ -36,7 +36,7 @@ export class ContentLinkComponent extends ContentMixin< protected $link = computed(() => { const { url, type, id, params } = this.$options(); if (url) return of(url); - if (type) return this.semanticLinkService.get({ type: type, id, params }); + if (type) return this.semanticLinkService.get({ type, id, params }); return of(null); }); @@ -56,9 +56,9 @@ export class ContentLinkComponent extends ContentMixin< const { button, icon, singleLine, color } = this.$options(); if (button) { - return html`${this.renderLink(true)}`; + return html` + ${this.renderLink(true)} + `; } return html` category?.name)); } @@ -94,7 +94,7 @@ export class ContentLinkComponent extends ContentMixin< }); protected renderLink(custom?: boolean): TemplateResult { - if (!this.$link()) return html`${this.$text()}`; + if (!this.$link()) return html`${this.$text()}`; const { label, target } = this.$options(); diff --git a/libs/domain/content/link/link.styles.ts b/libs/domain/content/link/link.styles.ts index 83040de08b..bff6ffb264 100644 --- a/libs/domain/content/link/link.styles.ts +++ b/libs/domain/content/link/link.styles.ts @@ -2,6 +2,7 @@ import { css } from 'lit'; export const contentLinkStyles = css` :host { + display: block; padding: var(--oryx-content-link-padding); } diff --git a/libs/domain/product/src/services/resolvers/breadcrumb/product-details.resolver.ts b/libs/domain/product/src/services/resolvers/breadcrumb/product-details.resolver.ts index 7ceea78f19..bf6e335f54 100644 --- a/libs/domain/product/src/services/resolvers/breadcrumb/product-details.resolver.ts +++ b/libs/domain/product/src/services/resolvers/breadcrumb/product-details.resolver.ts @@ -70,7 +70,7 @@ export class ProductDetailsBreadcrumbResolver implements BreadcrumbResolver { } return combineLatest( - categoryIds.map((id) => this.categoryService.getTrail(id)) + categoryIds.map((id) => this.categoryService.getTrail({ id })) ).pipe( switchMap((trails) => combineLatest([ diff --git a/libs/domain/search/src/services/resolvers/breadcrumb.resolver.ts b/libs/domain/search/src/services/resolvers/breadcrumb.resolver.ts index 6e154afe4b..ee130e9dbb 100644 --- a/libs/domain/search/src/services/resolvers/breadcrumb.resolver.ts +++ b/libs/domain/search/src/services/resolvers/breadcrumb.resolver.ts @@ -22,7 +22,7 @@ export class CategoryBreadcrumbResolver implements BreadcrumbResolver { switchMap((category) => this.categoryService // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - .getTrail(String((category as ValueFacet).selectedValues![0])) + .getTrail({ id: String((category as ValueFacet).selectedValues![0]) }) .pipe( switchMap((trail) => combineLatest( diff --git a/libs/domain/user/navigation-control/index.ts b/libs/domain/user/navigation-control/index.ts new file mode 100644 index 0000000000..49063f6ba8 --- /dev/null +++ b/libs/domain/user/navigation-control/index.ts @@ -0,0 +1 @@ +export * from './navigation-control.component'; diff --git a/libs/domain/user/navigation-control/navigation-control.component.ts b/libs/domain/user/navigation-control/navigation-control.component.ts new file mode 100644 index 0000000000..ad1f26268c --- /dev/null +++ b/libs/domain/user/navigation-control/navigation-control.component.ts @@ -0,0 +1,28 @@ +import { AuthService } from '@spryker-oryx/auth'; +import { resolve } from '@spryker-oryx/di'; +import { ContentMixin } from '@spryker-oryx/experience'; +import { ButtonType } from '@spryker-oryx/ui/button'; +import { IconTypes } from '@spryker-oryx/ui/icon'; +import { UserService } from '@spryker-oryx/user'; +import { hydrate, signal } from '@spryker-oryx/utilities'; +import { LitElement, TemplateResult, html } from 'lit'; + +@hydrate() +export class UserNavigationControlComponent extends ContentMixin(LitElement) { + protected authService = resolve(AuthService); + protected userService = resolve(UserService); + + protected $isAuthenticated = signal(this.authService.isAuthenticated()); + protected $user = signal(this.userService.getUser()); + + protected override render(): TemplateResult | void { + return html` + + + ${this.$isAuthenticated() + ? this.$user()?.firstName ?? '' + : this.i18n('auth.login')} + + `; + } +} diff --git a/libs/domain/user/navigation-control/navigation-control.def.ts b/libs/domain/user/navigation-control/navigation-control.def.ts new file mode 100644 index 0000000000..dcdd71b74b --- /dev/null +++ b/libs/domain/user/navigation-control/navigation-control.def.ts @@ -0,0 +1,9 @@ +import { componentDef } from '@spryker-oryx/utilities'; + +export const userNavigationControlComponent = componentDef({ + name: 'oryx-user-navigation-control', + impl: () => + import('./navigation-control.component.js').then( + (m) => m.UserNavigationControlComponent + ), +}); diff --git a/libs/domain/user/src/components.ts b/libs/domain/user/src/components.ts index 12ffe2440a..8c4321ab47 100644 --- a/libs/domain/user/src/components.ts +++ b/libs/domain/user/src/components.ts @@ -7,4 +7,5 @@ export * from '../address-list/address-list.def'; export * from '../address-remove/address-remove.def'; export * from '../address/address.def'; export * from '../contact-form/contact-form.def'; +export * from '../navigation-control/navigation-control.def'; export * from '../registration/registration.def'; diff --git a/libs/platform/auth/logout-link/index.ts b/libs/platform/auth/logout-link/index.ts new file mode 100644 index 0000000000..68cf1a9979 --- /dev/null +++ b/libs/platform/auth/logout-link/index.ts @@ -0,0 +1,3 @@ +export * from './logout-link.component'; +export * from './logout-link.model'; +export * from './logout-link.schema'; diff --git a/libs/platform/auth/logout-link/logout-link.component.spec.ts b/libs/platform/auth/logout-link/logout-link.component.spec.ts new file mode 100644 index 0000000000..48ed502f5e --- /dev/null +++ b/libs/platform/auth/logout-link/logout-link.component.spec.ts @@ -0,0 +1,105 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { AuthService, logoutLinkComponent } from '@spryker-oryx/auth'; +import { createInjector, destroyInjector } from '@spryker-oryx/di'; +import { RouterService } from '@spryker-oryx/router'; +import { useComponent } from '@spryker-oryx/utilities'; +import { html } from 'lit'; +import { of } from 'rxjs'; +import { LogoutLinkComponent } from './logout-link.component'; + +class MockAuthService implements Partial { + logout = vi.fn().mockReturnValue(of(null)); + isAuthenticated = vi.fn().mockReturnValue(of(false)); +} + +class MockRouterService implements Partial { + navigate = vi.fn(); +} + +describe('LogoutLinkComponent', () => { + let element: LogoutLinkComponent; + let authService: MockAuthService; + let routerService: MockRouterService; + + const clickButton = (): void => { + element.renderRoot.querySelector('oryx-content-link')?.click(); + }; + + beforeAll(async () => { + await useComponent(logoutLinkComponent); + }); + + beforeEach(async () => { + const testInjector = createInjector({ + providers: [ + { provide: AuthService, useClass: MockAuthService }, + { provide: RouterService, useClass: MockRouterService }, + ], + }); + authService = testInjector.inject(AuthService); + routerService = testInjector.inject(RouterService); + }); + + afterEach(() => { + destroyInjector(); + }); + + describe('when is not authenticated', () => { + it('passes the a11y audit', async () => { + await expect(element).shadowDom.to.be.accessible(); + }); + + it('should not render anything', () => { + expect(element).not.toContainElement('*'); + }); + }); + + describe.only('when is authenticated', () => { + beforeEach(async () => { + authService.isAuthenticated = vi.fn().mockReturnValue(of(true)); + element = await fixture( + html`` + ); + }); + + it('passes the a11y audit', async () => { + await expect(element).shadowDom.to.be.accessible(); + }); + + describe('and link is clicked', () => { + beforeEach(() => { + clickButton(); + }); + + it('should emit the logout', () => { + expect(authService.logout).toHaveBeenCalled(); + }); + + describe('and redirect route is not provided', () => { + beforeEach(async () => { + clickButton(); + }); + + it('should redirect to the route', () => { + expect(routerService.navigate).not.toHaveBeenCalled(); + }); + }); + + describe('and redirect route is provided', () => { + const redirectUrl = '/test'; + + beforeEach(async () => { + element = await fixture(html` + `); + clickButton(); + }); + + it('should redirect to the route', () => { + expect(routerService.navigate).toHaveBeenCalledWith(redirectUrl); + }); + }); + }); + }); +}); diff --git a/libs/platform/auth/logout-link/logout-link.component.ts b/libs/platform/auth/logout-link/logout-link.component.ts new file mode 100644 index 0000000000..cd1d9288b1 --- /dev/null +++ b/libs/platform/auth/logout-link/logout-link.component.ts @@ -0,0 +1,40 @@ +import { AuthService } from '@spryker-oryx/auth'; +import { resolve } from '@spryker-oryx/di'; +import { ContentMixin } from '@spryker-oryx/experience'; +import { RouterService } from '@spryker-oryx/router'; +import { hydrate, signal } from '@spryker-oryx/utilities'; +import { LitElement, TemplateResult, html } from 'lit'; +import { LogoutLinkOptions } from './logout-link.model'; + +@hydrate({ event: 'window:load' }) +export class LogoutLinkComponent extends ContentMixin( + LitElement +) { + protected authService = resolve(AuthService); + protected routerService = resolve(RouterService); + + protected $isAuthenticated = signal(this.authService.isAuthenticated()); + + protected override render(): TemplateResult | void { + if (!this.$isAuthenticated()) return; + + const icon = this.$options()?.icon; + return html` + + + `; + } + + protected onClick(): void { + this.authService.logout().subscribe(() => { + const { redirectUrl } = this.$options(); + if (redirectUrl) { + this.routerService.navigate(redirectUrl); + } + }); + } +} diff --git a/libs/platform/auth/logout-link/logout-link.def.ts b/libs/platform/auth/logout-link/logout-link.def.ts new file mode 100644 index 0000000000..5f5254a455 --- /dev/null +++ b/libs/platform/auth/logout-link/logout-link.def.ts @@ -0,0 +1,16 @@ +import { componentDef } from '@spryker-oryx/utilities'; +import { LogoutLinkOptions } from './logout-link.model'; + +declare global { + interface FeatureOptions { + 'oryx-auth-logout-link'?: LogoutLinkOptions; + } +} + +export const logoutLinkComponent = componentDef({ + name: 'oryx-auth-logout-link', + impl: () => + import('./logout-link.component').then((m) => m.LogoutLinkComponent), + schema: () => + import('./logout-link.schema').then((m) => m.logoutLinkComponentSchema), +}); diff --git a/libs/platform/auth/logout-link/logout-link.model.ts b/libs/platform/auth/logout-link/logout-link.model.ts new file mode 100644 index 0000000000..d00856e6e0 --- /dev/null +++ b/libs/platform/auth/logout-link/logout-link.model.ts @@ -0,0 +1,7 @@ +import { IconTypes } from '@spryker-oryx/ui/icon'; + +export interface LogoutLinkOptions { + icon?: IconTypes; + redirectUrl?: string; + notify?: boolean; +} diff --git a/libs/platform/auth/logout-link/logout-link.schema.ts b/libs/platform/auth/logout-link/logout-link.schema.ts new file mode 100644 index 0000000000..dfb16a217c --- /dev/null +++ b/libs/platform/auth/logout-link/logout-link.schema.ts @@ -0,0 +1,24 @@ +import { ContentComponentSchema } from '@spryker-oryx/experience'; +import { FormFieldType } from '@spryker-oryx/form'; +import { IconTypes } from '@spryker-oryx/ui/icon'; +import { iconInjectable } from '@spryker-oryx/utilities'; +import { LogoutLinkComponent } from './logout-link.component'; + +export const logoutLinkComponentSchema: ContentComponentSchema = + { + name: 'Logout Link', + group: 'Auth', + icon: IconTypes.Input, + options: { + redirectUrl: { type: FormFieldType.Text }, + icon: { + type: FormFieldType.Select, + options: + iconInjectable + .get() + ?.getIcons() + .sort() + .map((i) => ({ value: i, text: i })) ?? [], + }, + }, + }; diff --git a/libs/platform/auth/logout-link/logout-link.styles.ts b/libs/platform/auth/logout-link/logout-link.styles.ts new file mode 100644 index 0000000000..d6b98eab03 --- /dev/null +++ b/libs/platform/auth/logout-link/logout-link.styles.ts @@ -0,0 +1,7 @@ +import { css } from 'lit'; + +export const logoutStyles = css` + :host { + /* display: contents; */ + } +`; diff --git a/libs/platform/auth/src/bapi/feature.ts b/libs/platform/auth/src/bapi/feature.ts index 87acaa40b9..cdd615487e 100644 --- a/libs/platform/auth/src/bapi/feature.ts +++ b/libs/platform/auth/src/bapi/feature.ts @@ -1,11 +1,12 @@ import { - authLoginComponent, CodeGrantAuthLoginStrategy, CodeGrantAuthLoginStrategyConfig, IdentityService, - loginLinkComponent, OauthFeature, OauthFeatureConfig, + authLoginComponent, + loginLinkComponent, + logoutLinkComponent, oauthHandlerComponent, } from '@spryker-oryx/auth'; import { AuthLoginStrategy } from '@spryker-oryx/auth/login'; @@ -100,6 +101,7 @@ export class BapiAuthComponentsFeature implements AppFeature { components: ComponentsInfo = [ authLoginComponent, loginLinkComponent, + logoutLinkComponent, oauthHandlerComponent, ]; } diff --git a/libs/platform/auth/src/components.ts b/libs/platform/auth/src/components.ts index 829d523f44..fe31af3f98 100644 --- a/libs/platform/auth/src/components.ts +++ b/libs/platform/auth/src/components.ts @@ -1,3 +1,4 @@ export * from '../login-link/login-link.def'; export * from '../login/login.def'; +export * from '../logout-link/logout-link.def'; export * from '../oauth-handler/oauth-handler.def'; diff --git a/libs/platform/auth/src/sapi/feature.ts b/libs/platform/auth/src/sapi/feature.ts index e719ab4bff..e3f272d231 100644 --- a/libs/platform/auth/src/sapi/feature.ts +++ b/libs/platform/auth/src/sapi/feature.ts @@ -2,19 +2,20 @@ import { AnonAuthTokenService, AnonTokenInterceptor, AnonTokenInterceptorConfig, - authLoginComponent, AuthTokenService, IdentityService, - loginLinkComponent, OauthFeature, OauthFeatureConfig, OauthService, PasswordGrantAuthLoginStrategy, PasswordGrantAuthLoginStrategyConfig, + authLoginComponent, + loginLinkComponent, + logoutLinkComponent, } from '@spryker-oryx/auth'; import { AuthLoginStrategy } from '@spryker-oryx/auth/login'; import { AppFeature, HttpInterceptor } from '@spryker-oryx/core'; -import { inject, Provider } from '@spryker-oryx/di'; +import { Provider, inject } from '@spryker-oryx/di'; import { ComponentsInfo, featureVersion } from '@spryker-oryx/utilities'; import { GuestIdentityInterceptor, @@ -115,5 +116,9 @@ export interface SapiAuthFeatureConfig extends OauthFeatureConfig { } export class SapiAuthComponentsFeature implements AppFeature { - components: ComponentsInfo = [authLoginComponent, loginLinkComponent]; + components: ComponentsInfo = [ + authLoginComponent, + loginLinkComponent, + logoutLinkComponent, + ]; } diff --git a/libs/platform/experience/layout/stories/static/divider.stories.ts b/libs/platform/experience/layout/stories/static/divider.stories.ts new file mode 100644 index 0000000000..2a9ffa3b33 --- /dev/null +++ b/libs/platform/experience/layout/stories/static/divider.stories.ts @@ -0,0 +1,57 @@ +import { Meta, Story } from '@storybook/web-components'; +import { TemplateResult, html } from 'lit'; +import { storybookPrefix } from '../../../.constants'; + +export default { + title: `${storybookPrefix}/Layout/Static/Features`, + args: {}, + argTypes: {}, + chromatic: { + delay: 1000, + }, +} as Meta; + +const Template: Story = (): TemplateResult => { + return html` +

Divided flex items (horizontal)

+ +
a
+
b
+
c
+
+ +

Divided flex items (vertical)

+ +
a
+
b
+
c
+
+ +

Divided grid items

+ +
a
+
b
+
c
+
+ +

Divided carousel items

+ +
a
+
b
+
c
+
+ +

Custom divider (color, width)

+ +
a
+
b
+
c
+
+ `; +}; + +export const Divider = Template.bind({}); diff --git a/libs/platform/experience/src/services/layout/plugins/layout-plugins.providers.ts b/libs/platform/experience/src/services/layout/plugins/layout-plugins.providers.ts index 5595fcbae7..7a80ed7a4e 100644 --- a/libs/platform/experience/src/services/layout/plugins/layout-plugins.providers.ts +++ b/libs/platform/experience/src/services/layout/plugins/layout-plugins.providers.ts @@ -102,6 +102,13 @@ export const layoutPluginsProviders: Provider[] = [ (m) => m.NavigationLayoutPlugin ), }, + // { + // provide: DropdownLayoutPluginToken, + // asyncClass: () => + // import('./types/dropdown/dropdown.plugin').then( + // (m) => m.DropdownLayoutPlugin + // ), + // }, { provide: StickyLayoutPluginToken, asyncClass: () => diff --git a/libs/platform/experience/src/services/layout/plugins/types/dropdown/dropdown.model.ts b/libs/platform/experience/src/services/layout/plugins/types/dropdown/dropdown.model.ts new file mode 100644 index 0000000000..44cde0ca52 --- /dev/null +++ b/libs/platform/experience/src/services/layout/plugins/types/dropdown/dropdown.model.ts @@ -0,0 +1,13 @@ +import { LayoutPlugin } from '../../layout.plugin'; + +export const DropdownLayoutPluginToken = `${LayoutPlugin}dropdown`; + +declare global { + export interface Layouts { + dropdown?: boolean; + } + + export interface LayoutProperty { + dropdownTrigger?: string; + } +} diff --git a/libs/platform/experience/src/services/layout/plugins/types/dropdown/dropdown.plugin.ts b/libs/platform/experience/src/services/layout/plugins/types/dropdown/dropdown.plugin.ts new file mode 100644 index 0000000000..cf5735c396 --- /dev/null +++ b/libs/platform/experience/src/services/layout/plugins/types/dropdown/dropdown.plugin.ts @@ -0,0 +1,45 @@ +import { ssrAwaiter } from '@spryker-oryx/core/utilities'; +import { html } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { when } from 'lit/directives/when.js'; +import { Observable, of } from 'rxjs'; +import { LayoutStyles } from '../../../layout.model'; +import { + LayoutPlugin, + LayoutPluginConfig, + LayoutPluginOptionsParams, + LayoutPluginRender, + LayoutPluginRenderParams, +} from '../../layout.plugin'; + +export class DropdownLayoutPlugin implements LayoutPlugin { + getStyles(data: LayoutPluginOptionsParams): Observable { + return ssrAwaiter( + import('./dropdown.styles').then((m) => { + return { + styles: `${m.dropdownStyles}`, + }; + }) + ); + } + + getConfig(): Observable { + return of({ + schema: () => import('./dropdown.schema').then((m) => m.schema), + }); + } + + getRender( + data: LayoutPluginRenderParams + ): Observable { + const trigger = data.options.dropdownTrigger; + return of({ + outer: html` + ${when(trigger, () => + unsafeHTML(`<${trigger} slot="trigger">`) + )} +
${data.template}
+
`, + }); + } +} diff --git a/libs/platform/experience/src/services/layout/plugins/types/dropdown/dropdown.schema.ts b/libs/platform/experience/src/services/layout/plugins/types/dropdown/dropdown.schema.ts new file mode 100644 index 0000000000..65fe9437f2 --- /dev/null +++ b/libs/platform/experience/src/services/layout/plugins/types/dropdown/dropdown.schema.ts @@ -0,0 +1,5 @@ +import { ContentComponentSchema } from '@spryker-oryx/experience'; +export const schema: ContentComponentSchema = { + name: 'dropdown', + group: 'layout', +}; diff --git a/libs/platform/experience/src/services/layout/plugins/types/dropdown/dropdown.styles.ts b/libs/platform/experience/src/services/layout/plugins/types/dropdown/dropdown.styles.ts new file mode 100644 index 0000000000..2b8925d62a --- /dev/null +++ b/libs/platform/experience/src/services/layout/plugins/types/dropdown/dropdown.styles.ts @@ -0,0 +1,31 @@ +import { css } from 'lit'; + +export const dropdownStyles = css` + :host { + --oryx-link-icon-size: 24px; + --oryx-link-width: 100%; + --oryx-link-color: currentColor; + --oryx-link-decoration: none; + + display: flex; + } + + oryx-content-link, + ::slotted(oryx-content-link) { + display: flex; + flex-wrap: wrap; + align-self: stretch; + } + + :host { + --oryx-popover-border-radius: 0; + --oryx-content-link-padding: 0 0 0 12px; + --oryx-link-padding: 8px 12px 8px 0; + --oryx-link-hover-background: var(--oryx-color-neutral-3); + --oryx-link-active-background: var(--oryx-color-primary-5); + --oryx-link-hover-shadow: none; + --oryx-link-active-shadow: none; + --oryx-link-current-shadow: none; + --oryx-link-current-color: var(--oryx-color-primary-9); + } +`; diff --git a/libs/platform/experience/src/services/layout/plugins/types/dropdown/index.ts b/libs/platform/experience/src/services/layout/plugins/types/dropdown/index.ts new file mode 100644 index 0000000000..bc7cfb7656 --- /dev/null +++ b/libs/platform/experience/src/services/layout/plugins/types/dropdown/index.ts @@ -0,0 +1 @@ +export * from './dropdown.model'; diff --git a/libs/platform/experience/src/services/layout/plugins/types/navigation/navigation-layout.model.ts b/libs/platform/experience/src/services/layout/plugins/types/navigation/navigation-layout.model.ts index f82512c623..b05cac3710 100644 --- a/libs/platform/experience/src/services/layout/plugins/types/navigation/navigation-layout.model.ts +++ b/libs/platform/experience/src/services/layout/plugins/types/navigation/navigation-layout.model.ts @@ -7,10 +7,11 @@ declare global { navigation: undefined; } - // eslint-disable-next-line @typescript-eslint/no-empty-interface - export interface LayoutProperty extends NavigationLayoutProperties {} -} - -export interface NavigationLayoutProperties { - navigationType?: 'flyout' | 'dropdown'; + export interface LayoutProperty { + navigationType?: 'dropdown'; + dropdown?: boolean; + dropdownTrigger?: string; + dropdownVerticalAlign?: boolean; + dropdownPosition?: 'start' | 'end'; + } } diff --git a/libs/platform/experience/src/services/layout/plugins/types/navigation/navigation-layout.plugin.ts b/libs/platform/experience/src/services/layout/plugins/types/navigation/navigation-layout.plugin.ts index b2af2477ee..be0a037c09 100644 --- a/libs/platform/experience/src/services/layout/plugins/types/navigation/navigation-layout.plugin.ts +++ b/libs/platform/experience/src/services/layout/plugins/types/navigation/navigation-layout.plugin.ts @@ -1,10 +1,13 @@ import { ssrAwaiter } from '@spryker-oryx/core/utilities'; +import { html } from 'lit'; import { Observable, of } from 'rxjs'; import { LayoutStyles } from '../../../layout.model'; import { LayoutPlugin, LayoutPluginConfig, LayoutPluginOptionsParams, + LayoutPluginRender, + LayoutPluginRenderParams, } from '../../layout.plugin'; export class NavigationLayoutPlugin implements LayoutPlugin { @@ -17,7 +20,12 @@ export class NavigationLayoutPlugin implements LayoutPlugin { ? m.verticalStyles : m.horizontalStyles; - return { styles: `${m.styles.styles}${direction}` }; + // TODO: only load dropdown styles if one of the components requires dropdown + // const dropdownStyles = data.options.dropdown ? m.dropdownStyles : ''; + + return { + styles: `${m.styles.styles}${direction}`, + }; }) ); } @@ -27,4 +35,36 @@ export class NavigationLayoutPlugin implements LayoutPlugin { schema: () => import('./navigation-layout.schema').then((m) => m.schema), }); } + + getRender( + data: LayoutPluginRenderParams + ): Observable { + const itemLayout = data.experience?.options?.rules?.[0]?.layout as + | LayoutProperty + | undefined; + + if (!itemLayout?.dropdown) return of(); + + return of({ + inner: html` + ${data.template} + + `, + }); + } } diff --git a/libs/platform/experience/src/services/layout/plugins/types/navigation/navigation-layout.styles.ts b/libs/platform/experience/src/services/layout/plugins/types/navigation/navigation-layout.styles.ts index 894b1aba33..4a22afb36f 100644 --- a/libs/platform/experience/src/services/layout/plugins/types/navigation/navigation-layout.styles.ts +++ b/libs/platform/experience/src/services/layout/plugins/types/navigation/navigation-layout.styles.ts @@ -44,3 +44,19 @@ export const verticalStyles = css` flex-direction: column; } `; + +export const dropdownStyles = css` + :host { + --oryx-popover-border-radius: 0; + --oryx-content-link-padding: 0 0 0 12px; + --oryx-link-padding: 8px 12px 8px 0; + --oryx-link-hover-background: var(--oryx-color-neutral-3); + --oryx-link-active-background: var(--oryx-color-primary-5); + --oryx-link-hover-shadow: none; + --oryx-link-active-shadow: none; + --oryx-link-current-shadow: none; + --oryx-link-current-color: var(--oryx-color-primary-9); + + display: contents; + } +`; diff --git a/libs/template/labs/src/account/account-navigation.ref.ts b/libs/template/labs/src/account/account-navigation.ref.ts new file mode 100644 index 0000000000..36ff30a3a8 --- /dev/null +++ b/libs/template/labs/src/account/account-navigation.ref.ts @@ -0,0 +1,63 @@ +import { ExperienceComponent } from '@spryker-oryx/experience'; +import { IconTypes } from '@spryker-oryx/ui/icon'; +import { pages } from './types'; + +export const accountNavigation: ExperienceComponent = { + type: 'oryx-composition', + id: 'accountNavigation', + + options: { + rules: [ + { + layout: { type: 'navigation', vertical: true }, + gap: '0px', + }, + ], + }, + + // render all pages from pages config + components: pages.map((page) => ({ + type: 'oryx-content-link', + content: { data: { text: page.type } }, + options: { id: page.type, type: page.route, icon: page.icon }, + })), +}; + +export const userHeaderNavigation = { + id: 'user-header-navigation', + type: 'oryx-user-navigation-control', + components: [ + { + type: 'oryx-content-link', + options: { + type: 'account-overview', + id: 'overview', + icon: IconTypes.User, + }, + content: { data: { text: 'Overview' } }, + }, + { + type: 'oryx-content-link', + options: { + type: 'account-profile', + id: 'profile', + icon: 'badge', + }, + content: { data: { text: 'Profile' } }, + }, + { + type: 'oryx-content-link', + options: { + type: 'account-orders', + id: 'orders', + icon: IconTypes.History, + }, + content: { data: { text: 'Order History' } }, + }, + { + type: 'oryx-auth-logout-link', + options: { icon: IconTypes.Logout }, + }, + ], + options: { rules: [{ layout: { navigationType: 'dropdown2' } }] }, +}; diff --git a/libs/template/labs/src/account/account-routes.ts b/libs/template/labs/src/account/account-routes.ts new file mode 100644 index 0000000000..9b570697ad --- /dev/null +++ b/libs/template/labs/src/account/account-routes.ts @@ -0,0 +1,22 @@ +import { RouteConfig } from '@spryker-oryx/router/lit'; +import { pages } from './types'; + +export const accountRoutes: RouteConfig[] = pages.map((page) => ({ + type: page.route, + path: `/my-account/${page.type}`, +})); + +// export const accountRoutes: RouteConfig[] = [ +// { +// type: RouteType.AccountOverviewPage, +// path: '/my-account/overview', +// }, +// { +// type: RouteType.AccountProfilePage, +// path: '/my-account/profile', +// }, +// { +// type: RouteType.AccountOrdersPage, +// path: '/my-account/orders', +// }, +// ]; diff --git a/libs/template/labs/src/account/account.page.ts b/libs/template/labs/src/account/account.page.ts new file mode 100644 index 0000000000..186d118104 --- /dev/null +++ b/libs/template/labs/src/account/account.page.ts @@ -0,0 +1,69 @@ +import { ExperienceComponent } from '@spryker-oryx/experience'; +import { Size } from '@spryker-oryx/utilities'; +import { pages } from './types'; + +const accountPage = ( + id: string, + meta: { title: string; route: string }, + component: ExperienceComponent = {} +): ExperienceComponent => { + return { + id, + type: 'Page', + meta: { + title: meta.title, + route: meta.route, + index: false, + }, + components: [ + { ref: 'header' }, + { + type: 'oryx-composition', + options: { + rules: [ + { + layout: { type: 'list' }, + }, + ], + }, + components: [ + { + type: 'oryx-site-breadcrumb', + options: { + rules: [ + { + colSpan: 2, + }, + { query: { breakpoint: Size.Sm }, hide: true }, + ], + }, + }, + { + type: 'oryx-composition', + components: [{ ref: 'accountNavigation' }, component], + options: { + rules: [ + { + layout: { + type: 'split', + columnWidthType: 'aside', + divider: true, + }, + }, + ], + }, + }, + ], + }, + { ref: 'footer' }, + ], + }; +}; + +export const accountPages = pages.map((page) => + accountPage( + page.type, + { route: `/my-account/${page.type}`, title: page.type }, + page.component + ) +); diff --git a/libs/template/labs/src/account/index.ts b/libs/template/labs/src/account/index.ts new file mode 100644 index 0000000000..081e323bbb --- /dev/null +++ b/libs/template/labs/src/account/index.ts @@ -0,0 +1,147 @@ +import { AppFeature } from '@spryker-oryx/core'; +import { provideExperienceData } from '@spryker-oryx/experience'; +import { provideLitRoutes } from '@spryker-oryx/router/lit'; +import { IconTypes } from '@spryker-oryx/ui/icon'; +import { Size } from '@spryker-oryx/utilities'; +import { accountNavigation } from './account-navigation.ref.js'; +import { accountRoutes } from './account-routes'; +import { accountPages } from './account.page'; + +/** + * Initial landing page for My Account. we keep it in labs for now + * as it's not yet production ready. Once the first my account pages are + * added, we'll release it as a feature in the presets package. + */ +export const accountFeature: AppFeature = { + providers: [ + ...provideLitRoutes({ routes: accountRoutes }), + provideExperienceData([ + accountNavigation, + ...accountPages, + + { + merge: { selector: 'header-actions' }, + components: [ + { + type: 'oryx-user-navigation-control', + options: { + rules: [ + { + layout: { + dropdown: true, + dropdownPosition: 'start', + dropdownVerticalAlign: true, + }, + }, + ], + }, + components: [ + { + type: 'oryx-content-link', + options: { + type: 'account-overview', + id: 'overview', + icon: IconTypes.User, + }, + content: { data: { text: 'Overview' } }, + }, + { + type: 'oryx-content-link', + options: { + type: 'account-profile', + id: 'profile', + icon: 'badge', + }, + content: { data: { text: 'Profile' } }, + }, + { + type: 'oryx-content-link', + options: { + type: 'account-orders', + id: 'orders', + icon: IconTypes.History, + }, + content: { data: { text: 'Order History' } }, + }, + { + type: 'oryx-auth-logout-link', + options: { icon: IconTypes.Logout }, + }, + ], + }, + // { + // type: 'oryx-composition', + + // options: { + // rules: [ + // { + // layout: { + // // type: 'navigation', + // // navigationType: 'dropdown', + // type: 'navigation', + // dropdown: true, + // dropdownTrigger: 'oryx-user-navigation-control', + // // // dropdownPosition: 'start', + // // // dropdownVerticalAlign: true, + // }, + // }, + // ], + // }, + // components: [ + // { + // type: 'oryx-content-link', + // options: { + // type: 'account-overview', + // id: 'overview', + // icon: IconTypes.User, + // }, + // content: { data: { text: 'Overview' } }, + // }, + // { + // type: 'oryx-content-link', + // options: { + // type: 'account-profile', + // id: 'profile', + // icon: 'badge', + // }, + // content: { data: { text: 'Profile' } }, + // }, + // { + // type: 'oryx-content-link', + // options: { + // type: 'account-orders', + // id: 'orders', + // icon: IconTypes.History, + // }, + // content: { data: { text: 'Order History' } }, + // }, + // { + // type: 'oryx-auth-logout-link', + // options: { icon: IconTypes.Logout }, + // }, + // ], + // }, + { + type: 'oryx-site-navigation-item', + options: { + label: 'cart', + badge: 'CART.SUMMARY', + icon: IconTypes.Cart, + url: { type: 'cart' }, + }, + }, + ], + options: { + rules: [ + { + colSpan: 2, + layout: 'navigation', + justify: 'end', + }, + { query: { breakpoint: Size.Lg }, colSpan: 3 }, + ], + }, + }, + ]), + ], +}; diff --git a/libs/template/labs/src/account/types.ts b/libs/template/labs/src/account/types.ts new file mode 100644 index 0000000000..eeccbcbe9d --- /dev/null +++ b/libs/template/labs/src/account/types.ts @@ -0,0 +1,86 @@ +import { ExperienceComponent } from '@spryker-oryx/experience'; +import { IconTypes } from '@spryker-oryx/ui/icon'; +import { EditTarget } from '@spryker-oryx/user/address-list-item'; + +interface Page { + type: string; + route: string; // tmp + icon: IconTypes | string; + component?: ExperienceComponent; +} + +export const overviewPage: Page = { + type: 'overview', + route: 'account-overview', + icon: IconTypes.User, +}; + +export const profilePage: Page = { + type: 'profile', + route: 'account-profile', + icon: 'badge', +}; + +export const ordersPage: Page = { + type: 'orders', + route: 'account-orders', + icon: IconTypes.History, +}; + +export const consentPage: Page = { + type: 'consent', + route: 'account-consent', + icon: 'shield_locked', +}; + +export const addressesPage: Page = { + type: 'addresses', + route: 'account-addresses', + icon: IconTypes.Location, + component: { + type: 'oryx-composition', + options: { + rules: [ + { + layout: { type: 'list' }, + }, + ], + }, + components: [ + { + type: 'oryx-user-address-list', + options: { + editable: true, + removable: true, + editTarget: EditTarget.Link, + }, + }, + { + type: 'oryx-user-address-add-button', + options: { target: 'link' }, + }, + ], + }, +}; + +export const cartsPage: Page = { + type: 'carts', + route: 'account-carts', + icon: IconTypes.Cart, +}; + +export const wishListsPage: Page = { + type: 'wishlists', + route: 'account-wishlists', + icon: IconTypes.Wishlist, +}; + +export const pages: Page[] = [ + overviewPage, + profilePage, + ordersPage, + consentPage, + addressesPage, + cartsPage, + wishListsPage, +]; diff --git a/libs/template/labs/src/feature.ts b/libs/template/labs/src/feature.ts index 84d88592e2..648333ad67 100644 --- a/libs/template/labs/src/feature.ts +++ b/libs/template/labs/src/feature.ts @@ -1,10 +1,10 @@ import { AppFeature } from '@spryker-oryx/core'; +import { accountFeature } from './account'; import { articleProviders } from './articles'; import { bazaarVoiceComponentMapping } from './bazaarvoice'; import { cloudinaryImageConverter } from './cloudinary'; import * as components from './components'; import { i18nLabsProviders, labsI18nFeature } from './i18n'; -import { myAccountFeature } from './my-account'; export * from './components'; export { labsI18nFeature } from './i18n'; @@ -18,7 +18,7 @@ export const labsComponents = Object.values(components); */ export const labsFeatures: AppFeature[] = [ labsI18nFeature, - myAccountFeature, + accountFeature, { components: labsComponents, providers: [ diff --git a/libs/template/labs/src/my-account/index.ts b/libs/template/labs/src/my-account/index.ts deleted file mode 100644 index 1b52192525..0000000000 --- a/libs/template/labs/src/my-account/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AppFeature } from '@spryker-oryx/core'; -import { provideExperienceData } from '@spryker-oryx/experience'; -import { myAccountNavigation } from './my-account-navigation.ref'; -import { myAccountPage } from './my-account.page'; - -/** - * Initial landing page for My Account. we keep it in labs for now - * as it's not yet production ready. Once the first my account pages are - * added, we'll release it as a feature in the presets package. - */ -export const myAccountFeature: AppFeature = { - providers: [provideExperienceData([myAccountNavigation, myAccountPage])], -}; diff --git a/libs/template/labs/src/my-account/my-account-navigation.ref.ts b/libs/template/labs/src/my-account/my-account-navigation.ref.ts deleted file mode 100644 index e7ff7d438e..0000000000 --- a/libs/template/labs/src/my-account/my-account-navigation.ref.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { ExperienceComponent } from '@spryker-oryx/experience'; -import { IconTypes } from '@spryker-oryx/ui/icon'; -import { i18n } from '@spryker-oryx/utilities'; - -export const myAccountNavigation: ExperienceComponent = { - type: 'oryx-composition', - id: 'myAccountNavigation', - components: [ - { - type: 'oryx-content-link', - content: { data: { text: i18n('my-account.navigation.overview') } }, - options: { - url: '/my-account', - icon: IconTypes.User, - }, - }, - { - type: 'oryx-content-link', - content: { data: { text: i18n('my-account.navigation.profile') } }, - options: { - url: '/my-account/profile', - icon: 'badge', - }, - }, - { - type: 'oryx-content-link', - content: { data: { text: i18n('my-account.navigation.consent') } }, - options: { - url: '/my-account/consent', - icon: 'shield_locked', - }, - }, - { - type: 'oryx-content-link', - content: { data: { text: i18n('my-account.navigation.addresses') } }, - options: { - url: '/my-account/addresses', - icon: IconTypes.Location, - }, - }, - { - type: 'oryx-content-link', - content: { data: { text: i18n('my-account.navigation.order-history') } }, - options: { - url: '/my-account/orders', - icon: IconTypes.History, - }, - }, - { - type: 'oryx-content-link', - content: { data: { text: i18n('my-account.navigation.carts') } }, - options: { - url: '/my-account/wishlist', - icon: IconTypes.Cart, - }, - }, - { - type: 'oryx-content-link', - content: { data: { text: i18n('my-account.navigation.wishlist') } }, - options: { - url: '/my-account/wishlist', - icon: IconTypes.Wishlist, - }, - }, - ], - options: { - rules: [ - { - layout: { - type: 'navigation', - vertical: true, - }, - gap: '0px', - }, - ], - }, -}; diff --git a/libs/template/labs/src/my-account/my-account.page.ts b/libs/template/labs/src/my-account/my-account.page.ts deleted file mode 100644 index f902fc013c..0000000000 --- a/libs/template/labs/src/my-account/my-account.page.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ExperienceComponent } from '@spryker-oryx/experience'; -import { Size } from '@spryker-oryx/utilities'; - -export const myAccountPage: ExperienceComponent = { - id: 'my-account', - type: 'Page', - meta: { - title: 'My account', - route: '/my-account', - index: false, - }, - components: [ - { ref: 'header' }, - { - type: 'oryx-composition', - options: { - rules: [ - { - layout: { type: 'split', columnWidthType: 'aside' }, - padding: '30px 0 0', - }, - ], - }, - components: [ - { - type: 'oryx-site-breadcrumb', - options: { - rules: [ - { - colSpan: 2, - }, - { query: { breakpoint: Size.Sm }, hide: true }, - ], - }, - }, - { ref: 'myAccountNavigation' }, - { - type: 'oryx-content-text', - content: { - data: { - text: `

My account

content...

`, - }, - }, - }, - ], - }, - { ref: 'footer' }, - ], -};