@@ -9,11 +9,6 @@ type ParamValue = number | string;
type ParsedProduct = ProductNode & {
productTypeLabel : string ;
widthMm : number | null ;
lengthM : number | null ;
thicknessMicron : number | null ;
sleeveBrand : string | null ;
quantityPerBox : string | null ;
quantityPerBoxOptions : string [ ] ;
} ;
@@ -33,10 +28,10 @@ type GroupState = {
const PARAM _KEYS : ParamFieldKey [ ] = [ 'widthMm' , 'lengthM' , 'thicknessMicron' , 'sleeveBrand' ] ;
const parameterFields : Array < { key : ParamFieldKey ; label : string } > = [
{ key : 'widthMm' , label : 'Ширина, мм ' } ,
{ key : 'lengthM' , label : 'Длина, м ' } ,
{ key : 'thicknessMicron' , label : 'Толщина, мкм ' } ,
{ key : 'sleeveBrand' , label : 'Бренд в тулки ' } ,
{ key : 'widthMm' , label : 'Ширина' } ,
{ key : 'lengthM' , label : 'Длина' } ,
{ key : 'thicknessMicron' , label : 'Толщина' } ,
{ key : 'sleeveBrand' , label : 'В тулка ' } ,
] ;
const coverPresets = [
@@ -50,8 +45,15 @@ const search = ref('');
const groupStates = reactive < Record < string , GroupState > > ( { } ) ;
const { addProduct , getQuantity , incrementQuantity , decrementQuantity } = useClientCart ( ) ;
function normalizeText ( value : string ) {
return value . replaceAll ( /\s+/g , ' ' ) . trim ( ) ;
function normalizeText ( value : string | null | undefined ) {
return String ( value ? ? '' ) . replaceAll ( /\s+/g , ' ' ) . trim ( ) ;
}
function splitBoxValues ( value : string | null | undefined ) {
return normalizeText ( value )
. split ( '/' )
. map ( ( item ) => item . trim ( ) )
. filter ( Boolean ) ;
}
function createProductCover ( name : string , sku : string ) {
@@ -80,31 +82,15 @@ function createProductCover(name: string, sku: string) {
return ` data:image/svg+xml;utf8, ${ encodeURIComponent ( svg ) } ` ;
}
function pars eProductMeta ( product : ProductNode ) {
const normalizedName = normalizeText ( product . name ) ;
const [ typeLabelRaw ] = normalizedName . split ( ',' ) ;
const typeLabel = typeLabelRaw ? . trim ( ) || 'Без типа' ;
const sizeMatch = normalizedName . match ( /(\d+)\s*[xх *]\s*(\d+)\s*м/i ) ;
const thicknessMatch = normalizedName . match ( /(\d+)\s*мкм/i ) ;
const sleeveMatch = normalizedName . match ( /втулка\s+([^,]+)/i ) ;
const quantityMatch = normalizedName . match ( /короб\s+([^,]+)/i ) ;
const quantityPerBox = quantityMatch ? . [ 1 ] ? . trim ( ) ? ? null ;
function hydrat eProduct( product : ProductNode ) : ParsedProduct {
return {
productTypeLabel : typeLabel ,
widthMm : sizeMatch ? Number . parseInt ( sizeMatch [ 1 ] ? ? '' , 10 ) : null ,
lengthM : sizeMatch ? Number . parseIn t( sizeMatch [ 2 ] ? ? '' , 10 ) : null ,
thicknessMicron : thicknessMatch ? Number . parseInt ( thicknessMatch [ 1 ] ? ? '' , 10 ) : null ,
sleeveBrand : sleeveMatch ? . [ 1 ] ? . trim ( ) ? ? null ,
quantityPerBox ,
quantityPerBoxOptions : quantityPerBox
? quantityPerBox . split ( '/' ) . map ( ( value ) => value . trim ( ) ) . filter ( Boolean )
: [ ] ,
... product ,
productTypeLabel : normalizeText ( product . productType ) || 'Без типа' ,
quantityPerBoxOptions : splitBoxValues ( produc t. quantityPerBox ) ,
} ;
}
function productSortValue ( value : number | null ) {
function productSortValue ( value : number | null | undefined ) {
return value ? ? Number . MAX _SAFE _INTEGER ;
}
@@ -124,7 +110,7 @@ function compareProducts(a: ParsedProduct, b: ParsedProduct) {
return byThickness ;
}
const bySleeve = ( a . sleeveBrand ? ? '' ) . localeCompare ( b . sleeveBrand ? ? '' , 'ru' ) ;
const bySleeve = normalizeText ( a . sleeveBrand ) . localeCompare ( normalizeText ( b. sleeveBrand ) , 'ru' ) ;
if ( bySleeve !== 0 ) {
return bySleeve ;
}
@@ -137,13 +123,22 @@ const parsedProducts = computed<ParsedProduct[]>(() => {
const query = search . value . trim ( ) . toLowerCase ( ) ;
return list
. map ( ( product ) => ( { ... product , ... parseProductMeta ( product ) } ) )
. map ( hydrateProduct )
. filter ( ( product ) => {
if ( ! query ) {
return true ;
}
return [ product . name , product . sku , product . productTypeLabel , product . sleeveBrand ? ? '' ]
. some ( ( part ) => part . toLowerCase ( ) . includes ( query ) ) ;
return [
product . name ,
product . sku ,
product . productTypeLabel ,
String ( product . widthMm ? ? '' ) ,
String ( product . lengthM ? ? '' ) ,
String ( product . thicknessMicron ? ? '' ) ,
normalizeText ( product . sleeveBrand ) ,
normalizeText ( product . quantityPerBox ) ,
] . some ( ( part ) => part . toLowerCase ( ) . includes ( query ) ) ;
} )
. sort ( compareProducts ) ;
} ) ;
@@ -152,12 +147,11 @@ const productGroups = computed<ProductGroup[]>(() => {
const map = new Map < string , ParsedProduct [ ] > ( ) ;
for ( const product of parsedProducts . value ) {
const key = product . productTypeLabel ;
const existing = map . get ( key ) ;
const existing = map . get ( product . productTypeLabel ) ;
if ( existing ) {
existing . push ( product ) ;
} else {
map . set ( key , [ product ] ) ;
map . set ( product . productTypeLabel , [ product ] ) ;
}
}
@@ -170,25 +164,6 @@ const productGroups = computed<ProductGroup[]>(() => {
} ) ) ;
} ) ;
function matchesState ( product : ParsedProduct , state : GroupState , skipField ? : ParamFieldKey ) {
for ( const key of PARAM _KEYS ) {
if ( key === skipField ) {
continue ;
}
const selectedValue = state [ key ] ;
if ( selectedValue === null ) {
continue ;
}
if ( product [ key ] !== selectedValue ) {
return false ;
}
}
return true ;
}
function sortParamValues ( values : ParamValue [ ] ) {
return [ ... values ] . sort ( ( a , b ) => {
if ( typeof a === 'number' && typeof b === 'number' ) {
@@ -203,7 +178,7 @@ function getAllFieldOptions(group: ProductGroup, field: ParamFieldKey) {
for ( const product of group . products ) {
const value = product [ field ] ;
if ( value !== null ) {
if ( value !== null && value !== undefined ) {
values . add ( value ) ;
}
}
@@ -211,17 +186,22 @@ function getAllFieldOptions(group: ProductGroup, field: ParamFieldKey) {
return sortParamValues ( [ ... values ] ) ;
}
function createGroupState ( group : ProductGroup ) : GroupState {
void group ;
const state : GroupState = {
function visibleFields ( group : ProductGroup ) {
return parameterFields . filter ( ( field ) => getAllFieldOptions ( group , field . key ) . length > 1 ) ;
}
function requiredKeys ( group : ProductGroup ) {
return visibleFields ( group ) . map ( ( field ) => field . key ) ;
}
function createGroupState ( ) : GroupState {
return {
widthMm : null ,
lengthM : null ,
thicknessMicron : null ,
sleeveBrand : null ,
isExpanded : false ,
} ;
return state ;
}
watch (
@@ -236,70 +216,97 @@ watch(
}
for ( const group of groups ) {
if ( ! groupStates [ group . key ] ) {
groupStates [ group . key ] = createGroupState ( group ) ;
}
groupStates [ group . key ] ? ? = createGroupState ( ) ;
}
} ,
{ immediate : true } ,
) ;
function getGroupState ( group : ProductGroup ) : GroupState {
const existing = groupStates [ group . key ] ;
if ( existing ) {
return existing ;
}
const created = createGroupState ( group ) ;
groupStates [ group . key ] = created ;
return created ;
function getGroupState ( group : ProductGroup ) {
groupStates [ group . key ] ? ? = createGroupState ( ) ;
return groupStates [ group . key ] ;
}
function updateField ( g roup : ProductGroup , field : ParamFieldKey , value : ParamValue ) {
function matchesProductState ( p roduct : Parsed Product, state : GroupState , keys : ParamFieldKey [ ] ) {
return keys . every ( ( key ) => state [ key ] === null || product [ key ] === state [ key ] ) ;
}
function matchingProducts ( group : ProductGroup ) {
const state = getGroupState ( group ) ;
state [ field ] = value as GroupState [ typeof field ] ;
return group . products . filter ( ( product ) => matchesProductState ( product , state , requiredKeys ( group ) ) ) ;
}
function selectedProduct ( group : ProductGroup ) {
const keys = requiredKeys ( group ) ;
const state = getGroupState ( group ) ;
if ( PARAM _KEYS . some ( ( key ) => state [ key ] === null ) ) {
if ( keys . length === 0 ) {
return group . products . length === 1 ? group . products [ 0 ] : null ;
}
if ( keys . some ( ( key ) => state [ key ] === null ) ) {
return null ;
}
return group . products . find ( ( product ) => matchesState ( product , state ) ) ? ? null ;
const matches = group . products . filter ( ( product ) => matchesProductState ( product , state , keys ) ) ;
return matches . length === 1 ? matches [ 0 ] : null ;
}
function matching Count( group : ProductGroup ) {
function remainingSelection Count( group : ProductGroup ) {
const state = getGroupState ( group ) ;
return group . p roducts . filter ( ( product ) => matchesS tate( product , state ) ) . length ;
return requiredKeys ( g roup ) . filter ( ( key ) => s tate[ key ] === null ) . length ;
}
function toggleExpanded ( group : ProductGroup ) {
const state = getGroupState ( group ) ;
state . isExpanded = ! state . isExpanded ;
function pluralize ( value : number , one : string , few : string , many : string ) {
const mod10 = value % 10 ;
const mod100 = value % 100 ;
if ( mod10 === 1 && mod100 !== 11 ) {
return one ;
}
if ( mod10 >= 2 && mod10 <= 4 && ( mod100 < 12 || mod100 > 14 ) ) {
return few ;
}
return many ;
}
function formatOptionLabel ( field : ParamFieldKey , value : ParamValue ) {
if ( field === 'widthMm' || field === 'lengthM' ) {
return ` ${ value } мм ` ;
function selectionHeadline ( group : ProductGroup ) {
const product = selectedProduct ( group ) ;
if ( product ) {
return ` SKU ${ product . sku } ` ;
}
if ( field === 'thicknessMicron' ) {
return ` ${ value } мкм ` ;
const remaining = remainingSelectionCount ( group ) ;
if ( remaining > 0 ) {
return ` Выберите еще ${ remaining } ${ pluralize ( remaining , 'параметр' , 'параметра' , 'параметров' ) } ` ;
}
return String ( value ) ;
return 'Комбинация не найдена' ;
}
function selectedFieldValue ( group : ProductGroup , field : ParamFieldKey ) {
const value = getGroupState ( group ) [ field ] ;
if ( value === null ) {
return 'не выбрано' ;
function selectionDescription ( group : ProductGroup ) {
const product = selectedProduct ( group ) ;
if ( product ) {
return [
product . widthMm ? ` ${ product . widthMm } мм ` : null ,
product . lengthM ? ` ${ product . lengthM } м ` : null ,
product . thicknessMicron ? ` ${ product . thicknessMicron } мкм ` : null ,
normalizeText ( product . sleeveBrand ) || null ,
] . filter ( Boolean ) . join ( ' • ' ) ;
}
return formatOptionLabel ( field , value ) ;
const remaining = remainingSelectionCount ( group ) ;
if ( remaining > 0 ) {
return 'Переключатели ниже собирают точную модификацию товара.' ;
}
return 'Разверните весь список, если нужна ручная проверка вариантов.' ;
}
function boxQuantityLabel ( group : ProductGroup ) {
const product = selectedProduct ( group ) ;
if ( product ? . quantityPerBox ) {
return product . quantityPerBox ;
if ( product ? . quantityPerBoxOptions . length ) {
return product . quantityPerBoxOptions . join ( ' / ' ) ;
}
const values = new Set < string > ( ) ;
@@ -312,6 +319,49 @@ function boxQuantityLabel(group: ProductGroup) {
return sortParamValues ( [ ... values ] ) . join ( ' / ' ) || '—' ;
}
function formatOptionLabel ( field : ParamFieldKey , value : ParamValue ) {
if ( field === 'widthMm' ) {
return ` ${ value } мм ` ;
}
if ( field === 'lengthM' ) {
return ` ${ value } м ` ;
}
if ( field === 'thicknessMicron' ) {
return ` ${ value } мкм ` ;
}
return String ( value ) ;
}
function isOptionAvailable ( group : ProductGroup , field : ParamFieldKey , option : ParamValue ) {
const state = getGroupState ( group ) ;
const scopedState = {
... state ,
[ field ] : option ,
} satisfies GroupState ;
return group . products . some ( ( product ) => matchesProductState ( product , scopedState , requiredKeys ( group ) ) ) ;
}
function updateField ( group : ProductGroup , field : ParamFieldKey , value : ParamValue ) {
const state = getGroupState ( group ) ;
state [ field ] = value as GroupState [ typeof field ] ;
}
function clearField ( group : ProductGroup , field : ParamFieldKey ) {
getGroupState ( group ) [ field ] = null ;
}
function clearSelection ( group : ProductGroup ) {
const state = getGroupState ( group ) ;
for ( const key of PARAM _KEYS ) {
state [ key ] = null ;
}
}
function toggleExpanded ( group : ProductGroup ) {
getGroupState ( group ) . isExpanded = ! getGroupState ( group ) . isExpanded ;
}
function incrementProduct ( product : ProductNode ) {
if ( getQuantity ( product . id ) === 0 ) {
addProduct ( {
@@ -332,26 +382,21 @@ function decrementProduct(productId: string) {
function selectedQty ( group : ProductGroup ) {
const product = selectedProduct ( group ) ;
if ( ! product ) {
return 0 ;
}
return getQuantity ( product . id ) ;
return product ? getQuantity ( product . id ) : 0 ;
}
function incrementSelected ( group : ProductGroup ) {
const product = selectedProduct ( group ) ;
if ( ! product ) {
return ;
if ( product ) {
incrementProduct ( product ) ;
}
incrementProduct ( product ) ;
}
function decrementSelected ( group : ProductGroup ) {
const product = selectedProduct ( group ) ;
if ( ! product ) {
return ;
if ( product ) {
decrementProduct ( product . id ) ;
}
decrementProduct ( product . id ) ;
}
< / script >
@@ -380,76 +425,110 @@ function decrementSelected(group: ProductGroup) {
:key = "group.key"
class = "surface-card rounded-3xl p-4 md:p-5"
>
< div class = "flex flex-wrap items-center gap-3 " >
< h2 class = "text-xl font-bold text-[#133826]" > { { group . typeLabel } } < / h2 >
< span class = "badge badge-outline" > { { group . products . length } } вариантов < / span >
< / div >
< div class = "mt-4 grid gap-4 xl:grid-cols-[360px_1fr]" >
< aside class = "rounded-2xl bg-base-100 p-3" >
< div class = "grid gap-4 xl:grid-cols-6 xl:items-stretch " >
< div class = "rounded-[28px] bg-base-100 p-3 xl:col-span-1" >
< img
: src = "createProductCover(group.typeLabel, group.key)"
: alt = "`Превью группы ${group.typeLabel}`"
class = "h-56 w-full rounded-2xl object-cover"
class = "h-full min-h-[220px] w-full rounded-[24px] object-cover"
loading = "lazy"
>
< / div >
< div class = "mt-3 flex flex-wrap gap-2 " >
< span class = "badge badge-neutral" > SKU : { { selectedProduct ( group ) ? . sku ? ? '—' } } < / span >
< span class = "badge badge-outline" > Совпадений : { { matchingCount ( group ) } } < / span >
< span class = "badge badge-outline" > К о р о б : { { boxQuantityLabel ( group ) } } < / span >
< / div >
< div class = "mt-3 flex items-center justify-between rounded-2xl border border-base-300 bg-base-100 px-2 py-1" >
< button
class = "btn btn-square btn-sm"
: disabled = "selectedQty(group) === 0"
@click ="decrementSelected(group)"
>
-
< / button >
< span class = "min-w-10 text-center font-semibold" > { { selectedQty ( group ) } } < / span >
< button class = "btn btn-square btn-sm" @click ="incrementSelected(group)" > + < / button >
< / div >
< div class = "mt-3 flex flex-wrap gap-2" >
< span
v-for = "field in parameterFields"
:key = "`selected-${group.key}-${field.key}`"
class = "badge badge-outline"
>
{ { field . label } } : { { selectedFieldValue ( group , field . key ) } }
< / span >
< / div >
< / aside >
< div class = "space-y-4" >
< div class = "grid gap-3 md:grid-cols-2 2xl:grid-cols-3" >
< div
v-for = "field in parameterFields"
< div class = "rounded-[28px] bg-base-100 p-4 md:p-5 xl:col-span-4 " >
< div class = "grid h-full gap-4 md:grid-cols-2" >
< fieldset
v-for = "field in visibleFields(group)"
:key = "`${group.key}-${field.key}`"
class = "rounded-2xl bg-base-1 00 p-3 "
class = "rounded-[24px] bg-base-2 00/50 p-4 "
>
< p class = "text-xs font-semibold uppercase tracking-wide text-base-content/60" > { { field . label } } < / p >
< div class = "mt-2 flex flex-wrap gap-2" >
< div class = "flex items-center justify-between gap-3" >
< legend class = "text-sm font-semibold text-[#163624]" > { { field . label } } < / legend >
< button
v-for = "option in getAllFieldOptions (group, field.key) "
:key = "`${group.key}-${field.key}-${option}` "
class = "btn btn-sm "
: class = "getGroupState(group)[field.key] === option ? 'btn-neutral' : 'btn-outline'"
@click ="updateField(group, field.key, option)"
v-i f = "getGroupState (group)[ field.key] !== null "
class = "btn btn-ghost btn-xs rounded-full px-2 "
@click ="clearField(group, field.key) "
>
{ { formatOptionLabel ( field . key , option ) } }
Сбросить
< / button >
< / div >
< div class = "mt-3 flex flex-wrap gap-2" >
< input
v-for = "option in getAllFieldOptions(group, field.key)"
:key = "`${group.key}-${field.key}-${option}`"
class = "btn btn-sm rounded-full border-base-300 bg-base-100 text-sm normal-case checked:btn-neutral"
type = "radio"
:name = "`${group.key}-${field.key}`"
: aria -label = " formatOptionLabel ( field.key , option ) "
: checked = "getGroupState(group)[field.key] === option"
: disabled = "!isOptionAvailable(group, field.key, option)"
@change ="updateField(group, field.key, option)"
>
< / div >
< / fieldset >
< / div >
< / div >
< aside class = "rounded-[28px] bg-base-100 p-4 md:p-5 xl:col-span-1" >
< div class = "flex h-full flex-col justify-between gap-4" >
< div class = "space-y-3" >
< div >
< p class = "text-xs font-semibold uppercase tracking-[0.18em] text-base-content/45" > Тип товара < / p >
< h2 class = "mt-2 text-xl font-bold text-[#163624]" > { { group . typeLabel } } < / h2 >
< / div >
< div class = "flex flex-wrap gap-2" >
< span class = "badge badge-outline" > { { group . products . length } } вариантов < / span >
< span class = "badge badge-outline" > К о р о б : { { boxQuantityLabel ( group ) } } < / span >
< / div >
< div class = "rounded-[22px] bg-base-200/70 p-3" >
< p class = "text-sm font-semibold text-[#163624]" > { { selectionHeadline ( group ) } } < / p >
< p class = "mt-1 text-xs leading-5 text-base-content/65" > { { selectionDescription ( group ) } } < / p >
< / div >
< / div >
< div class = "space-y-3" >
< div class = "rounded-[22px] border border-base-300 bg-base-100 px-2 py-1" >
< div class = "flex items-center justify-between gap-2" >
< button
class = "btn btn-square btn-sm"
: disabled = "selectedQty(group) === 0"
@click ="decrementSelected(group)"
>
-
< / button >
< div class = "text-center" >
< div class = "text-[11px] uppercase tracking-[0.16em] text-base-content/45" > В корзине < / div >
< div class = "text-lg font-semibold text-[#163624]" > { { selectedQty ( group ) } } < / div >
< / div >
< button
class = "btn btn-square btn-sm"
:disabled = "!selectedProduct(group)"
@click ="incrementSelected(group)"
>
+
< / button >
< / div >
< / div >
< button
class = "btn btn-ghost btn-sm w-full rounded-full"
: disabled = "requiredKeys(group).every((key) => getGroupState(group)[key] === null)"
@click ="clearSelection(group)"
>
Сбросить выбор
< / button >
< / div >
< / div >
< p class = "text-sm text-base-content/70" > Совпадающих вариантов : { { matchingCount ( group ) } } < / p >
< / div >
< / aside >
< / div >
< div v-if = "getGroupState(group).isExpanded" class="mt-4 overflow-x-auto rounded-2xl bg-base-100 p-2" >
< div
v-if = "getGroupState(group).isExpanded"
class = "mt-4 overflow-x-auto rounded-[28px] bg-base-100 p-2"
>
< table class = "table table-zebra" >
< thead >
< tr >
@@ -481,7 +560,13 @@ function decrementSelected(group: ProductGroup) {
< td > { { product . quantityPerBox ? ? '—' } } < / td >
< td class = "text-right" >
< div class = "ml-auto flex w-28 items-center justify-between rounded-xl border border-base-300 px-1 py-1" >
< button class = "btn btn-xs btn-square" : disabled = "getQuantity(product.id) === 0" @click ="decrementProduct(product.id)" > - < / button >
< button
class = "btn btn-xs btn-square"
: disabled = "getQuantity(product.id) === 0"
@click ="decrementProduct(product.id)"
>
-
< / button >
< span class = "text-sm font-semibold" > { { getQuantity ( product . id ) } } < / span >
< button class = "btn btn-xs btn-square" @click ="incrementProduct(product)" > + < / button >
< / div >
@@ -502,7 +587,13 @@ function decrementSelected(group: ProductGroup) {
fill = "none"
xmlns = "http://www.w3.org/2000/svg"
>
< path d = "M5 7.5L10 12.5L15 7.5" stroke = "currentColor" stroke -width = " 1.8 " stroke -linecap = " round " stroke -linejoin = " round " / >
< path
d = "M5 7.5L10 12.5L15 7.5"
stroke = "currentColor"
stroke -width = " 1.8 "
stroke -linecap = " round "
stroke -linejoin = " round "
/ >
< / svg >
< span > { { getGroupState ( group ) . isExpanded ? 'Свернуть все варианты' : 'Развернуть все варианты' } } < / span >
< / button >