Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion core-libs/assets/src/translations/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -823,5 +824,6 @@ export const defaultFeatureToggles: Required<FeatureTogglesInterface> = {
a11yDisabledButtonContrast: false,
a11yAddressFormInitialFocus: false,
a11yFocusBreadcrumbOnNavigation: false,
useAiSearch: false,
a11yNavigationChevronContrast: false,
};
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import {
BehaviorSubject,
combineLatest,
merge,
Observable,
of,
ReplaySubject,
Expand All @@ -38,6 +39,21 @@ export class SearchBoxComponentService {
chosenWord = new ReplaySubject<string>();
sharedEvent = new ReplaySubject<KeyboardEvent>();
searchCompleted = new BehaviorSubject<boolean>(false);
private readonly _currentQuery$ = new BehaviorSubject<string>('');
readonly currentQuery$: Observable<string> = this._currentQuery$.asObservable();

private readonly _isAiModeActive$ = new BehaviorSubject<boolean>(false);
readonly isAiModeActive$: Observable<boolean> = this._isAiModeActive$.asObservable();

private readonly _lastSearchWasAi$ = new BehaviorSubject<boolean>(false);
readonly lastSearchWasAi$: Observable<boolean> = this._lastSearchWasAi$.asObservable();

private readonly _lastAiQuery$ = new BehaviorSubject<string>('');
private readonly _restoredAiQuery$ = new BehaviorSubject<string>('');
// aiSearchTrigger$ fires only on explicit user search (ENTER) — used by backend to start stream
readonly aiSearchTrigger$: Observable<string> = this._lastAiQuery$.asObservable();
// lastAiQuery$ merges both explicit and restored queries — used by criteria/badges components
readonly lastAiQuery$: Observable<string> = merge(this._lastAiQuery$, this._restoredAiQuery$);

protected enableRecentSearches: boolean = false;
protected enableTrendingSearches: boolean = false;
Expand All @@ -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 &&
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@
[attr.aria-label]="'searchBox.productSearch' | cxTranslate"
role="search"
class="cx-searchbox-container"
[class.cx-searchbox--ai-mode]="isAiSearchEnabled && isAiModeActive"
>
<label class="searchbox" [class.dirty]="!!searchInput?.value">
<span class="cx-input-label">{{ 'common.search' | cxTranslate }}</span>
<div class="cx-label-inner-container">
<input
#searchInput
[placeholder]="'searchBox.placeholder' | cxTranslate"
[placeholder]="(isAiSearchEnabled && isAiModeActive) ? ('searchBox.aiPlaceholder' | cxTranslate) : ('searchBox.placeholder' | cxTranslate)"
autocomplete="off"
aria-describedby="initialDescription"
[attr.aria-controls]="getAriaControls()"
[attr.aria-controls]="(isAiSearchEnabled && isAiModeActive) ? 'results-ai' : getAriaControls()"
[attr.tabindex]="getTabIndex(isMobile | async)"
(click)="open()"
(input)="search(searchInput.value)"
Expand All @@ -34,12 +35,54 @@
</button>

<div
role="presentation"
class="search-icon"
[title]="'common.productSearchDescription' | cxTranslate"
*ngIf="isAiSearchEnabled; else searchIcon"
#aiToggleContainer
class="cx-ai-mode-toggle"
role="group"
[attr.aria-label]="'searchBox.aiToggle.groupLabel' | cxTranslate"
>
<cx-icon [type]="iconTypes.SEARCH"></cx-icon>
<div class="cx-ai-pill" aria-hidden="true"></div>
<button
#regularBtn
class="cx-ai-toggle-btn cx-ai-toggle-regular"
[title]="'searchBox.aiToggle.regularTooltip' | cxTranslate"
[class.active]="!isAiModeActive"
[attr.aria-pressed]="!isAiModeActive"
[attr.aria-label]="'searchBox.aiToggle.regular' | cxTranslate"
(mousedown)="preventDefault($event)"
(click)="toggleAiMode(false)"
>
<cx-icon [type]="iconTypes.SEARCH"></cx-icon>
<span class="cx-ai-toggle-label">{{ 'searchBox.aiToggle.regularLabel' | cxTranslate }}</span>
</button>
<span class="cx-ai-toggle-divider" aria-hidden="true"></span>
<button
#aiBtn
class="cx-ai-toggle-btn cx-ai-toggle-ai"
[title]="'searchBox.aiToggle.aiTooltip' | cxTranslate"
[class.active]="isAiModeActive"
[attr.aria-pressed]="isAiModeActive"
[attr.aria-label]="'searchBox.aiToggle.ai' | cxTranslate"
(mousedown)="preventDefault($event)"
(click)="toggleAiMode(true)"
>
<svg class="cx-ai-icon" aria-hidden="true" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.7739 5.5177C5.0042 4.8274 5.9957 4.8275 6.226 5.5177C7.0371 7.8988 8.0986 8.9593 10.4819 9.7697C11.1728 10.0098 11.1728 11.0005 10.4819 11.2306C8.0987 12.041 7.0371 13.1015 6.226 15.4826C5.9956 16.1726 5.0042 16.1726 4.7739 15.4826C3.9628 13.1015 2.9012 12.041 0.518 11.2306C-0.1727 10.9904 -0.1726 9.9999 0.518 9.7697C2.9012 8.9593 3.9628 7.8988 4.7739 5.5177ZM12.6049 0.28432C12.735 -0.0948 13.2648 -0.09474 13.395 0.28432C13.835 1.58216 14.4153 2.16146 15.7153 2.60072C16.095 2.7306 16.0951 3.2697 15.7153 3.3996C14.4153 3.8388 13.835 4.4181 13.395 5.716C13.2647 6.0948 12.7351 6.0948 12.6049 5.716C12.1649 4.4181 11.5846 3.8388 10.2846 3.3996C9.9051 3.2696 9.9052 2.73073 10.2846 2.60072C11.5846 2.16146 12.1649 1.58216 12.6049 0.28432Z" fill="currentColor"/>
</svg>
<span class="cx-ai-toggle-label">{{ 'searchBox.aiToggle.aiLabel' | cxTranslate }}</span>
</button>
</div>

<ng-template #searchIcon>
<div
role="presentation"
class="search-icon"
[title]="'common.productSearchDescription' | cxTranslate"
>
<cx-icon [type]="iconTypes.SEARCH"></cx-icon>
</div>
</ng-template>

</div>
<button
#searchButton
Expand All @@ -61,9 +104,37 @@
{{ 'searchBox.initialDescription' | cxTranslate }}
</span>

<!-- AI MODE PANEL: shown when AI mode is active and search box is open -->
<div
*ngIf="isAiSearchEnabled && isAiModeActive && searchBoxActive"
class="results cx-ai-results"
id="results-ai"
>
<div class="cx-ai-panel-header">
<span class="cx-ai-panel-title">
<svg class="cx-ai-panel-title-icon" aria-hidden="true" width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.7739 5.5177C5.0042 4.8274 5.9957 4.8275 6.226 5.5177C7.0371 7.8988 8.0986 8.9593 10.4819 9.7697C11.1728 10.0098 11.1728 11.0005 10.4819 11.2306C8.0987 12.041 7.0371 13.1015 6.226 15.4826C5.9956 16.1726 5.0042 16.1726 4.7739 15.4826C3.9628 13.1015 2.9012 12.041 0.518 11.2306C-0.1727 10.9904 -0.1726 9.9999 0.518 9.7697C2.9012 8.9593 3.9628 7.8988 4.7739 5.5177ZM12.6049 0.28432C12.735 -0.0948 13.2648 -0.09474 13.395 0.28432C13.835 1.58216 14.4153 2.16146 15.7153 2.60072C16.095 2.7306 16.0951 3.2697 15.7153 3.3996C14.4153 3.8388 13.835 4.4181 13.395 5.716C13.2647 6.0948 12.7351 6.0948 12.6049 5.716C12.1649 4.4181 11.5846 3.8388 10.2846 3.3996C9.9051 3.2696 9.9052 2.73073 10.2846 2.60072C11.5846 2.16146 12.1649 1.58216 12.6049 0.28432Z" fill="currentColor"/>
</svg>
{{ 'searchBox.aiToggle.aiLabel' | cxTranslate }}
</span>
<button
class="btn btn-tertiary search-panel-close-btn cx-ai-panel-close"
(keydown.arrowdown)="focusPreviousGroup($any($event))"
(click)="close()"
>
{{ 'searchBox.closeSearchPanel' | cxTranslate }}
</button>
</div>
<ng-template
[cxOutlet]="searchBoxOutlets.AI_SEARCH_PANEL"
[cxOutletContext]="{ isAiModeActive: isAiModeActive }"
></ng-template>
</div>

<!-- REGULAR PANEL: standard search results (hidden in AI mode) -->
<ng-container *ngIf="results$ | async as result">
<div
*ngIf="isResultsPanelVisible()"
*ngIf="!(isAiSearchEnabled && isAiModeActive) && isResultsPanelVisible()"
class="results"
id="results"
(click)="close(true)"
Expand Down Expand Up @@ -160,6 +231,7 @@ <h3>
}"
></ng-template>
</ng-container>

<!--RESULT PRODUCTS-->
<div class="products">
<h3 *ngIf="result.products?.length">
Expand Down
Loading