4 Commits

Author SHA1 Message Date
Krzysztof Płaczek
2e4c79c87a Merge branch 'master' into feature/order-columns
# Conflicts:
#	Dockerfile
#	templates/productList.html.twig
2025-03-17 12:52:07 +01:00
Krzysztof Płaczek
c947e470af Increase php memory limit 2025-02-04 20:41:58 +01:00
Krzysztof Płaczek
8eeba225ed Merge branch 'master' of 192.168.0.129:krzysiej/ryobi-crawler into feature/order-columns 2025-01-28 09:15:49 +01:00
Krzysztof Płaczek
9b6bcd22be Start working on sorting products by different columns. 2025-01-27 12:10:15 +01:00
38 changed files with 997 additions and 1519 deletions

View File

@@ -1,15 +0,0 @@
on:
push:
workflow_dispatch:
jobs:
deploy-job:
runs-on: server
steps:
- name: initial test
run: echo "test"
- name: get page
run: curl http://192.168.0.129:9001/update
- run: pwd
- run: cd ../ && pwd
- run: pwd
- run: cd /var/www/html/ryobi-crawler && bin/update

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
/vendor/ /vendor/
.idea .idea
*.sqlite database.sqlite
var/cache/ var/cache/
.env.local .env.local

View File

@@ -1,8 +1,8 @@
FROM php:8.4-cli FROM php:8.3-cli
WORKDIR /usr/src/app WORKDIR /usr/src/app
ENV PHP_MEMORY_LIMIT=1500M ENV PHP_MEMORY_LIMIT=512M
RUN echo "memory_limit=1500M" > /usr/local/etc/php/conf.d/memory-limit.ini RUN echo "memory_limit=512M" > /usr/local/etc/php/conf.d/memory-limit.ini
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
git \ git \
unzip \ unzip \

View File

@@ -4,7 +4,7 @@
1. Clone repository using `git clone https://git.techtube.pl/krzysiej/ryobi-crawler.git` 1. Clone repository using `git clone https://git.techtube.pl/krzysiej/ryobi-crawler.git`
2. Cd into project directory `cd ryobi-crawler` 2. Cd into project directory `cd ryobi-crawler`
3. Build and start docker container `docker compose up -d --build --force-recreate` 3. Build and start docker container `docker compose up -d`
4. Run `docker compose exec php-app php console.php app:migrate` file to create `database.sqlite` and create tables. 4. Run `docker compose exec php-app php console.php app:migrate` file to create `database.sqlite` and create tables.
5. Run `docker compose exec php-app php console.php app:scrape` command to scrape all the products from the ryobi website. 5. Run `docker compose exec php-app php console.php app:scrape` command to scrape all the products from the ryobi website.
6. Access web interface using `localhost:9001` address in web browser. 6. Access web interface using `localhost:9001` address in web browser.
@@ -17,17 +17,10 @@
3. Refresh cache on production by removing cache directory: `rm -rf var/cache` 3. Refresh cache on production by removing cache directory: `rm -rf var/cache`
4. Start and build image in one go with command: `docker compose up -d --build --force-recreate` 4. Start and build image in one go with command: `docker compose up -d --build --force-recreate`
## Bonus
### Install composer package
1. Run `bin/composer require vendor/package-name`
## Running Cron ## Running Cron
For now only way of running `app:scrape` command on schedule is to use host crontab. For now only way of running `app:scrape` command on schedule is to use host crontab.
1. Run `crontab -e` command to edit a host crontab job file 1. Run `crontab -e` command to edit host crontab job file
2. Add a new line with e.g. line like this `0 1 * * * cd /var/project/directory/ && docker compose exec php-app php console.php app:scrape` 2. Add a new line with e.g. line like this `0 1 * * * cd /var/project/directory/ && docker compose exec php-app php console.php app:scrape`
3. Save and exit file editor. Cron will execute `app:scrape` once per day. 3. Save and exit file editor. Cron will execute `app:scrape` once per day.

View File

@@ -1,4 +0,0 @@
#!/usr/bin/env bash
echo "Cleaning cache"
bin/cli rm -rf var/cache
echo "cache cleaned2"

View File

@@ -1,2 +0,0 @@
#!/usr/bin/env bash
bin/cli php console.php app:cache:warm-twig

View File

@@ -1,3 +0,0 @@
#!/usr/bin/env bash
[ -z "$1" ] && bin/cli composer list && exit
bin/cli composer "$@"

View File

@@ -1,6 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
echo "Updating project" echo "Updating project"
git pull origin master git pull origin master
bin/cacheclean bin/cli rm -rf var/cache
bin/composer install --no-dev
echo "Project updated" echo "Project updated"

View File

@@ -1,19 +1,17 @@
{ {
"require": { "require": {
"guzzlehttp/guzzle": "^7", "guzzlehttp/guzzle": "^7.0",
"illuminate/database": "11.26.0.0", "illuminate/database": "11.26.0.0",
"ext-json": "*", "ext-json": "*",
"symfony/console": "^8.0", "symfony/console": "^7.0",
"symfony/routing": "^8.0", "symfony/routing": "^7.1",
"laravel/serializable-closure": "^1.3", "laravel/serializable-closure": "^1.3",
"symfony/http-kernel": "^8.0", "symfony/http-kernel": "^7.1",
"symfony/framework-bundle": "^8.0", "symfony/framework-bundle": "^7.1",
"symfony/twig-bundle": "^8.0", "symfony/twig-bundle": "^7.1",
"symfony/dotenv": "^8.0", "symfony/dotenv": "^7.1",
"twig/intl-extra": "^3.13", "twig/intl-extra": "^3.13",
"twig/extra-bundle": "^3.13", "twig/extra-bundle": "^3.13"
"symfony/cache": "^8.0",
"twig/cache-extra": "^3.21"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@@ -21,7 +19,6 @@
} }
}, },
"require-dev": { "require-dev": {
"symfony/var-dumper": "^8.0" "symfony/var-dumper": "^7.1"
}, }
"minimum-stability": "stable"
} }

1562
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,16 +2,18 @@
include_once 'vendor/autoload.php'; include_once 'vendor/autoload.php';
use Krzysiej\RyobiCrawler\Kernel; use Krzysiej\RyobiCrawler\Command\Migrate;
use Symfony\Bundle\FrameworkBundle\Console\Application; use Krzysiej\RyobiCrawler\Command\ScrapeWebsite;
use Symfony\Component\Console\Application;
if (php_sapi_name() !== 'cli') { if (php_sapi_name() !== 'cli') {
header('Location: browser.php'); header('Location: browser.php');
echo 'Execute this script in cli only'; echo 'Execute this script in cli only';
exit; exit;
} }
$kernel = new Kernel('dev', true);
$application = new Application($kernel); $application = new Application('Ryobi website scraper application', '1.1.1');
$application->setName('Ryobi website scraper application'); $application->add(new ScrapeWebsite());
$application->setVersion('1.2.0'); $application->add(new Migrate());
$application->run(); $application->run();

View File

@@ -1,11 +1,46 @@
<?php <?php
use Krzysiej\RyobiCrawler\Kernel; use Krzysiej\RyobiCrawler\Twig\AppExtension;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\Dotenv\Dotenv; use Symfony\Component\Dotenv\Dotenv;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
require 'vendor/autoload.php'; require 'vendor/autoload.php';
class Kernel extends BaseKernel
{
use MicroKernelTrait;
public function registerBundles(): iterable
{
return [
new Symfony\Bundle\FrameworkBundle\FrameworkBundle(),
new Symfony\Bundle\TwigBundle\TwigBundle(),
new Twig\Extra\TwigExtraBundle\TwigExtraBundle(),
];
}
protected function configureContainer(ContainerConfigurator $container): void
{
$container->extension('framework', [
'secret' => 'S0ME_SECRET'
]);
$container->services()
->load('Krzysiej\\RyobiCrawler\\Controller\\', __DIR__.'/src/Controller/*')
->autowire()
->autoconfigure()
;
$container->services()->set(AppExtension::class)->tag('twig.extension');
}
protected function configureRoutes(RoutingConfigurator $routes): void
{
$routes->import(__DIR__.'/src/Controller/', 'attribute');
}
}
(new Dotenv())->bootEnv(__DIR__.'/.env'); (new Dotenv())->bootEnv(__DIR__.'/.env');
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); $kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 199 KiB

View File

@@ -6,13 +6,11 @@ namespace Krzysiej\RyobiCrawler\Command;
use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Capsule\Manager as Capsule;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Krzysiej\RyobiCrawler\Models\Country;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use function Symfony\Component\Clock\now;
#[AsCommand(name: 'app:migrate', description: 'Create database and rum migrations')] #[AsCommand(name: 'app:migrate', description: 'Create database and rum migrations')]
class Migrate extends Command class Migrate extends Command
@@ -26,10 +24,11 @@ class Migrate extends Command
public function execute(InputInterface $input, OutputInterface $output): int public function execute(InputInterface $input, OutputInterface $output): int
{ {
if ($input->getOption(self::RECREATE_OPTION)) { if (true === $input->hasOption(self::RECREATE_OPTION)) {
unlink(__DIR__ . '/../../database.sqlite'); unlink(__DIR__ . '/../../database.sqlite');
touch(__DIR__ . '/../../database.sqlite'); //sleep(5);
} }
touch(__DIR__ . '/../../database.sqlite');
$capsule = new Capsule; $capsule = new Capsule;
$capsule->addConnection([ $capsule->addConnection([
'driver' => 'sqlite', 'driver' => 'sqlite',
@@ -40,9 +39,6 @@ class Migrate extends Command
$this->createProductsTable(); $this->createProductsTable();
$this->createPricesTable(); $this->createPricesTable();
$this->createStocksTable(); $this->createStocksTable();
$this->addColumns();
$this->createCountriesTable();
$this->index();
return Command::SUCCESS; return Command::SUCCESS;
} }
@@ -81,51 +77,6 @@ class Migrate extends Command
} }
} }
public function createCountriesTable(): void
{
if (!Capsule::schema()->hasTable('countries')) {
Capsule::schema()->create('countries', function (Blueprint $table) {
$table->increments('id');
$table->text('countryName');
$table->text('productsUrl');
$table->text('cultureCode');
$table->text('currency');
$table->text('locale');
$table->timestamps();
});
}
if (Capsule::schema()->hasTable('countries') && !Country::where('countryName', 'Poland')->exists()) {
$id = Capsule::table('countries')->insertGetId(
[
'countryName' => 'Poland',
'productsUrl' => 'https://pl.ryobitools.eu/api/product-listing/get-products',
'cultureCode' => 'pl-PL',
'currency' => 'PLN',
'locale' => 'pl',
'created_at' => now(),
'updated_at' => now(),
]);
}
if (Capsule::schema()->hasTable('countries') && !Country::where('countryName', 'UK')->exists()) {
Capsule::table('countries')->insert([
'countryName' => 'UK',
'productsUrl' => 'https://uk.ryobitools.eu/api/product-listing/get-products',
'cultureCode' => 'en-GB',
'currency' => 'GBP',
'locale' => 'uk',
'created_at' => now(),
'updated_at' => now(),
]
);
}
if (!Capsule::schema()->hasColumn('products', 'country_id')) {
Capsule::schema()->table('products', function (Blueprint $table) use ($id) {
$table->foreignId('country_id')->default($id)->references('id')->on('countries');
});
}
}
public function createStocksTable(): void public function createStocksTable(): void
{ {
if (!Capsule::schema()->hasTable('stocks')) { if (!Capsule::schema()->hasTable('stocks')) {
@@ -143,110 +94,4 @@ class Migrate extends Command
}); });
} }
} }
public function addColumns(): void
{
if (!Capsule::schema()->hasColumn('products', 'priceCurrent')) {
Capsule::schema()->table('products', function (Blueprint $table) {
$table->float('priceCurrent')->default(0);
});
}
if (!Capsule::schema()->hasColumn('products', 'priceLowest')) {
Capsule::schema()->table('products', function (Blueprint $table) {
$table->float('priceLowest')->default(0);
});
}
if (!Capsule::schema()->hasColumn('products', 'productStandardPrice')) {
Capsule::schema()->table('products', function (Blueprint $table) {
$table->float('productStandardPrice')->default(0);
});
}
if (!Capsule::schema()->hasColumn('products', 'lowestProductPrice30Days')) {
Capsule::schema()->table('products', function (Blueprint $table) {
$table->float('lowestProductPrice30Days')->default(0);
});
}
if (!Capsule::schema()->hasColumn('products', 'lastSeen')) {
Capsule::schema()->table('products', function (Blueprint $table) {
$table->date('lastSeen')->nullable();
});
}
if (!Capsule::schema()->hasColumn('products', 'stock')) {
Capsule::schema()->table('products', function (Blueprint $table) {
$table->integer('stock')->nullable();
});
}
if (!Capsule::schema()->hasColumn('products', 'promotions')) {
Capsule::schema()->table('products', function (Blueprint $table) {
$table->json('promotions')->nullable();
});
}
}
public function index(): void
{
if (!$this->hasIndex('products_skuid_country_id_unique')) {
Capsule::schema()->table('products', function (Blueprint $table) {
if ($this->hasIndex('products_skuid_unique')) {
$table->integer('skuID')->unique(false)->change();
}
$table->unique(['skuID', 'country_id']);
});
}
if (!$this->hasIndex('prices_product_id_index')) {
Capsule::schema()->table('prices', function (Blueprint $table) {
$table->index('product_id');
});
}
if (!$this->hasIndex('products_id_index')) {
Capsule::schema()->table('products', function (Blueprint $table) {
$table->index('id');
});
}
if (!$this->hasIndex('stocks_product_id_stock_index')) {
Capsule::schema()->table('stocks', function (Blueprint $table) {
$table->index(['product_id', 'stock']);
});
}
if (!$this->isFK('products', 'id', 'stocks', 'product_id')) {
Capsule::schema()->table('products', function (Blueprint $table) {
$table->foreign('id')->references('product_id')->on('stocks');
});
}
if (!$this->isFK('products', 'id', 'prices', 'product_id')) {
Capsule::schema()->table('products', function (Blueprint $table) {
$table->foreign('id')->references('product_id')->on('prices');
});
}
if (!$this->isFK('products', 'country_id', 'countries', 'id')) {
Capsule::schema()->table('products', function (Blueprint $table) {
$table->foreign('country_id')->references('id')->on('countries');
});
}
}
private function isFK(string $table, string $column, string $fTable, string $fColumn): bool
{
$fkColumns = Capsule::schema()->getForeignKeys($table);
return !empty(array_filter($fkColumns, fn($fkColumn) => ($fkColumn['foreign_table'] == $fTable &&
in_array($fColumn, $fkColumn['foreign_columns']) &&
in_array($column, $fkColumn['columns']))
));
}
private function hasIndex(string $indexName): bool
{
return !!count(Capsule::select('SELECT name FROM sqlite_master WHERE type = "index" and name = ?', [$indexName]));
}
} }

View File

@@ -7,7 +7,6 @@ namespace Krzysiej\RyobiCrawler\Command;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Capsule\Manager as Capsule;
use Krzysiej\RyobiCrawler\Models\Country;
use Krzysiej\RyobiCrawler\Models\Price; use Krzysiej\RyobiCrawler\Models\Price;
use Krzysiej\RyobiCrawler\Models\Product; use Krzysiej\RyobiCrawler\Models\Product;
use Krzysiej\RyobiCrawler\Models\Stock; use Krzysiej\RyobiCrawler\Models\Stock;
@@ -15,90 +14,61 @@ use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:scrape', description: 'Scrape all products from Ryobi website')] #[AsCommand(name: 'app:scrape', description: 'Scrape all products from Ryobi website')]
class ScrapeWebsite extends Command class ScrapeWebsite extends Command
{ {
const COUNTRY_ID = 'country';
private Client $client; private Client $client;
public function __construct(protected Capsule $database)
{
parent::__construct();
}
protected function configure(): void protected function configure(): void
{ {
$capsule = new Capsule;
$capsule->addConnection([
'driver' => 'sqlite',
'database' => __DIR__ . '/../../database.sqlite',
]);
$capsule->setAsGlobal();
$capsule->bootEloquent();
$this->client = new Client(); $this->client = new Client();
$this->addOption(self::COUNTRY_ID, 'c', InputOption::VALUE_OPTIONAL, 'Country id');
} }
public function execute(InputInterface $input, OutputInterface $output): int public function execute(InputInterface $input, OutputInterface $output): int
{ {
$countryId = intval($input->getOption(self::COUNTRY_ID));
$output->writeln('Scrape products');
$countries = Country::all()->when($countryId, fn ($query) => $query->where('id', $countryId));
foreach($countries as $country) {
$output->writeln('Country name: ' . $country->countryName);
$products = $this->getProducts($country);
$progress = new ProgressBar($output);
$progress->start();
$progress->setMaxSteps(count($products));
foreach ($products as $product) {
$this->saveProduct($product, $country);
$progress->advance();
}
$progress->finish();
$output->writeln("\nScrape products - DONE\n");
}
$output->writeln('Update prices');
$products = Product::all();
$progress = new ProgressBar($output); $progress = new ProgressBar($output);
$progress->setMaxSteps(count($products));
$progress->start(); $progress->start();
foreach($products as $product) { $products = $this->getProducts();
$newestPrice = $product->newestPrice; $progress->setMaxSteps(count($products));
$currentStock = $product->currentStock; foreach ($products as $product) {
$product->priceCurrent = $newestPrice->price; $this->saveProduct($product);
$product->productStandardPrice = $newestPrice->productStandardPrice;
$product->lowestProductPrice30Days = $newestPrice->lowestProductPrice30Days;
$product->priceLowest = $product->lowestPrice->price;
$product->lastSeen = $newestPrice->created_at->format('Y-m-d');
$product->stock = $currentStock->stock;
$product->save(['timestamps' => false]);
$progress->advance(); $progress->advance();
} }
$progress->finish(); $progress->finish();
$output->writeln(''); $output->writeln('');
$output->writeln('Update prices - DONE'); $output->writeln('DONE');
$output->writeln('COMMAND - DONE');
return Command::SUCCESS; return Command::SUCCESS;
} }
private function getProducts(Country $country): array private function getProducts(): array
{ {
$products = []; $products = [];
$page = 0; $page = 0;
do { do {
try { try {
$res = $this->client->request('POST', $country->productsUrl, [ $res = $this->client->request('POST', 'https://pl.ryobitools.eu/api/product-listing/get-products', [
'form_params' => [ 'form_params' => [
"includePreviousPages" => false, "includePreviousPages" => false,
"pageIndex" => $page, "pageIndex" => $page,
"pageSize" => 100, "pageSize" => 100,
"cultureCode" => $country->cultureCode, "cultureCode" => "pl-PL",
] ]
]); ]);
$responseObject = json_decode($res->getBody()->getContents()); $responseObject = json_decode($res->getBody()->getContents());
$products = array_merge($products, $responseObject->products); $products = array_merge($products, $responseObject->products);
$page++; $page++;
$canLoadMore = $responseObject->canLoadMore; $canLoadMore = $responseObject->canLoadMore;
} catch (GuzzleException $e) { } catch (GuzzleException) {
return $products; return $products;
} }
} while ($canLoadMore); } while ($canLoadMore);
@@ -106,24 +76,21 @@ class ScrapeWebsite extends Command
return $products; return $products;
} }
private function saveProduct(\stdClass $product, Country $country): void private function saveProduct(\stdClass $product): void
{ {
/** @var Product $productModel */ /** @var Product $productModel */
$productModel = Product::firstOrNew(['skuID' => $product->skuID, 'country_id' => $country->id]); $productModel = Product::firstOrNew(['skuID' => $product->skuID]);
$productModel->skuID = $product->skuID; $productModel->skuID = $product->skuID;
$productModel->name = $product->name; $productModel->name = $product->name;
$productModel->availableQuantity = $product->availableQuantity; $productModel->availableQuantity = $product->availableQuantity;
$productModel->categories = str_replace(['/', ' '], '', $product->categories); $productModel->categories = $product->categories;
$productModel->image = $product->image; $productModel->image = $product->image;
$productModel->subTitle = $product->subTitle; $productModel->subTitle = $product->subTitle;
$productModel->variantCode = $product->variantCode; $productModel->variantCode = $product->variantCode;
$productModel->modelCode = $product->modelCode; $productModel->modelCode = $product->modelCode;
$productModel->url = $product->url; $productModel->url = $product->url;
$productModel->lastSeen = date("Y-m-d");
$productModel->touch('updated_at'); $productModel->touch('updated_at');
$productModel->country()->associate($country);
$productModel->promotions = $product->promotions;
$productModel->save(); $productModel->save();
$priceExists = $productModel->price()->whereRaw("strftime('%Y-%m-%d', created_at) = ?", [date('Y-m-d')])->exists(); $priceExists = $productModel->price()->whereRaw("strftime('%Y-%m-%d', created_at) = ?", [date('Y-m-d')])->exists();

View File

@@ -2,8 +2,9 @@
namespace Krzysiej\RyobiCrawler\Controller; namespace Krzysiej\RyobiCrawler\Controller;
use Illuminate\Database\Eloquent\Builder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Cache\Adapter\FilesystemAdapter; use Symfony\Component\HttpFoundation\Request;
use Twig\Environment; use Twig\Environment;
use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Capsule\Manager as Capsule;
@@ -11,7 +12,26 @@ class BaseController extends AbstractController
{ {
protected Environment $twig; protected Environment $twig;
public function __construct( protected Capsule $database) public function __construct()
{ {
$capsule = new Capsule;
$capsule->addConnection(['driver' => 'sqlite', 'database' => __DIR__ . '/../../database.sqlite']);
$capsule->setAsGlobal();
$capsule->bootEloquent();
}
protected function handleOrderProducts(Builder $builder, Request $request): Builder
{
$builder->orderByDesc('starred')->orderByDesc('created_by');
if ($request->query->get('order')) {
$orderField = $request->query->get('order');
$direction = 'desc';
if (str_starts_with($orderField, '-')) {
$direction = 'asc';
}
$builder->orderBy($request->query->get('order'), $direction);
}
return $builder;
} }
} }

View File

@@ -8,40 +8,17 @@ use Symfony\Component\Routing\Attribute\Route;
final class CategoryController extends BaseController final class CategoryController extends BaseController
{ {
#[Route('/category/{category?}', name: 'app_category')] #[Route('/category/{category}', name: 'app_category')]
public function __invoke(?string $category): Response public function __invoke(string $category): Response
{ {
$products = Product::with('price')
/** @var Product[] $products */
$products = Product::with(['price', 'lowestPrice'])
->selectRaw('products.*') ->selectRaw('products.*')
->distinct('products.id') ->distinct('products.id')
->when(!is_null($category) , fn ($q) => $q->whereRaw('json_each.value = ?', [$category]))
->fromRaw('products, json_each(products.categories)') ->fromRaw('products, json_each(products.categories)')
->whereRaw('json_each.value = ?', [$category])
->orderByDesc('starred') ->orderByDesc('starred')
->orderByDesc('created_by') ->orderByDesc('created_by')
->get(); ->get();
return $this->render('productList.html.twig', ['products' => $products]);
$categoriesTree = [];
foreach ($products as $product) {
$categoriesTree = $this->addToTree($product->categories, $categoriesTree);
}
return $this->render('productList.html.twig', ['products' => $products, 'listType' => 'category_' . $category, 'category' => $category, 'categoryTree' => $categoriesTree]);
}
private function addToTree(array $categories, mixed $categoriesTree)
{
$tmp = &$categoriesTree;
foreach ($categories as $category) {
if (empty($tmp[$category])) {
$tmp[$category] = ['count' => 0];
}
$tmp = &$tmp[$category];
$tmp['count']++;
}
return $categoriesTree;
} }
} }

View File

@@ -14,10 +14,11 @@ final class DiscontinuedController extends BaseController
public function __invoke(): Response public function __invoke(): Response
{ {
$products = Product::where('lastSeen', '<', now()->format('Y-m-d')) $products = Product::where('updated_at', '<', now()->format('Y-m-d'))
->orderByDesc('starred') ->orderByDesc('starred')
->orderByDesc('created_by') ->orderByDesc('created_by')
->with(['currentPrice'])
->get(); ->get();
return $this->render('productList.html.twig', ['products' => $products, 'listType' => 'discontinued']); return $this->render('productList.html.twig', ['products' => $products]);
} }
} }

View File

@@ -3,19 +3,17 @@
namespace Krzysiej\RyobiCrawler\Controller; namespace Krzysiej\RyobiCrawler\Controller;
use Krzysiej\RyobiCrawler\Models\Product; use Krzysiej\RyobiCrawler\Models\Product;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
final class IndexController extends BaseController final class IndexController extends BaseController
{ {
#[Route('/', name: 'app_home')] #[Route('/', name: 'app_home')]
public function __invoke(): Response public function __invoke(Request $request): Response
{ {
$products = Product::orderByDesc('starred') $products = Product::with(['currentStock', 'price', 'currentPrice']);
->orderByDesc('created_by') $products = $this->handleOrderProducts($products, $request)->get();
->get(); return $this->render('productList.html.twig', ['products' => $products]);
return $this->render('productList.html.twig', ['products' => $products, 'listType' => 'all']);
} }
} }

View File

@@ -1,26 +0,0 @@
<?php
namespace Krzysiej\RyobiCrawler\Controller;
use Krzysiej\RyobiCrawler\Models\Product;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use function Symfony\Component\Clock\now;
final class LowestPriceController extends BaseController
{
#[Route('/lowest-price', name: 'app_lowest_price', methods: ['GET'])]
public function __invoke(): Response
{
$listType = 'lowest_price';
$products = Product::whereRaw('priceCurrent = priceLowest')
->whereRaw('lastSeen = "'.now()->format('Y-m-d').'"')
->whereRaw('priceCurrent < productStandardPrice')
->orderByDesc('starred')
->orderByDesc('created_by')
->with(['currentPrice', 'lowestPrice'])
->get();
return $this->render('productList.html.twig', ['products' => $products, 'listType' => $listType]);
}
}

View File

@@ -2,6 +2,7 @@
namespace Krzysiej\RyobiCrawler\Controller; namespace Krzysiej\RyobiCrawler\Controller;
use Illuminate\Database\Eloquent\Builder;
use Krzysiej\RyobiCrawler\Models\Product; use Krzysiej\RyobiCrawler\Models\Product;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -13,12 +14,12 @@ final class NewController extends BaseController
#[Route('/new', name: 'app_new')] #[Route('/new', name: 'app_new')]
public function __invoke(): Response public function __invoke(): Response
{ {
$date = now()->modify('-30 days')->format('Y-m-d'); $products = Product::where('created_at', '>', now()->modify('-30 days')->format('Y-m-d'))
$products = Product::where('created_at', '>', $date)
->with(['country', 'stock'])
->orderByDesc('starred') ->orderByDesc('starred')
->orderByDesc('name')
->orderByDesc('created_by') ->orderByDesc('created_by')
->with(['currentPrice'])
->get(); ->get();
return $this->render('productList.html.twig', ['products' => $products, 'listType' => 'new']); return $this->render('productList.html.twig', ['products' => $products]);
} }
} }

View File

@@ -26,38 +26,26 @@ final class ProductController extends BaseController
if (null === $product) { if (null === $product) {
throw $this->createNotFoundException('Product not found'); throw $this->createNotFoundException('Product not found');
} }
$priceList = $product->price()->pluck('price', 'created_at')->mapWithKeys(fn($price, $createdAt) => [explode(' ', $createdAt)[0] => $price])->toArray(); $priceList = $product->price()->pluck('price');
$stockList = $product->stock()->pluck('stock', 'created_at')->mapWithKeys(fn($stock, $createdAt) => [explode(' ', $createdAt)[0] => $stock])->toArray(); $stockList = $product->stock()->pluck('stock');
$priceDates = $product->price()->pluck('created_at')->map(fn($date) => $date->format('Y-m-d'))->toArray();
ksort($stockList); $stockDates = $product->stock()->pluck('created_at')->map(fn($date) => $date->format('Y-m-d'))->toArray();
ksort($priceList);
return $this->render('product.html.twig', [ return $this->render('product.html.twig', [
'product' => $product, 'product' => $product,
'price_list' => $this->prepareChartData($priceList), 'price_list' => $this->prepareChartData($priceDates, $priceList),
'stock_list' => $this->prepareChartData($stockList), 'stock_list' => $this->prepareChartData($stockDates, $stockList),
'price_dates' => implode("','", $this->dateRange(array_key_first($priceList), array_key_last($priceList))), 'price_dates' => implode("','", $priceDates),
]); ]);
} }
private function prepareChartData($set1): string private function prepareChartData($set1, $set2): string
{ {
$dates = $this->dateRange(array_key_first($set1), array_key_last($set1)); $data = [];
$data = array_map(fn($date) => ['x' => $date, 'y' => $set1[$date] ?? null], $dates); foreach ($set1 as $key => $value) {
$data[] = ['x' => $value, 'y' => $set2[$key]];
}
$stringData = json_encode($data); $stringData = json_encode($data);
return str_replace(['"x"', '"y"'], ['x', 'y'], $stringData); return str_replace(['"x"', '"y"'], ['x', 'y'], $stringData);
} }
private function dateRange($dateStart, $dateEnd): array
{
$from = new \DateTime($dateStart);
$to = new \DateTime($dateEnd);
$range = [];
for ($date = clone $from; $date < $to; $date->modify('+1 day')) {
$range[] = $date->format('Y-m-d');
}
return $range;
}
} }

View File

@@ -2,27 +2,21 @@
namespace Krzysiej\RyobiCrawler\Controller; namespace Krzysiej\RyobiCrawler\Controller;
use Illuminate\Database\Eloquent\Builder;
use Krzysiej\RyobiCrawler\Models\Product; use Krzysiej\RyobiCrawler\Models\Product;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
final class PromosController extends BaseController final class PromosController extends BaseController
{ {
#[Route('/promos/{promo?}', name: 'app_promos')] #[Route('/promos', name: 'app_promos')]
public function __invoke(?string $promo): Response public function __invoke(): Response
{ {
$products = Product::when(is_null($promo), fn($q) => $q->whereRaw('priceCurrent < productStandardPrice')) $products = Product::whereHas('currentPrice', fn(Builder $query) => $query->whereColumn('price', '<', 'productStandardPrice'))
->orderByDesc('starred') ->orderByDesc('starred')
->orderByDesc('created_by') ->orderByDesc('created_by')
->with(['currentPrice', 'lowestPrice']) ->with(['currentPrice'])
->when(!is_null($promo), fn($q) => $q->whereRaw("json_extract(promotions, '$.slug') LIKE ?", $promo))
->get(); ->get();
return $this->render('productList.html.twig', ['products' => $products]);
$promos = Product::select($this->database->getConnection()->raw("distinct json_extract(promotions, '$.slug') as slug, json_extract(promotions, '$.tag') as tag"))
->whereRaw("json_extract(promotions, '$.tag') is not null")
->get();
return $this->render('productList.html.twig', ['products' => $products, 'listType' => 'promos' . $promo, 'promos' => $promos->toArray()]);
} }
} }

View File

@@ -17,6 +17,6 @@ final class SearchController extends BaseController
->orWhere([['name', 'like', "%$search%"]]) ->orWhere([['name', 'like', "%$search%"]])
->orWhere([['subTitle', 'like', "%$search%"]])->get(); ->orWhere([['subTitle', 'like', "%$search%"]])->get();
return $this->render('productList.html.twig', ['products' => $products ?? [], 'search' => $search, 'listType' => 'search_'.$search]); return $this->render('productList.html.twig', ['products' => $products ?? [], 'search' => $search]);
} }
} }

View File

@@ -12,15 +12,7 @@ final class StarController extends BaseController
#[Route('/star/{productId<\d+>}', name: 'app_star')] #[Route('/star/{productId<\d+>}', name: 'app_star')]
public function __invoke(int $productId, Request $request): Response public function __invoke(int $productId, Request $request): Response
{ {
$referer = $request->headers->get('referer');
if (str_contains($referer, '/category/')) {
preg_match('#/category/(.*)#i', $referer, $matches);
}
if (str_contains($referer, '/search?search=')) {
preg_match('#/search\?search=(.*)#i', $referer, $matches);
}
Product::find($productId)->toggleStarred()->save(); Product::find($productId)->toggleStarred()->save();
return $this->redirect($referer); return $this->redirect($request->headers->get('referer'));
} }
} }

View File

@@ -1,18 +0,0 @@
<?php
namespace Krzysiej\RyobiCrawler\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class UpdateController extends BaseController
{
#[Route('/update', name: 'app_update')]
public function __invoke(Request $request): Response
{
$output = shell_exec('bin/update');
return new Response($output);
}
}

View File

@@ -1,14 +0,0 @@
<?php
namespace Krzysiej\RyobiCrawler;
use Illuminate\Database\Capsule\Manager as Capsule;
class DatabaseFactory
{
public static function create(Capsule $capsule): void
{
$capsule->addConnection(['driver' => 'sqlite', 'database' => __DIR__ . '/../database.sqlite']);
$capsule->setAsGlobal();
$capsule->bootEloquent();
}
}

View File

@@ -1,48 +0,0 @@
<?php
namespace Krzysiej\RyobiCrawler;
use Illuminate\Database\Capsule\Manager;
use Krzysiej\RyobiCrawler\Twig\AppExtension;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Bundle\TwigBundle\TwigBundle;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
use Twig\Extra\Cache\CacheExtension;
use Twig\Extra\Cache\CacheRuntime;
use Twig\Extra\TwigExtraBundle\TwigExtraBundle;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
public function registerBundles(): iterable
{
return [
new FrameworkBundle(),
new TwigBundle(),
new TwigExtraBundle(),
];
}
protected function configureContainer(ContainerConfigurator $container): void
{
$container->extension('framework', [
'secret' => 'S0ME_SECRET'
]);
$services = $container->services()->defaults()->autowire()->autoconfigure();
$services->set(Manager::class)->configurator([DatabaseFactory::class, 'create']);
$services->load('Krzysiej\\RyobiCrawler\\', __DIR__ )
->exclude('../src/{Models,Twig,DatabaseFactory.php,Kernel.php}');
$services->set('twig.extension', AppExtension::class)->tag('twig.extension');
}
protected function configureRoutes(RoutingConfigurator $routes): void
{
$routes->import(__DIR__ . '/Controller/', 'attribute');
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace Krzysiej\RyobiCrawler\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property string $countryName
* @property string $productsUrl
* @property string $cultureCode
* @property string $currency
* @property string $locale
*/
class Country extends Model
{
public $timestamps = true;
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
}

View File

@@ -2,14 +2,11 @@
namespace Krzysiej\RyobiCrawler\Models; namespace Krzysiej\RyobiCrawler\Models;
use Carbon\Traits\Date;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Str;
use function Symfony\Component\Clock\now; use function Symfony\Component\Clock\now;
/** /**
@@ -23,23 +20,11 @@ use function Symfony\Component\Clock\now;
* @property string $modelCode * @property string $modelCode
* @property string $url * @property string $url
* @property int $starred * @property int $starred
* @property float $priceCurrent
* @property float $priceLowest
* @property float $productStandardPrice
* @property float $lowestProductPrice30Days
* @property Date $lastSeen
* @property integer $stock
* @property Object $promotions
*/ */
class Product extends Model class Product extends Model
{ {
public $timestamps = true; public $timestamps = true;
public $fillable = ['skuID', 'country_id']; public $fillable = ['skuID'];
public function country(): BelongsTo
{
return $this->belongsTo(Country::class);
}
public function price(): HasMany public function price(): HasMany
{ {
@@ -56,16 +41,6 @@ class Product extends Model
return $this->hasOne(Price::class)->latestOfMany('created_at'); return $this->hasOne(Price::class)->latestOfMany('created_at');
} }
public function lowestPrice(): HasOne
{
return $this->hasOne(Price::class)->ofMany('price', 'MIN');
}
public function newestPrice(): HasOne
{
return $this->hasOne(Price::class)->latest()->take(1);
}
public function stock(): HasMany public function stock(): HasMany
{ {
return $this->hasMany(Stock::class); return $this->hasMany(Stock::class);
@@ -73,7 +48,9 @@ class Product extends Model
public function currentStock(): HasOne public function currentStock(): HasOne
{ {
return $this->stock()->one()->ofMany()->withDefault(fn(Stock $stock) => $stock->stock = 0)->take(1); return $this->stock()->one()->ofMany()->withDefault(function (Stock $stock) {
$stock->stock = 0;
});
} }
public function toggleStarred(): self public function toggleStarred(): self
@@ -91,23 +68,10 @@ class Product extends Model
); );
} }
public function promotions(): Attribute
{
return Attribute::make(
get: fn(?string $value) => json_decode($value ?? '{"hasPromotion": false}', 1),
set: function (\stdClass $value) {
$value->slug = Str::slug($value->tag);
return json_encode($value);
}
);
}
public function isDiscontinued(): bool public function isDiscontinued(): bool
{ {
return $this->lastSeen < now()->format('Y-m-d'); return $this->updated_at->format('Y-m-d') < now()->format('Y-m-d');
} }
public function isNew(): bool public function isNew(): bool
{ {
return $this->created_at->format('Y-m-d') > now()->modify('-30 days')->format('Y-m-d'); return $this->created_at->format('Y-m-d') > now()->modify('-30 days')->format('Y-m-d');

View File

@@ -2,11 +2,11 @@
namespace Krzysiej\RyobiCrawler\Twig; namespace Krzysiej\RyobiCrawler\Twig;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Krzysiej\RyobiCrawler\Models\Price; use Krzysiej\RyobiCrawler\Models\Price;
use Krzysiej\RyobiCrawler\Models\Product; use Krzysiej\RyobiCrawler\Models\Product;
use Krzysiej\RyobiCrawler\Models\Stock; use Krzysiej\RyobiCrawler\Models\Stock;
use Symfony\Component\Routing\RouterInterface;
use Twig\Extension\AbstractExtension; use Twig\Extension\AbstractExtension;
use Twig\TwigFilter; use Twig\TwigFilter;
use Twig\TwigFunction; use Twig\TwigFunction;
@@ -15,19 +15,12 @@ use function Symfony\Component\Clock\now;
class AppExtension extends AbstractExtension class AppExtension extends AbstractExtension
{ {
public function __construct(public RouterInterface $route)
{
}
public function getFunctions(): array public function getFunctions(): array
{ {
return [ return [
new TwigFunction('promosCount', [$this, 'promosCount']), new TwigFunction('promosCount', [$this, 'promosCount']),
new TwigFunction('allCount', [$this, 'allCount']),
new TwigFunction('newCount', [$this, 'newCount']), new TwigFunction('newCount', [$this, 'newCount']),
new TwigFunction('discontinuedCount', [$this, 'discontinuedCount']), new TwigFunction('discontinuedCount', [$this, 'discontinuedCount']),
new TwigFunction('lowestPriceCount', [$this, 'lowestPriceCount']),
new TwigFunction('renderCategoryTree', [$this, 'renderCategoryTree']),
]; ];
} }
@@ -38,14 +31,9 @@ class AppExtension extends AbstractExtension
]; ];
} }
public function allCount(): int
{
return Product::count();
}
public function promosCount(): int public function promosCount(): int
{ {
return Product::whereRaw('priceCurrent < productStandardPrice')->count(); return Product::whereHas('currentPrice', fn(Builder $query) => $query->whereColumn('price', '<', 'productStandardPrice'))->with(['currentPrice'])->count();
} }
public function newCount(): int public function newCount(): int
@@ -55,43 +43,11 @@ class AppExtension extends AbstractExtension
public function discontinuedCount(): int public function discontinuedCount(): int
{ {
return Product::where('lastSeen', '<>', now()->format('Y-m-d'))->count(); return Product::where('updated_at', '<', now()->format('Y-m-d'))->count();
}
public function lowestPriceCount(): int
{
return Product::whereRaw('priceCurrent = priceLowest')
->whereRaw('lastSeen = "' . now()->format('Y-m-d') . '"')
->whereRaw('priceCurrent < productStandardPrice')
->count();
} }
public function findByCreatedAtDate(Collection $items, string $date): Stock|Price|null public function findByCreatedAtDate(Collection $items, string $date): Stock|Price|null
{ {
return $items->first(fn($item) => str_starts_with($item->created_at, $date)); return $items->first(fn($item) => str_starts_with($item->created_at, $date));
} }
public function renderCategoryTree($categories, $current, $level = 0): string
{
$tree = '';
if ($level == 0) {
$tree .= '<ul class="list-group list-group-flush">';
}
foreach ($categories as $categoryName => $category) {
$currentClass = $categoryName == $current ? 'list-group-item-primary' : '';
$tree .= '<a class="list-group-item list-group-item-action '.$currentClass.' text-decoration-none ms-' . ($level * 2) . '" href="' . $this->route->generate('app_category', ['category' => $categoryName]) . '">' . $categoryName . ' <span class="badge bg-primary rounded-pill">' . $category['count'] . '</span></a>';
unset($category['count']);
if (is_array($category) && count($category) >= 1) {
foreach ($category as $subcategoryName => $subCategory) {
$tree .= $this->renderCategoryTree([$subcategoryName => $subCategory], $current, $level + 1);
}
}
}
if ($level == 0) {
$tree .= '</ul>';
}
return $tree;
}
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,31 +5,23 @@
<table class='table table-hover'> <table class='table table-hover'>
<tr> <tr>
<td class="align-middle font-weight-bold h3"><a class="text-warning text-decoration-none" <td class="align-middle font-weight-bold h3"><a class="text-warning text-decoration-none"
href="{{ path('app_star', {'productId': product.id}) }}">{% if product.starred %}{% else %}{% endif %}</a> href="{{ path('app_star', {'productId': product.id}) }}">{% if product.starred %}{% else %}{% endif %}</a></td>
</td>
<td><img src='{{ product.image }}&width=150' class='border rounded p-1' alt='{{ product.name }}'/></td> <td><img src='{{ product.image }}&width=150' class='border rounded p-1' alt='{{ product.name }}'/></td>
<td> <td>
<a href='{{ path('app_product', {'productId': product.id}) }}' <a href='{{ path('app_product', {'productId': product.id}) }}' class="text-decoration-none">{{ product.name }}</a>
class="text-decoration-none">{{ product.name }}</a> <span class="badge text-bg-light">{{ product.subTitle }}</span>
<span class="badge text-bg-light"><a href="{{ path('app_search', {'search': product.subTitle}) }}"
class="link-underline link-underline-opacity-0 link-dark">{{ product.subTitle }}</a></span>
{% if product.promotions is not null and product.promotions.hasPromotion %}<a
href="{{ path('app_promos', {'promo': product.promotions.slug}) }}"><span class="badge bg-info">PROMO: {{ product.promotions.tag }}</span>
</a>{% endif %}
</td> </td>
<td> <td>
<nav aria-label="breadcrumb" style="--bs-breadcrumb-divider: '>';"> <nav aria-label="breadcrumb" style="--bs-breadcrumb-divider: '>';">
<ol class="breadcrumb"> <ol class="breadcrumb">
{% for category in product.categories %} {% for category in product.categories %}
<li class="breadcrumb-item" aria-current="page"><a <li class="breadcrumb-item" aria-current="page"><a class="breadcrumb-item text-decoration-none"
class="breadcrumb-item text-decoration-none" href="{{ path('app_category', {'category': category}) }}">{{ category }}</a></li>
href="{{ path('app_category', {'category': category}) }}">{{ category }}</a>
</li>
{% endfor %} {% endfor %}
</ol> </ol>
</nav> </nav>
</td> </td>
<td><a href='https://{{ product.country.locale }}.ryobitools.eu{{ product.url }}'>link</a></td> <td><a href='https://pl.ryobitools.eu/{{ product.url }}'>link</a></td>
</tr> </tr>
<tr> <tr>
<td colspan="5"> <td colspan="5">
@@ -49,14 +41,13 @@
<th>Stock</th> <th>Stock</th>
</tr> </tr>
</thead> </thead>
{% set stock = product.stock().get() %}
{% for price in product.price %} {% for price in product.price %}
<tr> <tr>
<td>{{ price.price | format_currency(product.country.currency, {}, product.country.locale) }}</td> <td>{{ price.price | format_currency('PLN', {}, 'pl') }}</td>
<td>{{ price.lowestProductPrice30Days | format_currency(product.country.currency, {}, product.country.locale) }}</td> <td>{{ price.lowestProductPrice30Days | format_currency('PLN', {}, 'pl') }}</td>
<td>{{ price.productStandardPrice | format_currency(product.country.currency, {}, product.country.locale) }}</td> <td>{{ price.productStandardPrice | format_currency('PLN', {}, 'pl') }}</td>
<td>{{ price.created_at }}</td> <td>{{ price.created_at }}</td>
<td>{{ (stock | findByCreatedAtDate(price.created_at | slice(0,10))).stock ?? '' }}</td> <td>{{ (product.stock | findByCreatedAtDate(price.created_at | slice(0,10))).stock ?? '' }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
@@ -75,7 +66,7 @@
labels: ['{{ price_dates|raw }}'], labels: ['{{ price_dates|raw }}'],
datasets: [ datasets: [
{ {
label: 'Price ({{ product.country.currency }})', label: 'Price (PLN)',
data: {{ price_list|raw }}, data: {{ price_list|raw }},
yAxisID: 'yPrice', yAxisID: 'yPrice',
tension: 0.1, tension: 0.1,
@@ -91,7 +82,6 @@
] ]
}, },
options: { options: {
spanGaps: false,
responsive: true, responsive: true,
animation: false, animation: false,
scales: { scales: {
@@ -107,7 +97,7 @@
beginAtZero: true, beginAtZero: true,
title: { title: {
display: true, display: true,
text: 'Price ({{ product.country.currency }})' text: 'Price (PLN)'
}, },
grid: { grid: {
drawOnChartArea: false drawOnChartArea: false

View File

@@ -1,94 +1,61 @@
{% extends "template.html.twig" %} {% extends "template.html.twig" %}
{% block content %} {% block content %}
{% if listType starts with 'category_' %} <div class="table-responsive">
{{ renderCategoryTree(categoryTree, category) | raw }} {{ app.request.get('order') }}
{% endif %} <table class='table table-hover'>
<thead>
{% if listType starts with 'promos' %} <tr>
{% for promo in promos %} <th></th>
<a href="{{ path('app_promos', {'promo': promo.slug}) }}"><span class="badge bg-info">PROMO: {{ promo.tag }}</span></a> <th></th>
{% endfor %} <th><a href="{{ path( app.request.get('_route') , {'order': app.request.get('order') is same as('-name')?'name':'-name'|default('-name')}) }}">Name</a></th>
{% endif %} <th>Categories</th>
<th></th>
<th><a href="{{ path( app.request.get('_route') , {'order': app.request.get('order') is same as('-price')?'price':'-price'|default('-price')}) }}">Price</a></th>
{% if (listType starts with 'category_' and category == null) or not (listType starts with 'category_') or (listType starts with 'category_' and category is not null) %} <th></th>
<div class="table-responsive"> </tr>
<table class='table table-hover'> </thead>
<thead> {% for product in products %}
<tr> <tr>
<th></th> <td class="align-middle font-weight-bold h3"><a class="text-warning text-decoration-none"
<th></th> href="{{ path('app_star', {'productId': product.id}) }}">{% if product.starred == true %}{% else %}{% endif %}</a></td>
<th>Name</th> <td class="align-middle" style="width: 120px;"><img src='{{ product.image }}&width=70' class='img-thumbnail' alt='{{ product.name }}'/></td>
<th>Categories</th> <td class="align-middle">
<th></th> <a href='{{ path('app_product', {'productId': product.id}) }}' class="text-decoration-none">{{ product.name }}</a>
<th class="text-end">Lowest Price</th> {% if product.currentStock.stock > 0 %}
<th class="text-end">Current Price</th> <span class="badge text-bg-light">stock: {{ product.currentStock.stock }}</span>
<th></th> {% else %}
</tr> <span class="badge text-bg-warning">out of stock</span>
</thead> {% endif %}
{% for product in products %} {% if product.isDiscontinued() %}
<tr> <span class="badge text-bg-secondary" data-bs-toggle="tooltip" data-bs-title="Last update: {{ product.updated_at }}">is discontinued</span>
<td class="align-middle font-weight-bold h3"><a class="text-warning text-decoration-none" {% endif %}
href="{{ path('app_star', {'productId': product.id}) }}">{% if product.starred == true %}{% else %}{% endif %}</a> {% if product.isNew() %}
</td> <span class="badge text-bg-success">is new</span>
<td class="align-middle" style="width: 120px;"><img src='{{ product.image }}&width=70' {% endif %}
class='img-thumbnail' <span class="badge text-bg-light">{{ product.subTitle }}</span>
alt='{{ product.name }}'/></td> </td>
<td class="align-middle"> <td class="align-middle">
<a href='{{ path('app_product', {'productId': product.id}) }}' <nav aria-label="breadcrumb" style="--bs-breadcrumb-divider: '>';" >
class="text-decoration-none">{{ product.name }}</a> <ol class="breadcrumb">
{% if product.stock > 0 %} {% for category in product.categories %}
<span class="badge text-bg-light">stock: {{ product.stock }}</span> <li class="breadcrumb-item" aria-current="page"><a class="breadcrumb-item text-decoration-none" href="{{ path('app_category', {'category': category}) }}">{{ category }}</a></li>
{% else %} {% endfor %}
<span class="badge text-bg-warning">out of stock</span> </ol>
{% endif %} </nav>
{% if product.isDiscontinued() %} </td>
<span class="badge text-bg-secondary" data-bs-toggle="tooltip" <td class="align-middle"><a href='https://pl.ryobitools.eu/{{ product.url }}'>link</a></td>
data-bs-title="Last update: {{ product.lastSeen }}">is discontinued</span> <td class="align-middle">{{ product.price.last.price | format_currency('PLN', {}, 'pl') }}</td>
{% endif %} <td class="align-middle">
{% if product.isNew() %} <div class="d-flex flex-row">
<span class="badge text-bg-success">is new</span> {% if product.price.last.price != product.price.last.productStandardPrice %}<span
{% endif %} class="badge text-bg-warning text-decoration-line-through flex-fill">{{ product.price.last.productStandardPrice | format_currency('PLN', {}, 'pl') }}</span>&nbsp;<span
<span class="badge text-bg-light"><a class="badge text-bg-success flex-fill">{{ ((1 - product.price.last.price / product.price.last.productStandardPrice)*100)|number_format(0) }}%</span>
href="{{ path('app_search', {'search': product.subTitle}) }}" {% endif %}
class="link-underline link-underline-opacity-0 link-dark">{{ product.subTitle }}</a></span> </div>
{% if product.promotions is not null and product.promotions.hasPromotion %}<a href="{{ path('app_promos', {'promo': product.promotions.slug}) }}"><span class="badge bg-info">PROMO: {{ product.promotions.tag }}</span></a>{% endif %} </td>
</td> </tr>
<td class="align-middle"> {% endfor %}
<nav aria-label="breadcrumb" style="--bs-breadcrumb-divider: '>';"> </table>
<ol class="breadcrumb mb-0"> </div>
{% for category in product.categories %}
<li class="breadcrumb-item" aria-current="page"><a
class="breadcrumb-item text-decoration-none"
href="{{ path('app_category', {'category': category}) }}">{{ category }}</a>
</li>
{% endfor %}
</ol>
</nav>
</td>
<td class="align-middle"><a href='https://{{ product.country.locale }}.ryobitools.eu{{ product.url }}'>link</a></td>
<td class="align-middle text-end">
{% if product.isDiscontinued() or product.priceCurrent == product.productStandardPrice %}
{{ product.priceLowest | format_currency(product.country.currency, {}, product.country.locale) }}
{% else %}
{% if product.priceLowest != product.priceCurrent %}{{ product.priceLowest | format_currency(product.country.currency, {}, product.country.locale) }}{% else %}
<span class="badge text-bg-info">now lowest</span>{% endif %}</td>
{% endif %}
<td class="align-middle text-end">{{ product.priceCurrent | format_currency(product.country.currency, {}, product.country.locale) }}</td>
<td class="align-middle">
<div class="d-flex flex-row">
{% if product.priceCurrent != product.productStandardPrice %}<span
class="badge text-bg-warning text-decoration-line-through flex-fill">{{ product.productStandardPrice | format_currency(product.country.currency, {}, product.country.locale) }}</span>
<span
class="badge text-bg-success flex-fill">{{ ((1 - product.priceCurrent / product.productStandardPrice)*100)|number_format(0) }}%</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en" data-bs-theme="light"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
@@ -18,21 +18,15 @@
</button> </button>
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if app.request.pathinfo == path('app_home') %}active shadow-sm bg-body rounded{% endif %}" aria-current="page" href="{{ path('app_home') }}">All products <span class="badge text-bg-secondary">{{ allCount() }}</span></a> <a class="nav-link {% if app.request.pathinfo == path('app_promos') %}active shadow-sm bg-body rounded{% endif %}" aria-current="page" href="{{ path('app_promos') }}">Promos <span class="badge text-bg-secondary">{{ promosCount() }}</span></a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if app.request.pathinfo == path('app_promos') %}active shadow-sm bg-body rounded{% endif %}" aria-current="page" href="{{ path('app_promos') }}">Promos <span class="badge text-bg-secondary">{{ promosCount() }}</span></a> <a class="nav-link {% if app.request.pathinfo == path('app_new') %}active shadow-sm bg-body rounded{% endif %}" aria-current="page" href="{{ path('app_new') }}">New in last 30 days <span class="badge text-bg-secondary">{{ newCount() }}</span></a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if app.request.pathinfo == path('app_lowest_price') %}active shadow-sm bg-body rounded{% endif %}" aria-current="page" href="{{ path('app_lowest_price') }}">Lowest price <span class="badge text-bg-secondary">{{ lowestPriceCount() }}</span></a> <a class="nav-link {% if app.request.pathinfo == path('app_discontinued') %}active shadow-sm bg-body rounded{% endif %}" aria-current="page" href="{{ path('app_discontinued') }}">Discontinued <span class="badge text-bg-secondary">{{ discontinuedCount() }}</span></a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% if app.request.pathinfo == path('app_new') %}active shadow-sm bg-body rounded{% endif %}" aria-current="page" href="{{ path('app_new') }}">New in last 30 days <span class="badge text-bg-secondary">{{ newCount() }}</span></a>
</li>
<li class="nav-item">
<a class="nav-link {% if app.request.pathinfo == path('app_discontinued') %}active shadow-sm bg-body rounded{% endif %}" aria-current="page" href="{{ path('app_discontinued') }}">Discontinued <span class="badge text-bg-secondary">{{ discontinuedCount() }}</span></a>
</li>
</ul> </ul>
<form class="form-floating d-flex col-lg-6 col-sm-8" role="search" action="{{ path('app_search') }}"> <form class="form-floating d-flex col-lg-6 col-sm-8" role="search" action="{{ path('app_search') }}">
@@ -45,7 +39,8 @@
</nav> </nav>
{% block content %}{% endblock %} {% block content %}{% endblock %}
<script src="/templates/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body> </body>
</html> </html>