Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @subpackage Nexus * @since 29 Apr 2014 */ namespace IPS\nexus\modules\front\store; /* To prevent PHP errors (extending class does not exist) revealing path */ use DateInterval; use IPS\Content\Filter; use IPS\DateTime; use IPS\Db; use IPS\Dispatcher\Controller; use IPS\Helpers\Form; use IPS\Helpers\Form\Number; use IPS\Http\Url; use IPS\Login; use IPS\Member; use IPS\nexus\Customer; use IPS\nexus\Money; use IPS\nexus\Package; use IPS\nexus\Package\Group; use IPS\nexus\Package\Item; use IPS\Output; use IPS\Patterns\ActiveRecordIterator; use IPS\Request; use IPS\Session; use IPS\Settings; use IPS\Theme; use OutOfBoundsException; use OutofRangeException; use function count; use function defined; use function floatval; use function in_array; use function intval; use function is_array; use function is_numeric; if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * Browse Store */ class store extends Controller { /** * @brief Products per page */ protected static int $productsPerPage = 50; /** * @brief Currency */ protected mixed $currency = NULL; /** * Execute * * @return void */ public function execute() : void { /* Set CSS */ Output::i()->cssFiles = array_merge( Output::i()->cssFiles, Theme::i()->css( 'store.css', 'nexus' ) ); /* Work out currency */ if ( isset( Request::i()->currency ) and in_array( Request::i()->currency, Money::currencies() ) ) { if ( isset( Request::i()->csrfKey ) and Login::compareHashes( (string) Session::i()->csrfKey, (string) Request::i()->csrfKey ) ) { $_SESSION['cart'] = array(); Request::i()->setCookie( 'currency', Request::i()->currency ); Output::i()->redirect( Request::i()->url() ); } $this->currency = Request::i()->currency; } else { $this->currency = Customer::loggedIn()->defaultCurrency(); } Output::setCacheTime( false ); /* Pass up */ parent::execute(); } /** * Browse Store * * @return void */ protected function manage() : void { /* If we have a category, display it */ if ( isset( Request::i()->cat ) ) { $this->_categoryView(); } /* Otherwise, display the index */ else { $this->_indexView(); } } /** * Show a category * * @return void */ protected function _categoryView() : void { /* Load category */ try { $category = Group::loadAndCheckPerms( Request::i()->cat ); } catch ( OutofRangeException ) { Output::i()->error( 'node_error', '1X241/1', 404, '' ); } $url = $category->url(); /* Set initial stuff for fetching packages */ $currentPage = isset( Request::i()->page ) ? intval( Request::i()->page ) : 1; if( $currentPage < 1 ) { $currentPage = 1; } $where = array( array( 'p_group=?', $category->id ), array( 'p_store=1' ), array( "( p_member_groups='*' OR " . Db::i()->findInSet( 'p_member_groups', Member::loggedIn()->groups ) . ' )' ) ); $havePackagesWhichAcceptReviews = (bool) Db::i()->select( 'COUNT(*)', 'nexus_packages', array_merge( $where, array( array( 'p_reviewable=1' ) ) ) )->first(); $havePackagesWhichUseStockLevels = (bool) Db::i()->select( 'COUNT(*)', 'nexus_packages', array_merge( $where, array( array( 'p_stock<>-1' ) ) ) )->first(); $joins = array(); /* Apply Filters */ if ( isset( Request::i()->filter ) and is_array( Request::i()->filter ) ) { $url = $url->setQueryString( 'filter', Request::i()->filter ); foreach ( Request::i()->filter as $filterId => $allowedValues ) { $filterId = (int) $filterId; $where[] = array( Db::i()->findInSet( "filter{$filterId}.pfm_values", array_map( 'intval', explode( ',', $allowedValues ) ) ) ); $joins[] = array( 'table' => array( 'nexus_package_filters_map', "filter{$filterId}" ), 'on' => array( "filter{$filterId}.pfm_package=p_id AND filter{$filterId}.pfm_filter=?", $filterId ) ); } } foreach ( array( 'minPrice' => '>', 'maxPrice' => '<' ) as $k => $op ) { if ( isset( Request::i()->$k ) and is_numeric( Request::i()->$k ) and floatval( Request::i()->$k ) > 0 ) { $url = $url->setQueryString( $k, Request::i()->$k ); $joins['nexus_package_base_prices'] = array( 'table' => 'nexus_package_base_prices', 'on' => array( 'id=p_id' ) ); $where[] = array( $this->currency . $op . '=?', floatval( Request::i()->$k ) ); } } if ( isset( Request::i()->minRating ) and is_numeric( Request::i()->minRating ) and floatval( Request::i()->minRating ) > 0 ) { $url = $url->setQueryString( 'minRating', Request::i()->minRating ); $where[] = array( 'p_rating>=?', intval( Request::i()->minRating ) ); } if ( isset( Request::i()->inStock ) ) { $url = $url->setQueryString( 'inStock', Request::i()->inStock ); $where[] = array( '( p_stock>0 OR ( p_stock=-2 AND (?)>0 ) )', Db::i()->select( 'MAX(opt_stock)', 'nexus_product_options', 'opt_package=p_id' ) ); } /* Figure out the sorting */ switch ( Request::i()->sortby ) { case 'name': $joins['core_sys_lang_words'] = array( 'table' => 'core_sys_lang_words', 'on' => array( "word_app='nexus' AND word_key=CONCAT( 'nexus_package_', p_id ) AND lang_id=?", Member::loggedIn()->language()->id ) ); $sortBy = 'word_custom'; break; case 'price_low': case 'price_high': $joins['nexus_package_base_prices'] = array( 'table' => 'nexus_package_base_prices', 'on' => array( 'id=p_id' ) ); $sortBy = Request::i()->sortby == 'price_low' ? $this->currency : ( $this->currency . ' DESC' ); break; case 'rating': $sortBy = 'p_rating DESC'; break; default: $sortBy = 'p_position'; break; } /* Fetch the packages */ $select = Db::i()->select( '*', 'nexus_packages', $where, $sortBy, array( ( $currentPage - 1 ) * static::$productsPerPage, static::$productsPerPage ) ); foreach ( $joins as $join ) { $select->join( $join['table'], $join['on'] ); } $packages = new ActiveRecordIterator( $select, 'IPS\nexus\Package' ); /* Get packages count */ $select = Db::i()->select( 'COUNT(*)', 'nexus_packages', $where ); foreach ( $joins as $join ) { $select->join( $join['table'], $join['on'] ); } $totalCount = $select->first(); /* Pagination */ $totalPages = ceil( $totalCount / static::$productsPerPage ); if ( $totalPages and $currentPage > $totalPages ) { Output::i()->redirect( $category->url()->setPage( 'page', $totalPages ), NULL, 303 ); } $pagination = Theme::i()->getTemplate( 'global', 'core', 'global' )->pagination( $category->url(), $totalPages, $currentPage, static::$productsPerPage ); /* Other stuff we need for the view */ $subcategories = new ActiveRecordIterator( Db::i()->select( '*', 'nexus_package_groups', array( 'pg_parent=?', $category->id ), 'pg_position ASC' ), 'IPS\nexus\Package\Group' ); $packagesWithCustomFields = array(); foreach ( iterator_to_array( Db::i()->select( 'cf_packages', 'nexus_package_fields', 'cf_purchase=1' ) ) as $ids ) { $packagesWithCustomFields = array_merge( $packagesWithCustomFields, array_filter( explode( ',', $ids ) ) ); } Output::i()->bodyAttributes['contentClass'] = Group::class; /* Output */ if ( Request::i()->isAjax() ) { Output::i()->json( array( 'contents' => Theme::i()->getTemplate('store')->categoryContents( $category, $subcategories, $packages, $pagination, $packagesWithCustomFields, $totalCount ), 'sidebar' => Theme::i()->getTemplate('store')->categorySidebar( $category, $subcategories, $url, count( $packages ), $this->currency, $havePackagesWhichAcceptReviews, $havePackagesWhichUseStockLevels ) )) ; } else { foreach ( $category->parents() as $parent ) { Output::i()->breadcrumb[] = array( $parent->url(), $parent->_title ); } Output::i()->breadcrumb[] = array( $category->url(), $category->_title ); Output::i()->title = $category->_title; Output::i()->sidebar['contextual'] = Theme::i()->getTemplate('store')->categorySidebar( $category, $subcategories, $url, count( $packages ), $this->currency, $havePackagesWhichAcceptReviews, $havePackagesWhichUseStockLevels ); /* Set default search */ Output::i()->defaultSearchOption = array( 'nexus_package_item', "nexus_package_item_el" ); Output::i()->output = Theme::i()->getTemplate('store')->category( $category, $subcategories, $packages, $pagination, $packagesWithCustomFields, $totalCount ); Output::i()->globalControllers[] = 'nexus.front.store.category'; } /* JSON-LD @note Google does not like categories marked up and instead recommends marking up each product separately. @link https://developers.google.com/search/docs/guides/intro-structured-data#multiple-entities-on-the-same-page */ foreach( $packages as $package ) { /* @var Package $package */ $item = Item::load( $package->id ); try { $price = $package->price(); } catch( OutOfBoundsException ) { $price = NULL; } /* A product MUST have an offer, so if there's no price (i.e. due to currency configuration) don't even output */ if( $price !== NULL ) { Output::i()->jsonLd['package' . $package->id ] = array( '@context' => "https://schema.org", '@type' => "Product", 'name' => $package->_title, 'description' => $item->truncated( TRUE, NULL ), 'category' => $category->_title, 'url' => (string) $package->url(), 'sku' => $package->id, 'offers' => array( '@type' => 'Offer', 'price' => $price->amountAsString(), 'priceCurrency' => $price->currency, 'seller' => array( '@type' => 'Organization', 'name' => Settings::i()->board_name ), ), ); /* Stock levels */ if( $package->stockLevel() === 0 ) { Output::i()->jsonLd['package' . $package->id ]['offers']['availability'] = 'https://schema.org/OutOfStock'; } else { Output::i()->jsonLd['package' . $package->id ]['offers']['availability'] = 'https://schema.org/InStock'; } if( $package->image ) { Output::i()->jsonLd['package' . $package->id ]['image'] = (string) $package->image; } if( $package->reviewable and $item->averageReviewRating() ) { Output::i()->jsonLd['package' . $package->id ]['aggregateRating'] = array( '@type' => 'AggregateRating', 'ratingValue' => $item->averageReviewRating(), 'ratingCount' => $item->reviews ); } } } } /** * Set a price filter * * @return void */ protected function priceFilter() : void { if ( !isset( Request::i()->maxPrice ) ) { Request::i()->maxPrice = 0; } $form = new Form( 'price_filter', 'filter' ); $form->class = 'ipsForm--vertical ipsForm--price-filter'; $form->add( new Number( 'minPrice', FALSE, 0, array( 'decimals' => Money::numberOfDecimalsForCurrency( $this->currency ) ), NULL, NULL, $this->currency ) ); $form->add( new Number( 'maxPrice', FALSE, 0, array( 'decimals' => Money::numberOfDecimalsForCurrency( $this->currency ), 'unlimited' => 0 ), NULL, NULL, $this->currency ) ); if ( $values = $form->values() ) { $url = Request::i()->url()->setQueryString( 'do', NULL ); if ( $values['minPrice'] ) { $url = $url->setQueryString( 'minPrice', $values['minPrice'] ); } else { $url = $url->setQueryString( 'minPrice', NULL ); } if ( $values['maxPrice'] ) { $url = $url->setQueryString( 'maxPrice', $values['maxPrice'] ); } else { $url = $url->setQueryString( 'maxPrice', NULL ); } Output::i()->redirect( $url ); } Output::i()->title = Member::loggedIn()->language()->addToStack('price_filter'); Output::i()->output = $form->customTemplate( array( Theme::i()->getTemplate( 'forms', 'core' ), 'popupTemplate' ) ); } /** * Show the index * * @return void */ protected function _indexView() : void { /* New Products */ $newProducts = array(); $nexus_store_new = explode( ',', Settings::i()->nexus_store_new ); if ( $nexus_store_new[0] ) { $newProducts = Item::getItemsWithPermission( array( array( 'p_date_added>?', DateTime::create()->sub( new DateInterval( 'P' . $nexus_store_new[1] . 'D' ) )->getTimestamp() ) ), 'p_date_added DESC', $nexus_store_new[0] ); } /* Popular Products */ $popularProducts = array(); $nexus_store_popular = explode( ',', Settings::i()->nexus_store_popular ); if ( $nexus_store_popular[0] ) { $where = array(); $where[] = array( 'ps_app=? AND ps_type=? AND ps_start>?', 'nexus', 'package', DateTime::create()->sub( new DateInterval( 'P' . $nexus_store_popular[1] . 'D' ) )->getTimestamp() ); $where[] = "( p_member_groups='*' OR " . Db::i()->findInSet( 'p_member_groups', Member::loggedIn()->groups ) . ' )'; $where[] = array( 'p_store=?', 1 ); $popularIds = Db::i()->select( 'nexus_purchases.ps_item_id', 'nexus_purchases', $where, 'COUNT(ps_item_id) DESC', $nexus_store_popular[0], 'ps_item_id' )->join( 'nexus_packages', 'ps_item_id=p_id' ); if( count( $popularIds ) ) { $popularProducts = new ActiveRecordIterator( Db::i()->select( 'nexus_packages.*', 'nexus_packages', array( Db::i()->in( 'p_id', iterator_to_array( $popularIds ) ) ), 'FIELD(p_id, ' . implode( ',', iterator_to_array($popularIds) ) . ')' ), 'IPS\nexus\Package' ); } } /* Display */ Output::i()->sidebar['contextual'] = Theme::i()->getTemplate('store')->categorySidebar( ); Output::i()->linkTags['canonical'] = (string) Url::internal( 'app=nexus&module=store&controller=store', 'front', 'store' ); Output::i()->output = Theme::i()->getTemplate('store')->index( Customer::loggedIn()->cm_credits[ $this->currency ], $newProducts, $popularProducts ); Output::i()->title = Member::loggedIn()->language()->addToStack('module__nexus_store'); } /** * Registration Packages * * @return void */ public function register() : void { Output::i()->bodyClasses[] = 'ipsLayout_minimal'; Output::i()->sidebar['enabled'] = FALSE; Output::i()->output = Theme::i()->getTemplate('store')->register( Item::getItemsWithPermission( array( array( 'p_reg=1' ) ), 'pg_position, p_position', 10, 'read', Filter::FILTER_AUTOMATIC, 0, NULL, TRUE ) ); Output::i()->title = Member::loggedIn()->language()->addToStack('sign_up'); } /** * View Cart * * @return void */ protected function cart() : void { Output::i()->output = Theme::i()->getTemplate('store')->cart(); } }