diff --git a/core-libs/assets/src/translations/en/common.json b/core-libs/assets/src/translations/en/common.json index 1784dbd32c8..6e6bcf5270d 100644 --- a/core-libs/assets/src/translations/en/common.json +++ b/core-libs/assets/src/translations/en/common.json @@ -96,7 +96,30 @@ "empty": "Ask us anything" }, "closeSearchPanel": "Close", - "queryError": "Your search query is incorrectly formatted. Please remove special characters like \":\" and try again." + "queryError": "Your search query is incorrectly formatted. Please remove special characters like \":\" and try again.", + "aiPlaceholder": "Describe what you're looking for in natural language. AI search may take a bit longer.", + "aiSearching": "AI searching", + "aiSearchError": "AI search failed", + "aiToggle": { + "groupLabel": "Search mode", + "regular": "Switch to standard search", + "regularLabel": "Search", + "regularTooltip": "Search", + "ai": "Switch to AI search", + "aiLabel": "AI Mode", + "aiTooltip": "AI Search" + }, + "aiPanel": { + "examples": "AI search inspiration", + "results": "AI search suggestions" + }, + "aiCriteria": { + "fullMatch": "Fully matches your search criteria", + "partialMatch": "Matches {{matched}} out of {{total}} search criteria", + "tooltipTitle": "Matches {{matched}} of {{total}} search criteria:", + "matched": "Matched", + "notMatched": "Not Matched" + } }, "sorting": { "date": "Date", diff --git a/core-libs/core/src/features-config/feature-toggles/config/feature-toggles.ts b/core-libs/core/src/features-config/feature-toggles/config/feature-toggles.ts index e30660b5f00..cbf5168447d 100644 --- a/core-libs/core/src/features-config/feature-toggles/config/feature-toggles.ts +++ b/core-libs/core/src/features-config/feature-toggles/config/feature-toggles.ts @@ -592,6 +592,7 @@ export interface FeatureTogglesInterface { * Login Page form submission. */ siteIsolationForCustomLoginPage?: boolean; + useAiSearch?: boolean; /** * When enabled, the navigation menu buttons (e.g. "My Account") and dropdown @@ -823,5 +824,6 @@ export const defaultFeatureToggles: Required = { a11yDisabledButtonContrast: false, a11yAddressFormInitialFocus: false, a11yFocusBreadcrumbOnNavigation: false, + useAiSearch: false, a11yNavigationChevronContrast: false, }; diff --git a/core-libs/storefront/cms-components/navigation/search-box/search-box-component.service.ts b/core-libs/storefront/cms-components/navigation/search-box/search-box-component.service.ts index f7f6bbcb0e7..5f678a00067 100644 --- a/core-libs/storefront/cms-components/navigation/search-box/search-box-component.service.ts +++ b/core-libs/storefront/cms-components/navigation/search-box/search-box-component.service.ts @@ -18,6 +18,7 @@ import { import { BehaviorSubject, combineLatest, + merge, Observable, of, ReplaySubject, @@ -38,6 +39,21 @@ export class SearchBoxComponentService { chosenWord = new ReplaySubject(); sharedEvent = new ReplaySubject(); searchCompleted = new BehaviorSubject(false); + private readonly _currentQuery$ = new BehaviorSubject(''); + readonly currentQuery$: Observable = this._currentQuery$.asObservable(); + + private readonly _isAiModeActive$ = new BehaviorSubject(false); + readonly isAiModeActive$: Observable = this._isAiModeActive$.asObservable(); + + private readonly _lastSearchWasAi$ = new BehaviorSubject(false); + readonly lastSearchWasAi$: Observable = this._lastSearchWasAi$.asObservable(); + + private readonly _lastAiQuery$ = new BehaviorSubject(''); + private readonly _restoredAiQuery$ = new BehaviorSubject(''); + // aiSearchTrigger$ fires only on explicit user search (ENTER) — used by backend to start stream + readonly aiSearchTrigger$: Observable = this._lastAiQuery$.asObservable(); + // lastAiQuery$ merges both explicit and restored queries — used by criteria/badges components + readonly lastAiQuery$: Observable = merge(this._lastAiQuery$, this._restoredAiQuery$); protected enableRecentSearches: boolean = false; protected enableTrendingSearches: boolean = false; @@ -60,6 +76,7 @@ export class SearchBoxComponentService { this.hasKeywordRedirect = false; this.currentQueryLength = query ? query.length : 0; this.searchCompleted.next(false); + this._currentQuery$.next(query ?? ''); if ( !this.enableRecentSearches && @@ -161,6 +178,7 @@ export class SearchBoxComponentService { clearResults() { this.searchService.clearResults(); this.toggleBodyClass(HAS_SEARCH_RESULT_CLASS, false); + this._currentQuery$.next(''); // Reset search completion state this.hasKeywordRedirect = false; @@ -360,4 +378,82 @@ export class SearchBoxComponentService { setRecentSearches(enabled: boolean = false) { this.enableRecentSearches = enabled; } + + setAiMode(active: boolean): void { + this._isAiModeActive$.next(active); + this.persistAiModePreference(active); + } + + /** + * Persists the user's search-mode toggle choice (regular vs AI) so it is + * restored on the next visit. Uses localStorage because this is a lasting UI + * preference, unlike the per-search AI context which lives in sessionStorage. + */ + private persistAiModePreference(active: boolean): void { + try { + this.winRef.localStorage?.setItem( + 'cx_ai_mode_preference', + active ? '1' : '0' + ); + } catch { + // localStorage may be unavailable (SSR, private mode, quota); ignore. + } + } + + getAiModePreference(): boolean { + try { + return this.winRef.localStorage?.getItem('cx_ai_mode_preference') === '1'; + } catch { + // localStorage may be unavailable (SSR, private mode); default to regular. + return false; + } + } + + markAiSearchLaunched(isAiMode: boolean): void { + this._lastSearchWasAi$.next(isAiMode); + if (!isAiMode) { + this.clearAiContext(); + } + } + + setAiQuery(query: string): void { + this._lastAiQuery$.next(query); + this.persistAiContext(query); + } + + private persistAiContext(query: string): void { + try { + const storage = this.winRef.sessionStorage; + if (storage) { + storage.setItem('cx_ai_context', JSON.stringify({ query, ts: Date.now() })); + } + } catch {} + } + + restoreAiContextFromStorage(): void { + try { + const storage = this.winRef.sessionStorage; + const raw = storage?.getItem('cx_ai_context'); + if (!raw) return; + const { query, ts } = JSON.parse(raw) as { query: string; ts: number }; + if (Date.now() - ts > 30 * 60 * 1000) { + storage?.removeItem('cx_ai_context'); + return; + } + const navEntries = this.winRef.nativeWindow?.performance?.getEntriesByType?.('navigation') as PerformanceNavigationTiming[] | undefined; + if (navEntries?.[0]?.type === 'reload') { + storage?.removeItem('cx_ai_context'); + return; + } + this._lastSearchWasAi$.next(true); + this._restoredAiQuery$.next(query); + } catch {} + } + + private clearAiContext(): void { + this._lastAiQuery$.next(''); + try { + this.winRef.sessionStorage?.removeItem('cx_ai_context'); + } catch {} + } } diff --git a/core-libs/storefront/cms-components/navigation/search-box/search-box-outlets.model.ts b/core-libs/storefront/cms-components/navigation/search-box/search-box-outlets.model.ts index f04e8087c3b..d9072419a6e 100644 --- a/core-libs/storefront/cms-components/navigation/search-box/search-box-outlets.model.ts +++ b/core-libs/storefront/cms-components/navigation/search-box/search-box-outlets.model.ts @@ -8,4 +8,6 @@ export enum SearchBoxOutlets { RECENT_SEARCHES = 'SearchBoxOutlets.RECENT_SEARCHES', RECENT_SEARCHES_HEADER = 'SearchBoxOutlets.RECENT_SEARCHES_HEADER', TRENDING_SEARCHES = 'SearchBoxOutlets.TRENDING_SEARCHES', + AI_TOGGLE = 'SearchBoxOutlets.AI_TOGGLE', + AI_SEARCH_PANEL = 'SearchBoxOutlets.AI_SEARCH_PANEL', } diff --git a/core-libs/storefront/cms-components/navigation/search-box/search-box.component.html b/core-libs/storefront/cms-components/navigation/search-box/search-box.component.html index 4b15a327c26..d249b3a5c60 100644 --- a/core-libs/storefront/cms-components/navigation/search-box/search-box.component.html +++ b/core-libs/storefront/cms-components/navigation/search-box/search-box.component.html @@ -2,16 +2,17 @@ [attr.aria-label]="'searchBox.productSearch' | cxTranslate" role="search" class="cx-searchbox-container" + [class.cx-searchbox--ai-mode]="isAiSearchEnabled && isAiModeActive" >