diff --git a/www/api/v1/.htaccess b/www/api/v1/.htaccess index 6e5e71e..8e4c575 100644 --- a/www/api/v1/.htaccess +++ b/www/api/v1/.htaccess @@ -4,6 +4,8 @@ RewriteEngine On #RewriteRule ^/v1/?([*a-zA-Z0-9_-]+)$ /api/index.php?slug1=$1 [L] RewriteRule ^([*a-zA-Z0-9_-]+)(?:\/|)$ index.php?fi=$1 [L] +RewriteRule ^([*a-zA-Z0-9_-]+)/([*a-zA-Z0-9_-]+)(?:\/|)$ index.php?fi=$1&sc=$2 [L] +RewriteRule ^([*a-zA-Z0-9_-]+)/([*a-zA-Z0-9_-]+)/([*a-zA-Z0-9_-]+)(?:\/|)$ index.php?fi=$1&sc=$2&th=$3 [L] #RewriteRule ^([a-zA-Z0-9_-]+)/$ index.php?slug=$1 [L] #RewriteRule ^project/$ projects/index.php [L] diff --git a/www/api/v1/index.php b/www/api/v1/index.php index 8df1941..096c45e 100644 --- a/www/api/v1/index.php +++ b/www/api/v1/index.php @@ -14,6 +14,19 @@ elseif(isset($data['recipes'])){ } switch ($group){ + case "product": + include 'products.php'; + if(isset($data['sc'])){ $data['q'] = $data['sc']; } + if(isset($data['q'])){ + if(strlen($data['q']) > 2){ + returns( Product::search( $data['q']) ); + } + else { + returnsErr("Please insert another character for the search."); + } + } + break; + case "recipe": include 'recipes.php'; if(!empty($_POST)){ diff --git a/www/api/v1/products.php b/www/api/v1/products.php new file mode 100644 index 0000000..b88e367 --- /dev/null +++ b/www/api/v1/products.php @@ -0,0 +1,154 @@ + $v1){ + if($k1 > 2){ break; } + + $newArr = array(); + $newArr[] = $v1; + foreach ($array as $v2){ + if(!in_array($v2, $newArr)){ + $newArr[] = $v2; + } + } + $arrays[] = $newArr; + + if(count($array) > 2){ + $newArr = array(); + $newArr[] = $v1; + foreach (array_reverse($array) as $v2){ + if(!in_array($v2, $newArr)){ + $newArr[] = $v2; + } + } + $arrays[] = $newArr; + } + } + + return $arrays; +} + + +class Product { + static function addProduct(string $name, string $productGroup){ + + } + + static function search(string $search){ + global $db; + + $results = array(); + $searchWords = explode(' ', $search); + $searchOrg = $search; + $search = str_replace(' ', '%', strtolower($search)); + + $productSql = "SELECT * FROM product WHERE name LIKE '%$search%' ORDER BY IF(name LIKE '$search%', 0, 1)"; + $productSqlRes = $db->query($productSql); + while($row = $productSqlRes->fetch_assoc()){ + $resultArr = array(); + $resultArr['name'] = $row['name']; + $results[] = $resultArr; + if($productSqlRes->num_rows == 1){ + $productId = $row['product_id']; + } + } + + $whereClauseOr = array(); + $whereClauseOr[] = "p.name LIKE '%$search%'"; + foreach (reorderArray($searchWords) as $arr){ + $whereClauseOr[] = "pv.name LIKE '%".implode('%', $arr)."%'"; + } + + $variantSql = " + SELECT + pv.* + FROM + product_variant pv + INNER JOIN product p on pv.product_id = p.product_id + WHERE + ".implode(" OR ", $whereClauseOr)." + ORDER BY + IF(pv.name LIKE '$searchOrg', 0, 1), #exact search result first + IF(pv.name LIKE '$search%', 0, 1) + ;"; + +// echo $variantSql; +// return array(); + + $variantSqlRes = $db->query($variantSql); + while ($row = $variantSqlRes->fetch_assoc()){ + $product = new Product($row['product_id'], $row['name'], $row['variant_id']); + $results[] = $product->toArray(true); + } + + return $results; + } + + private int $product_id; + private int $variant_id; + private string $product_name; + public ?float $price = null; + public ?DateTime $price_updated = null; + public ?int $price_age = null; + + public function __construct(int $product_id, string $product_name, int $variant_id = 0){ + $this->product_id = $product_id; + $this->product_name = $product_name; + $this->variant_id = $variant_id; + } + + public function getPrice(): int|false { + global $db; + if(!empty($this->price)){ return $this->price; } + + // TO DO: Support for store-based-result + $getPriceSql = $db->query("SELECT product_id, date, price, product_variant FROM product_price WHERE product_id = {$this->product_id} AND product_variant = {$this->variant_id} ORDER BY date DESC LIMIT 1"); + + if($getPriceSql->num_rows == 1){ + $row = $getPriceSql->fetch_assoc(); + $this->price = $row['price']; + $this->price_updated = DateTime::createFromFormat('Y-m-d', $row['date']); + $this->price_age = $this->price_updated->diff(new DateTime())->days; + + return $this->price; + } + + return false; + } + + public function toArray($fetchMissingData = false): array { + if($fetchMissingData){ + $this->getPrice(); + } + + $productArray = array(); + $productArray['name'] = $this->product_name; + if($this->price){ + $productArray['price']['price'] = $this->price; + $productArray['price']['updated'] = $this->price_updated->format('Y-m-d'); + $productArray['price']['age'] = $this->price_age; + $productArray['price']['store'] = "store_id"; + } + + return $productArray; + } +} + +class ProductGroup { + private string $name; + +} \ No newline at end of file diff --git a/www/css/index.css b/www/css/index.css index 4a85f77..77f3e90 100644 --- a/www/css/index.css +++ b/www/css/index.css @@ -4,7 +4,7 @@ --wsTop: env(safe-area-inset-top); --wsBottom: constant(safe-area-inset-bottom); --wsBottom: env(safe-area-inset-bottom); - --footerHeight: 51px; + --footerHeight: 72px; /* One line: 51px -> two lines 72px */ } html { @@ -79,6 +79,15 @@ body { height: 38px; } +#stores .card-body { + transition: height 500ms; +} +@media (prefers-reduced-motion) { + #stores .card-body { + transition: none; + } +} + .priceWrapper { float: right; text-align: right; @@ -107,12 +116,36 @@ span.recipeItemAmount::after { } -.itemAmountWrapper { - clear: both; +.list-group-item:focus-within .itemAmountButtons, +.list-group-item:hover .itemAmountButtons +{ + height: 35px; +} +ul.storePlanState .list-group-item:focus-within .itemAmountText, +ul.storePlanState .list-group-item:hover .itemAmountText +{ + height: 0; + overflow: hidden; +} + +.itemAmountButtons { + transition: height 400ms; + overflow: hidden; + height: 0; + width: 100%; +} +@media (prefers-reduced-motion) { + .itemAmountButtons, .itemAmountText { + transition: none; + } } .itemAmountText { font-size: 0.9rem; } +ul.storePlanState .itemAmountText { + height: 35px; + transition: height 400ms; +} .itemAmount.itemAmountBtn { -webkit-appearance: none; -moz-appearance: textfield; @@ -128,6 +161,11 @@ span.recipeItemAmount::after { .addItemForm div.form-control { transition: width 0.5s; } +@media (prefers-reduced-motion) { + .addItemForm div.form-control { + transition: none; + } +} /* .addItemForm div.form-control:not([type="submit"],[type="image"]):focus-within { */ .addItemForm div.form-control:focus-within { width: 40%; @@ -148,6 +186,11 @@ span.recipeItemAmount::after { cursor: pointer; transition: transform 0.5s; } +@media (prefers-reduced-motion) { + .remItem { + transition: none; + } +} .remItem.confirm { transform: rotate(90deg); @@ -176,6 +219,21 @@ span.recipeItemAmount::after { border-radius: 30px; font-weight: bold; } + +.itemSearchDropdown { + width: 100%; + box-shadow: 0 0 2px 2px #2229; + text-align: left; +} +.itemSearchDropdown > li { + border-bottom: 1px solid #5655; + padding: 5pt; + display: flex; + cursor: pointer; +} +.itemSearchDropdown > li:hover { + background: #0004; +} /*}*/ #page-container { /** body ? **/ diff --git a/www/plan/htmlElements.js b/www/plan/htmlElements.js new file mode 100644 index 0000000..03fc6a6 --- /dev/null +++ b/www/plan/htmlElements.js @@ -0,0 +1,59 @@ +class HtmlElements { + + static store = { + editStoreSection: function (name) { + return "
\n" + + "
\n" + + "
\n" + + "
\n" + + " \n" + + "
\n" + + "
\n" + + " \n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + " \n" + + "
\n" + + "
\n" + + " \n" + + "
\n" + + "
\n" + + " \n" + + "
\n" + + "
\n" + + "
"; + }, + editStoreSectionEvents: function(){ + }, + + items: { + + }, + + addItemSection: + "\n" + + "
\n" + + "
\n" + + "
\n" + + " \n" + + " \n" + + "
\n" + + "
\n" + + " \n" + + " \n" + + "
\n" + + "
\n" + + " \n" + + "
\n" + + // " \n" + + "
\n" + + "
" + } +} \ No newline at end of file diff --git a/www/plan/index.php b/www/plan/index.php index fbc0712..e685ed7 100644 --- a/www/plan/index.php +++ b/www/plan/index.php @@ -33,8 +33,10 @@ + + diff --git a/www/plan/plan.js b/www/plan/plan.js index bc9deb1..e919e60 100644 --- a/www/plan/plan.js +++ b/www/plan/plan.js @@ -1,5 +1,7 @@ /*jshint sub:true, esversion: 6, -W083 */ - +// Default timer for jquery slide, except set to 0 if the user prefers reduced motion. +const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; +const slideTimer = prefersReducedMotion?0:400; class Store { constructor(title, storeID, state) { this.itemsObj = {}; @@ -30,24 +32,7 @@ class Store { html += "
  • "; html += " "; - html += " "; - html += "
    "; - html += "
    "; - // html += " "; - // html += " "; - html += "
    "; - html += " "; - html += " "; - html += "
    "; - html += "
    "; - html += " "; - html += " "; - html += "
    "; - html += "
    "; - html += " "; - html += "
    "; - html += "
    "; - html += "
    "; + html += HtmlElements.store.addItemSection; html += " "; html += " "; @@ -55,6 +40,10 @@ class Store { this.selector = $(html).appendTo("#stores"); + HtmlElements.store.editStoreSectionEvents(); + + this.selector.find(".newItemName").on('keyup', productSearchEvent); + this.selector.find(".addItemForm").on('submit', ev => { ev.preventDefault(); @@ -73,19 +62,22 @@ class Store { if($(ev.currentTarget).hasClass('planningState')){ this.setState('planning'); - this.selector.find(".newItemName").first().focus(); + // this.selector.find(".newItemName").first().focus(); + this.selector.find(".card-body").focus(); } else if($(ev.currentTarget).hasClass('shoppingState')){ this.setState('shopping'); - this.selector.find(".checkItems input").first().focus(); + // this.selector.find(".checkItems input").first().focus(); + this.selector.find(".card-body").focus(); } else if($(ev.currentTarget).hasClass('closedState')){ this.setState('closed'); - this.selector.find(".removeStore").first().focus(); + this.selector.find(".card-body").focus(); } }); this.selector.find('.editStoreName').one('click', ev => { this.editNameFn(ev); }); + // this.selector.find('.editStoreName').on('click', ev => { this.toggleEditStore(); }); this.selector.find('.removeStore').on('click', ev => { if(confirm("Are you sure you want to remove this store?")){ this.removeStore(); } @@ -113,22 +105,24 @@ class Store { } setState(state, animTime){ - animTime = animTime || 200; + animTime = prefersReducedMotion?0:(animTime || 200); let prevState = this.state; if(state === "planning"){ this.state = "planning"; + this.selector.find('ul').addClass('storePlanState'); this.selector.find('li:not(.checkedItem) .itemAmountButtons').slideDown(animTime); - this.selector.find('li:not(.checkedItem) .itemAmountText').slideUp(animTime); - this.selector.find('.checkedItem .itemAmountText:not(.oneItem)').slideDown(animTime); + // this.selector.find('li:not(.checkedItem) .itemAmountText').slideUp(animTime); + // this.selector.find('.checkedItem .itemAmountText:not(.oneItem)').slideDown(animTime); this.selector.find('.addItemFormWrapper').slideDown(animTime); this.selector.find('.remItem').show(); this.draggingClass.unpause(); } if(state !== "planning"){ + this.selector.find('ul').removeClass('storePlanState'); this.selector.find('.itemAmountButtons').slideUp(animTime); - this.selector.find('.itemAmountText:not(.oneItem)').slideDown(animTime); + // this.selector.find('.itemAmountText:not(.oneItem)').slideDown(animTime); this.selector.find('.addItemFormWrapper').slideUp(animTime); this.selector.find('.remItem').hide(); this.draggingClass.pause(); @@ -147,7 +141,7 @@ class Store { } if(state !== "closed"){ - + this.selector.find('.archiveSection').slideUp(animTime); } if(prevState !== state){ @@ -234,31 +228,37 @@ class Store { price = Number(price); this.itemsObj[itemID] = { text: text, price: price, itemID: itemID, amount: amount, checked: checked }; - let html = "\n"; - html += "
  • "; // draggable='true' - // html += " "; - html += " "; - html += " "+text+""; + let html = + "
  • " + + "
    " + // draggable='true' + //" " + + " " + + "
    "+text+"
    " + - html += "
    "+(price*amount).toFixed(2)+""; - html += "Remove item
    "; + "
    " + + " "+(price*amount).toFixed(2)+"" + + " Remove item" + + "
    " + + "
    " + - html += " "; - html += "
    "; - html += " "; - html += " "; - if(price !== 0) { - html += " x" + price.toFixed(2) + ""; - } - html += " "; - html += "
    "; - html += "
    "; // - html += " Amount: "+amount+"x "; - if(price !== 0) { html += "" + price.toFixed(2) + ""; } - html += "
    "; - html += "
    "; + " " + + "
    " + + // "
    " + + " " + + " " + + (price !== 0?" x" + price.toFixed(2) + "":"") + + " " + + // "
    " + + // " " + + "
    " + + // "
    " + + "
    " + + " Amount: "+amount+"x " + + (price !== 0?"" + price.toFixed(2) + "":"") + + "
    " + + " " + - html += "
  • "; + "\n"; this.selector.find("ul.storeItems").append(html); this.selector.find(".emptyList").hide(); @@ -404,9 +404,11 @@ class Store { if(newValue === 1){ textAmountElem.addClass('oneItem'); + textAmountElem.hide(); } else { textAmountElem.removeClass('oneItem'); + textAmountElem.show(); } } }); diff --git a/www/plan/product.js b/www/plan/product.js new file mode 100644 index 0000000..62c683b --- /dev/null +++ b/www/plan/product.js @@ -0,0 +1,96 @@ + + +/* + Function productSearchEvent( event ) + This function is intended to run on `onKeyUp` and `onChange` events for a text-input. +*/ + +function productSearchEvent(event){ + const itemNameElem = $(event.target); + let search = itemNameElem.val(); + let formElem = itemNameElem.closest('form'); + if(formElem.find('.itemSearchDropdown').length === 0){ + formElem.append(''); + } + const searchDropdown = formElem.find('.itemSearchDropdown'); + searchDropdown.hide().html(''); + + let productSearch = new ProductSearch(itemNameElem, searchDropdown); + + if(search.length > 2){ + productSearch.search(search); + } + else { + searchDropdown.hide().html(''); + } +} + +let apiProductSearchTimer; // used for the product search timeout + +class ProductSearch { + constructor(textInput, dropdownElem) { + this.textInput = textInput; + this.dropdownElem = dropdownElem; + } + + search(search){ + // apiProductSearchTimer declared in upper scope + clearTimeout(apiProductSearchTimer); + + apiProductSearchTimer = setTimeout(()=>{ + $.getJSON('/api/v1/product/'+search).done(json => { + this.dropdownElem.hide().html(''); + for (const resultKey in json.data) { + const result = json.data[resultKey]; + let searchResHtml = ""; + if(typeof result.price !== "undefined"){ + let priceAge = priceAgeFormat(result.price.age); + searchResHtml = + "
  • " + + "
    "+result.name+"
    " + + "
    "+(result.price.price.toFixed(2))+"
    " + + "
  • "; + } + else { + searchResHtml = + "
  • " + + "
    "+result.name+"
    " + + "
  • "; + } + this.dropdownElem.show().append(searchResHtml); + } + this.dropdownElem.find('li').on('click', ev => { this.priceClick(ev); }); + }); + }, 500); + } + + priceClick(ev){ + let clickedResult = $(ev.currentTarget); + let prodName = clickedResult.attr('data-name'); + let prodPrice = clickedResult.attr('data-price'); + console.log(prodName, prodPrice); + this.textInput.val(prodName) + this.textInput.parent().parent().find('.newItemPrice').val(prodPrice); + this.dropdownElem.hide().html(''); + } +} + + +function priceAgeFormat(age){ + let price = {}; + + if(age <= 4) { + price.color = "var(--bs-success)"; + price.text = "Price is updated within the last 4 days"; + } + else if(age <= 14) { + price.color = "var(--bs-warning)"; + price.text = "Price is updated within the last 2 weeks"; + } + else { + price.color = "var(--bs-danger)"; + price.text = "Price was last updated more than 2 weeks ago"; + } + + return price; +} \ No newline at end of file diff --git a/www/plan/recipe.js b/www/plan/recipe.js index e7170f8..82a8fe2 100644 --- a/www/plan/recipe.js +++ b/www/plan/recipe.js @@ -20,9 +20,9 @@ class Recipe { let html = "
    "+ "

    "+ - " "+ + " "+ "

    "+ - "
    "+ + "