55 Commits

Author SHA1 Message Date
Krzysztof Płaczek
c093003fe7 Start working on tailwind instead of bootstrap 2025-05-20 16:31:46 +02:00
dc287aadc6 Merge pull request 'feature/lowest-price' (#27) from feature/lowest-price into master
Reviewed-on: #27
2025-05-14 12:46:12 +02:00
708a5eeae0 Merge pull request 'Implement a caching mechanism' (#28) from feature/cache into master
Reviewed-on: #28
2025-05-14 12:45:57 +02:00
Krzysztof Płaczek
7352eea270 Add the lowest price column to every product list view 2025-05-14 10:28:58 +02:00
Krzysztof Płaczek
e97d976705 Implement a caching mechanism 2025-05-14 09:03:25 +02:00
beb717f9b9 Add lowest price first draft. 2025-05-13 23:34:51 +02:00
2471a61076 Merge pull request 'Implement a caching mechanism' (#26) from feature/cache into master
Reviewed-on: #26
2025-05-13 18:35:29 +02:00
Krzysztof Płaczek
a01174b414 Implement a caching mechanism 2025-05-13 18:29:51 +02:00
512de51d08 Merge pull request 'All products menu link' (#25) from feature/count-all-products into master
Reviewed-on: #25
2025-02-19 15:35:08 +01:00
Krzysztof Płaczek
e6e8f2fc15 All products menu link 2025-02-19 15:34:51 +01:00
6246ad8692 Merge pull request 'Table is responsive on mobile' (#24) from feature/table-responsive-on-mobile into master
Reviewed-on: #24
2025-02-19 15:29:52 +01:00
Krzysztof Płaczek
d18abc6159 Table is responsive on mobile 2025-02-19 15:29:31 +01:00
5832ac583f Merge pull request 'Add a slash to static files.' (#23) from fix/missing-script-files-when-in-category into master
Reviewed-on: #23
2025-02-16 10:49:09 +01:00
Krzysztof Płaczek
8f87a6bbf0 Add a slash to static files. 2025-02-16 10:48:55 +01:00
ef1b7b8a9e Merge pull request 'Update search field.' (#21) from feature/update-search-field into master
Reviewed-on: #21
2025-02-16 10:46:02 +01:00
b0134accfa Merge pull request 'feature/discontinued' (#22) from feature/discontinued into master
Reviewed-on: #22
2025-02-16 10:45:57 +01:00
Krzysztof Płaczek
ae0f9b3be7 Add an active state to the main menu 2025-02-16 10:45:15 +01:00
Krzysztof Płaczek
5c2777999a Add a tooltip with last update to discontinued items. 2025-02-16 10:22:09 +01:00
Krzysztof Płaczek
71279025ae Update search field. 2025-02-15 12:21:26 +01:00
f334ba0232 Merge pull request 'Keep track of discontinued items. That means items that were tracked, but then they were never updated.Added is discontinued badge.' (#20) from feature/discontinued into master
Reviewed-on: #20
2025-02-15 11:33:45 +01:00
Krzysztof Płaczek
81f9c863c7 Keep track of discontinued items. That means items that were tracked, but then they were never updated.Added is discontinued badge. 2025-02-15 11:33:24 +01:00
7eb7bf3eb2 Merge pull request 'Increase php memory limit' (#18) from feature/increase-memory-limit into master
Reviewed-on: #18
2025-02-04 21:00:43 +01:00
Krzysztof Płaczek
ac1f0af5ae Increase php memory limit 2025-02-04 21:00:12 +01:00
35aea9dd7e Merge pull request 'Increase php memory limit' (#17) from feature/increase-memory-limit into master
Reviewed-on: #17
2025-02-04 20:43:56 +01:00
Krzysztof Płaczek
62239bd052 Increase php memory limit 2025-02-04 20:43:33 +01:00
22e59a2438 Merge pull request 'Display price badged inline.' (#16) from feature/tweak-displaing-badges-with-prices into master
Reviewed-on: #16
2025-01-28 08:23:21 +01:00
Krzysztof Płaczek
585f1336d2 Display price badged inline. 2025-01-28 08:23:03 +01:00
a26ad401df Merge pull request 'Add chart line to display stock as well as price.' (#15) from feature/display-stock-in-chart into master
Reviewed-on: #15
2025-01-26 11:42:38 +01:00
Krzysztof Płaczek
3705990be1 Add chart line to display stock as well as price. 2025-01-26 11:41:42 +01:00
c65f0fdb3e Merge pull request 'Add new items listing and counting' (#14) from feature/list-new-items into master
Reviewed-on: #14
2025-01-05 18:53:14 +01:00
d6bcffc3e1 Add new badge. Update screenshot. 2025-01-05 18:50:34 +01:00
faf9c8a480 Update src/Twig/AppExtension.php 2025-01-05 16:27:15 +01:00
14104f61a4 Add new items listing and counting 2025-01-05 16:19:49 +01:00
44254ed12a Merge pull request 'feature/breadcrumbs-as-categories' (#13) from feature/breadcrumbs-as-categories into master
Reviewed-on: #13
2024-11-28 11:55:25 +01:00
Krzysztof Płaczek
d9740a86d0 Use breadcrumbs to display categories 2024-11-28 11:51:14 +01:00
Krzysztof Płaczek
bd62a23854 Use breadcrumbs to display categories 2024-11-28 11:50:22 +01:00
2dc4dc778d Merge pull request 'Add bin/ commands' (#12) from feature/add-bin-commands into master
Reviewed-on: #12
2024-11-26 09:57:32 +01:00
Krzysztof Płaczek
85e32554ef Add bin/ commands 2024-11-26 09:57:16 +01:00
ebd48cfbf6 Merge pull request 'Remove duplicate products in category view due to the bug with the list of category.' (#11) from fix/duplicate-products-in-category-view into master
Reviewed-on: #11
2024-11-26 08:29:14 +01:00
Krzysztof Płaczek
369bacc38c Remove duplicate products in category view due to the bug with the list of category. 2024-11-26 08:28:23 +01:00
277257fda2 Merge pull request 'Add badge to single product view.' (#10) from feature/use-bootstrap-badges into master
Reviewed-on: #10
2024-11-23 12:01:37 +01:00
Krzysztof Płaczek
0e5d76313f Add badge to single product view. 2024-11-23 11:59:32 +01:00
54488f0b19 Merge pull request 'feature/use-bootstrap-badges' (#8) from feature/use-bootstrap-badges into master
Reviewed-on: #8
2024-11-23 11:34:46 +01:00
Krzysztof Płaczek
a6b108cb74 Add a badge for stock 2024-11-23 11:34:00 +01:00
Krzysztof Płaczek
0db4bacff4 Add some bootstrap badged in place of promos count, discount percent and previous price. 2024-11-21 10:05:55 +01:00
37680b0c91 Merge pull request 'Order by starred flag and create date in promos view' (#7) from fix/ordering-promo-items into master
Reviewed-on: #7
2024-11-21 09:46:03 +01:00
Krzysztof Płaczek
0e8c312f5e Order by starred flag and create date in promos view 2024-11-21 09:45:31 +01:00
685bff3ffc Merge pull request 'Add intl extension to dockerfile' (#6) from fix/missing-intl-extension into master
Reviewed-on: #6
2024-11-17 17:56:57 +01:00
Krzysztof Płaczek
11284e59ea Add intl extension to dockerfile 2024-11-17 17:56:29 +01:00
50305d5b5d Merge pull request 'Add twig extra bundle to use currency formatter filter. Add custom twig method to display number of active promos. Remove old index.php file.' (#5) from feature/display-stock into master
Reviewed-on: #5
2024-11-17 16:38:32 +01:00
Krzysztof Płaczek
f963dcd4c8 Display stock in product info in price table. 2024-11-17 16:38:22 +01:00
Krzysztof Płaczek
48ee68f71a Add twig extra bundle to use currency formatter filter. Add custom twig method to display number of active promos. Remove old index.php file. 2024-11-17 12:33:40 +01:00
f1c0ec6b3a Merge pull request 'Add dotenv package and .env file' (#3) from feature/dotenvfile into master
Reviewed-on: #3
2024-11-15 09:32:07 +01:00
Krzysztof Płaczek
c280abe484 Fix number of columns on product list. 2024-10-20 15:51:05 +02:00
Krzysztof Płaczek
7053716924 Add dotenv package and .env file 2024-10-20 12:57:16 +02:00
38 changed files with 2685 additions and 584 deletions

2
.env Normal file
View File

@@ -0,0 +1,2 @@
APP_ENV=prod
APP_DEBUG=false

4
.gitignore vendored
View File

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

View File

@@ -1,13 +1,17 @@
FROM php:8.3-cli
WORKDIR /usr/src/app
ENV PHP_MEMORY_LIMIT=1500M
RUN echo "memory_limit=1500M" > /usr/local/etc/php/conf.d/memory-limit.ini
RUN apt-get update && apt-get install -y \
git \
unzip \
libzip-dev
libicu-dev \
libzip-dev \
nodejs \
npm
RUN docker-php-ext-install zip
RUN docker-php-ext-install zip intl
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

View File

@@ -4,7 +4,7 @@
1. Clone repository using `git clone https://git.techtube.pl/krzysiej/ryobi-crawler.git`
2. Cd into project directory `cd ryobi-crawler`
3. Build and start docker container `docker compose up -d`
3. Build and start docker container `docker compose up -d --build --force-recreate`
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.
6. Access web interface using `localhost:9001` address in web browser.
@@ -14,12 +14,20 @@
1. Cd into project directory
2. Run `git pull`
3. Start and build image in one go with command: `docker compose up -d --build --force-recreate`
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`
## Bonus
### Install composer package
1. Run `bin/composer require vendor/package-name`
## Running Cron
For now only way of running `app:scrape` command on schedule is to use host crontab.
1. Run `crontab -e` command to edit host crontab job file
1. Run `crontab -e` command to edit a 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`
3. Save and exit file editor. Cron will execute `app:scrape` once per day.

2
bin/bash Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env bash
bin/cli bash

2
bin/cacheclean Executable file
View File

@@ -0,0 +1,2 @@
#!/usr/bin/env bash
bin/cli rm -rf var/cache

3
bin/cli Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
[ -z "$1" ] && echo "Please specify a CLI command (ex. ls)" && exit
docker compose exec php-app "$@"

3
bin/composer Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
[ -z "$1" ] && echo "Please specify a Composer command (ex. install)" && exit
bin/cli composer "$@"

5
bin/update Executable file
View File

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

View File

@@ -1,7 +1,6 @@
{
"require": {
"guzzlehttp/guzzle": "^7.0",
"symfony/var-dumper": "^7.0",
"illuminate/database": "11.26.0.0",
"ext-json": "*",
"symfony/console": "^7.0",
@@ -9,11 +8,19 @@
"laravel/serializable-closure": "^1.3",
"symfony/http-kernel": "^7.1",
"symfony/framework-bundle": "^7.1",
"symfony/twig-bundle": "^7.1"
"symfony/twig-bundle": "^7.1",
"symfony/dotenv": "^7.1",
"twig/intl-extra": "^3.13",
"twig/extra-bundle": "^3.13",
"symfony/cache": "^7.2",
"twig/cache-extra": "^3.21"
},
"autoload": {
"psr-4": {
"Krzysiej\\RyobiCrawler\\": "src/"
}
},
"require-dev": {
"symfony/var-dumper": "^7.1"
}
}

1059
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,48 +0,0 @@
<?php
include_once 'vendor/autoload.php';
use Krzysiej\RyobiCrawler\Controller\CategoryController;
use Krzysiej\RyobiCrawler\Controller\IndexController;
use Krzysiej\RyobiCrawler\Controller\ProductController;
use Krzysiej\RyobiCrawler\Controller\PromosController;
use Krzysiej\RyobiCrawler\Controller\SearchController;
use Krzysiej\RyobiCrawler\Controller\StarController;
use Symfony\Component\Routing\Matcher\UrlMatcher;
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
if (!file_exists('database.sqlite')) {
exit('Database file <code>database.sqlite</code> missing. Run docker compose <blockquote>docker compose exec php-app php index.php app:migrate</blockquote> to create it.');
}
$productRoute = new Route('/product/{product_id<\d+>}', ['_controller' => ProductController::class]);
$searchRoute = new Route('/?search={search_term}', ['_controller' => SearchController::class]);
$categoryRoute = new Route('/category/{category_name}', ['_controller' => CategoryController::class]);
$starRoute = new Route('/star/{product_id}', ['_controller' => StarController::class]);
$promosRoute = new Route('/promos', ['_controller' => PromosController::class]);
$indexRoute = new Route('/', ['_controller' => IndexController::class]);
$routes = new RouteCollection();
$routes->add('product_show', $productRoute);
$routes->add('search_show', $searchRoute);
$routes->add('category_show', $categoryRoute);
$routes->add('start_show', $starRoute);
$routes->add('promos_show', $promosRoute);
$routes->add('index_show', $indexRoute);
$context = new RequestContext();
$matcher = new UrlMatcher($routes, $context);
try {
$parameters = $matcher->match($_SERVER['REQUEST_URI']);
} catch (Exception $e) {
die($e->getMessage());
}
match ($parameters['_controller']) {
SearchController::class => (new $parameters['_controller']())($parameters['search_term']),
CategoryController::class => (new $parameters['_controller']())($parameters['category_name']),
ProductController::class, StarController::class => (new $parameters['_controller']())($parameters['product_id']),
PromosController::class, IndexController::class => (new $parameters['_controller']())(),
default => throw new Exception('Route not found')
};

1079
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "ryobi-crawler",
"version": "1.0.0",
"description": "## Project start",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "npx @tailwindcss/cli -i ./templates/input/style.css -o ./templates/output/style.css --watch"
},
"repository": {
"type": "git",
"url": "https://git.techtube.pl/krzysiej/ryobi-crawler.git"
},
"author": "",
"license": "ISC",
"dependencies": {
"@tailwindcss/cli": "^4.1.7",
"tailwindcss": "^4.1.7"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 199 KiB

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Krzysiej\RyobiCrawler\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\HttpFoundation\Request;
use Illuminate\Database\Capsule\Manager as Capsule;
use Krzysiej\RyobiCrawler\Models\Product;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Environment;
#[AsCommand(name: 'app:cache:warm-twig', description: '')]
class CacheWarmCommand extends Command
{
public function __construct(
private Environment $twig,
private RequestStack $requestStack,
private FilesystemAdapter $cache,
) {
parent::__construct();
}
public function execute(InputInterface $input, OutputInterface $output): int
{
$this->cache->clear();
$capsule = new Capsule;
$capsule->addConnection([
'driver' => 'sqlite',
'database' => __DIR__ . '/../../database.sqlite',
]);
$capsule->setAsGlobal();
$capsule->bootEloquent();
$progress = new ProgressBar($output);
$progress->start();
$products = Product::with([
'price' => fn($query) => $query->orderByDesc('created_at'),
'stock' => fn($query) => $query->orderByDesc('created_at'),
])->get();
$progress->setMaxSteps(count($products));
$request = Request::create('/cache-warm');
$this->requestStack->push($request);
foreach ($products as $product) {
$priceList = $product->price()->pluck('price');
$stockList = $product->stock()->pluck('stock');
$priceDates = $product->price()->pluck('created_at')->map(fn($date) => $date->format('Y-m-d'))->toArray();
$stockDates = $product->stock()->pluck('created_at')->map(fn($date) => $date->format('Y-m-d'))->toArray();
$this->twig->render('product.html.twig', [
'product' => $product,
'price_list' => $this->prepareChartData($priceDates, $priceList),
'stock_list' => $this->prepareChartData($stockDates, $stockList),
'price_dates' => implode("','", $priceDates),
]);
$progress->advance();
}
$progress->finish();
$output->writeln('');
$output->writeln('DONE');
return Command::SUCCESS;
}
private function prepareChartData($set1, $set2): string
{
$data = [];
foreach ($set1 as $key => $value) {
$data[] = ['x' => $value, 'y' => $set2[$key]];
}
$stringData = json_encode($data);
return str_replace(['"x"', '"y"'], ['x', 'y'], $stringData);
}
}

View File

@@ -51,7 +51,6 @@ class Migrate extends Command
$table->string('name');
$table->integer('skuID')->unique();
$table->integer('availableQuantity');
$table->integer('stock');
$table->json('categories');
$table->string('image');
$table->string('subTitle');

View File

@@ -40,7 +40,6 @@ class ScrapeWebsite extends Command
$products = $this->getProducts();
$progress->setMaxSteps(count($products));
foreach ($products as $product) {
//dd($product);
$this->saveProduct($product);
$progress->advance();
}
@@ -69,7 +68,7 @@ class ScrapeWebsite extends Command
$products = array_merge($products, $responseObject->products);
$page++;
$canLoadMore = $responseObject->canLoadMore;
} catch (GuzzleException $e) {
} catch (GuzzleException) {
return $products;
}
} while ($canLoadMore);
@@ -81,18 +80,17 @@ class ScrapeWebsite extends Command
{
/** @var Product $productModel */
$productModel = Product::firstOrNew(['skuID' => $product->skuID]);
//dd($productModel);
$productModel->skuID = $product->skuID;
$productModel->name = $product->name;
$productModel->availableQuantity = $product->availableQuantity;
$productModel->stock = $product->stock;
$productModel->categories = $product->categories;
$productModel->image = $product->image;
$productModel->subTitle = $product->subTitle;
$productModel->variantCode = $product->variantCode;
$productModel->modelCode = $product->modelCode;
$productModel->url = $product->url;
$productModel->touch('updated_at');
$productModel->save();
$priceExists = $productModel->price()->whereRaw("strftime('%Y-%m-%d', created_at) = ?", [date('Y-m-d')])->exists();

View File

@@ -3,6 +3,7 @@
namespace Krzysiej\RyobiCrawler\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Twig\Environment;
use Illuminate\Database\Capsule\Manager as Capsule;
@@ -10,7 +11,7 @@ class BaseController extends AbstractController
{
protected Environment $twig;
public function __construct()
public function __construct(protected FilesystemAdapter $cache)
{
$capsule = new Capsule;
$capsule->addConnection(['driver' => 'sqlite', 'database' => __DIR__ . '/../../database.sqlite']);

View File

@@ -11,13 +11,19 @@ final class CategoryController extends BaseController
#[Route('/category/{category}', name: 'app_category')]
public function __invoke(string $category): Response
{
$products = Product::with('price')
if($this->cache->getItem('list_category_'.$category)->isHit()) {
return $this->render('productList.html.twig', ['listType' => 'category_'.$category]);
}
$products = Product::with(['price', 'lowestPrice'])
->selectRaw('products.*')
->distinct('products.id')
->fromRaw('products, json_each(products.categories)')
->whereRaw('json_each.value = ?', [$category])
->orderByDesc('starred')
->orderByDesc('created_by')
->get();
return $this->render('productList.html.twig', ['products' => $products]);
return $this->render('productList.html.twig', ['products' => $products, 'listType' => 'category_'.$category]);
}
}

View File

@@ -0,0 +1,27 @@
<?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 DiscontinuedController extends BaseController
{
#[Route('/discontinued', name: 'app_discontinued')]
public function __invoke(): Response
{
if($this->cache->getItem('list_discontinued')->isHit()) {
return $this->render('productList.html.twig', ['listType' => 'discontinued']);
}
$products = Product::where('updated_at', '<', now()->format('Y-m-d'))
->orderByDesc('starred')
->orderByDesc('created_by')
->with(['currentPrice', 'lowestPrice'])
->get();
return $this->render('productList.html.twig', ['products' => $products, 'listType' => 'discontinued']);
}
}

View File

@@ -11,7 +11,14 @@ final class IndexController extends BaseController
#[Route('/', name: 'app_home')]
public function __invoke(): Response
{
$products = Product::with('price')->orderByDesc('starred')->orderByDesc('created_by')->get();
return $this->render('productList.html.twig', ['products' => $products]);
if ($this->cache->getItem('list_all')->isHit()) {
return $this->render('productList.html.twig', ['listType' => 'all']);
}
$products = Product::with(['currentStock', 'price', 'lowestPrice'])
->orderByDesc('starred')
->orderByDesc('created_by')
->get();
return $this->render('productList.html.twig', ['products' => $products, 'listType' => 'all']);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Krzysiej\RyobiCrawler\Controller;
use Illuminate\Database\Eloquent\Builder;
use Krzysiej\RyobiCrawler\Models\Product;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use function Symfony\Component\Clock\now;
final class NewController extends BaseController
{
#[Route('/new', name: 'app_new')]
public function __invoke(): Response
{
if($this->cache->getItem('list_new')->isHit()) {
return $this->render('productList.html.twig', ['listType' => 'new']);
}
$products = Product::where('created_at', '>', now()->modify('-30 days')->format('Y-m-d'))
->orderByDesc('starred')
->orderByDesc('created_by')
->with(['currentPrice', 'lowestPrice'])
->get();
return $this->render('productList.html.twig', ['products' => $products, 'listType' => 'new']);
}
}

View File

@@ -2,8 +2,8 @@
namespace Krzysiej\RyobiCrawler\Controller;
use Illuminate\Support\ItemNotFoundException;
use Krzysiej\RyobiCrawler\Models\Product;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Error\LoaderError;
@@ -20,16 +20,37 @@ final class ProductController extends BaseController
#[Route('/product/{productId<\d+>}', name: 'app_product')]
public function __invoke(int $productId): Response
{
$product = Product::with([
'price' => fn($query) => $query->orderBy('created_at', 'desc')
])->find($productId);
if(null === $product) {
throw $this->createNotFoundException('Product not found');
if($this->cache->getItem('product'.$productId)->isHit()) {
return $this->render('product.html.twig', ['product' => ['id' => $productId]]);
}
$priceList = $product->price()->pluck('price')->implode(',');
$priceDates = $product->price()->pluck('created_at')->map(fn($date) => $date->format('Y-m-d'))->implode("','");
return $this->render('product.html.twig', ['product' => $product, 'price_list' => $priceList, 'price_dates' => $priceDates]);
$product = Product::with([
'price' => fn($query) => $query->orderBy('created_at', 'desc'),
'stock' => fn($query) => $query->orderBy('created_at', 'desc'),
])->find($productId);
if (null === $product) {
throw $this->createNotFoundException('Product not found');
}
$priceList = $product->price()->pluck('price');
$stockList = $product->stock()->pluck('stock');
$priceDates = $product->price()->pluck('created_at')->map(fn($date) => $date->format('Y-m-d'))->toArray();
$stockDates = $product->stock()->pluck('created_at')->map(fn($date) => $date->format('Y-m-d'))->toArray();
return $this->render('product.html.twig', [
'product' => $product,
'price_list' => $this->prepareChartData($priceDates, $priceList),
'stock_list' => $this->prepareChartData($stockDates, $stockList),
'price_dates' => implode("','", $priceDates),
]);
}
private function prepareChartData($set1, $set2): string
{
$data = [];
foreach ($set1 as $key => $value) {
$data[] = ['x' => $value, 'y' => $set2[$key]];
}
$stringData = json_encode($data);
return str_replace(['"x"', '"y"'], ['x', 'y'], $stringData);
}
}

View File

@@ -12,7 +12,15 @@ final class PromosController extends BaseController
#[Route('/promos', name: 'app_promos')]
public function __invoke(): Response
{
$products = Product::whereHas('currentPrice', fn(Builder $query) => $query->whereColumn('price', '<', 'productStandardPrice'))->with(['currentPrice'])->get();
return $this->render('productList.html.twig', ['products' => $products]);
if($this->cache->getItem('list_promos')->isHit()) {
return $this->render('productList.html.twig', ['listType' => 'promos']);
}
$products = Product::whereHas('currentPrice', fn(Builder $query) => $query->whereColumn('price', '<', 'productStandardPrice'))
->orderByDesc('starred')
->orderByDesc('created_by')
->with(['currentPrice', 'lowestPrice'])
->get();
return $this->render('productList.html.twig', ['products' => $products, 'listType' => 'promos']);
}
}

View File

@@ -13,7 +13,6 @@ final class SearchController extends BaseController
public function __invoke(Request $request): Response
{
$search = $request->query->get('search');
//dd();
$products = Product::with('price')
->orWhere([['name', 'like', "%$search%"]])
->orWhere([['subTitle', 'like', "%$search%"]])->get();

51
src/Kernel.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
namespace Krzysiej\RyobiCrawler;
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->load('Krzysiej\\RyobiCrawler\\', __DIR__ )
->exclude('../src/{Models,Twig,Kernel.php}');
$services->set('twig.extension.cache', AppExtension::class)->tag('twig.extension');
$services->set(CacheExtension::class)->tag('twig.extension');
$services->set(FilesystemAdapter::class)->args([
'$directory' => __DIR__ . '/../var/cache/twig_blocks'
]);
$services->set('twig.runtime.cache', CacheRuntime::class)->args([new Reference(FilesystemAdapter::class)])->tag('twig.runtime');
}
protected function configureRoutes(RoutingConfigurator $routes): void
{
$routes->import(__DIR__ . '/Controller/', 'attribute');
}
}

View File

@@ -7,11 +7,12 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use function Symfony\Component\Clock\now;
/**
* @property integer $skuID
* @property string $name
* @property integer $availableQuantity
* @property integer $stock
* @property string[] $categories
* @property string $image
* @property string $subTitle
@@ -32,7 +33,7 @@ class Product extends Model
public function isStarred(): bool
{
return (bool) $this->starred;
return (bool)$this->starred;
}
public function currentPrice(): HasOne
@@ -40,11 +41,23 @@ class Product extends Model
return $this->hasOne(Price::class)->latestOfMany('created_at');
}
public function lowestPrice(): HasOne
{
return $this->hasOne(Price::class)->ofMany('price', 'MIN');
}
public function stock(): HasMany
{
return $this->hasMany(Stock::class);
}
public function currentStock(): HasOne
{
return $this->stock()->one()->ofMany()->withDefault(function (Stock $stock) {
$stock->stock = 0;
});
}
public function toggleStarred(): self
{
$this->starred = !$this->starred;
@@ -59,4 +72,13 @@ class Product extends Model
set: fn(array $value) => json_encode($value),
);
}
public function isDiscontinued(): bool
{
return $this->updated_at->format('Y-m-d') < now()->format('Y-m-d');
}
public function isNew(): bool
{
return $this->created_at->format('Y-m-d') > now()->modify('-30 days')->format('Y-m-d');
}
}

58
src/Twig/AppExtension.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
namespace Krzysiej\RyobiCrawler\Twig;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Krzysiej\RyobiCrawler\Models\Price;
use Krzysiej\RyobiCrawler\Models\Product;
use Krzysiej\RyobiCrawler\Models\Stock;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
use function Symfony\Component\Clock\now;
class AppExtension extends AbstractExtension
{
public function getFunctions(): array
{
return [
new TwigFunction('promosCount', [$this, 'promosCount']),
new TwigFunction('allCount', [$this, 'allCount']),
new TwigFunction('newCount', [$this, 'newCount']),
new TwigFunction('discontinuedCount', [$this, 'discontinuedCount']),
];
}
public function getFilters(): array
{
return [
new TwigFilter('findByCreatedAtDate', [$this, 'findByCreatedAtDate']),
];
}
public function allCount(): int
{
return Product::count();
}
public function promosCount(): int
{
return Product::whereHas('currentPrice', fn(Builder $query) => $query->whereColumn('price', '<', 'productStandardPrice'))->count();
}
public function newCount(): int
{
return Product::where('created_at', '>', now()->modify('-30 days')->format('Y-m-d'))->count();
}
public function discontinuedCount(): int
{
return Product::where('updated_at', '<', now()->format('Y-m-d'))->count();
}
public function findByCreatedAtDate(Collection $items, string $date): Stock|Price|null
{
return $items->first(fn($item) => str_starts_with($item->created_at, $date));
}
}

6
templates/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
@import "tailwindcss";

4
templates/js/script.js Normal file
View File

@@ -0,0 +1,4 @@
(function() {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]')
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl))
})();

340
templates/output/style.css Normal file
View File

@@ -0,0 +1,340 @@
/*! tailwindcss v4.1.7 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
--spacing: 0.25rem;
--text-3xl: 1.875rem;
--text-3xl--line-height: calc(2.25 / 1.875);
--font-weight-bold: 700;
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
}
}
@layer base {
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
html, :host {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
}
hr {
height: 0;
color: inherit;
border-top-width: 1px;
}
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
b, strong {
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
}
small {
font-size: 80%;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring {
outline: auto;
}
progress {
vertical-align: baseline;
}
summary {
display: list-item;
}
ol, ul, menu {
list-style: none;
}
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
vertical-align: middle;
}
img, video {
max-width: 100%;
height: auto;
}
button, input, select, optgroup, textarea, ::file-selector-button {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
letter-spacing: inherit;
color: inherit;
border-radius: 0;
background-color: transparent;
opacity: 1;
}
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
::file-selector-button {
margin-inline-end: 4px;
}
::placeholder {
opacity: 1;
}
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
::placeholder {
color: currentcolor;
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
}
textarea {
resize: vertical;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-date-and-time-value {
min-height: 1lh;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
:-moz-ui-invalid {
box-shadow: none;
}
button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
[hidden]:where(:not([hidden="until-found"])) {
display: none !important;
}
}
@layer utilities {
.collapse {
visibility: collapse;
}
.container {
width: 100%;
@media (width >= 40rem) {
max-width: 40rem;
}
@media (width >= 48rem) {
max-width: 48rem;
}
@media (width >= 64rem) {
max-width: 64rem;
}
@media (width >= 80rem) {
max-width: 80rem;
}
@media (width >= 96rem) {
max-width: 96rem;
}
}
.me-2 {
margin-inline-end: calc(var(--spacing) * 2);
}
.me-auto {
margin-inline-end: auto;
}
.mb-0 {
margin-bottom: calc(var(--spacing) * 0);
}
.mb-2 {
margin-bottom: calc(var(--spacing) * 2);
}
.block {
display: block;
}
.grid {
display: grid;
}
.table {
display: table;
}
.flex-row {
flex-direction: row;
}
.rounded {
border-radius: 0.25rem;
}
.border {
border-style: var(--tw-border-style);
border-width: 1px;
}
.border-1 {
border-style: var(--tw-border-style);
border-width: 1px;
}
.p-1 {
padding: calc(var(--spacing) * 1);
}
.text-end {
text-align: end;
}
.align-middle {
vertical-align: middle;
}
.text-3xl {
font-size: var(--text-3xl);
line-height: var(--tw-leading, var(--text-3xl--line-height));
}
.font-bold {
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
}
.underline {
text-decoration-line: underline;
}
.shadow-sm {
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
@property --tw-border-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-font-weight {
syntax: "*";
inherits: false;
}
@property --tw-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-inset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-ring-inset {
syntax: "*";
inherits: false;
}
@property --tw-ring-offset-width {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --tw-ring-offset-color {
syntax: "*";
inherits: false;
initial-value: #fff;
}
@property --tw-ring-offset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-border-style: solid;
--tw-font-weight: initial;
--tw-shadow: 0 0 #0000;
--tw-shadow-color: initial;
--tw-shadow-alpha: 100%;
--tw-inset-shadow: 0 0 #0000;
--tw-inset-shadow-color: initial;
--tw-inset-shadow-alpha: 100%;
--tw-ring-color: initial;
--tw-ring-shadow: 0 0 #0000;
--tw-inset-ring-color: initial;
--tw-inset-ring-shadow: 0 0 #0000;
--tw-ring-inset: initial;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
}
}
}

View File

@@ -1,74 +1,90 @@
{% extends "template.html.twig" %}
{% block content %}
<table class='table table-hover'>
<tr>
<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></td>
<td><img src='{{ product.image }}&width=150' class='border rounded p-1' alt='{{ product.name }}'/></td>
<td><a href='{{ path('app_home') }}/product/{{ product.id }}' class="text-decoration-none">{{ product.name }}</a></td>
<td>{{ product.subTitle }}</td>
<td>
<ul class='nav'>
{% for category in product.categories %}
<li class="nav-item"><a class="nav-link" href="{{ path('app_category', {'category': category}) }}"> {{ category }} </a></li>
{% endfor %}
</ul>
</td>
<td><a href='https://pl.ryobitools.eu/{{ product.url }}'>link</a></td>
</tr>
<tr>
<td colspan="4">
<div style="width: 800px;">
<canvas id="price"></canvas>
</div>
</td>
</tr>
<tr>
<td colspan="4">
<table class='table table-hover table-sm mb-0'>
<thead>
<tr>
<th>price</th>
<th>lowest product price in 30 days</th>
<th colspan='2'>standard price</th>
</tr>
</thead>
{% for price in product.price %}
{% cache 'product' ~ product.id %}
<div class="table-responsive">
<table class='table table-hover'>
<tr>
<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></td>
<td><img src='{{ product.image }}&width=150' class='border rounded p-1' alt='{{ product.name }}'/></td>
<td>
<a href='{{ path('app_product', {'productId': product.id}) }}' class="text-decoration-none">{{ product.name }}</a>
<span class="badge text-bg-light">{{ product.subTitle }}</span>
</td>
<td>
<nav aria-label="breadcrumb" style="--bs-breadcrumb-divider: '>';">
<ol class="breadcrumb">
{% 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><a href='https://pl.ryobitools.eu/{{ product.url }}'>link</a></td>
</tr>
<tr>
<td colspan="5">
<div style="width: 800px;">
<canvas id="price"></canvas>
</div>
</td>
</tr>
<tr>
<td colspan="5">
<table class='table table-hover table-sm mb-0'>
<thead>
<tr>
<td>{{ price.price }}</td>
<td>{{ price.lowestProductPrice30Days }}</td>
<td>{{ price.productStandardPrice }}</td>
<td>{{ price.created_at }}</td>
<th>price</th>
<th>lowest product price in 30 days</th>
<th colspan='2'>standard price</th>
<th>Stock</th>
</tr>
{% endfor %}
</table>
</td>
</tr>
</table>
</thead>
{% for price in product.price %}
<tr>
<td>{{ price.price | format_currency('PLN', {}, 'pl') }}</td>
<td>{{ price.lowestProductPrice30Days | format_currency('PLN', {}, 'pl') }}</td>
<td>{{ price.productStandardPrice | format_currency('PLN', {}, 'pl') }}</td>
<td>{{ price.created_at }}</td>
<td>{{ (product.stock | findByCreatedAtDate(price.created_at | slice(0,10))).stock ?? '' }}</td>
</tr>
{% endfor %}
</table>
</td>
</tr>
</table>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const ctx = document.getElementById('price');
const ctx = document.getElementById('price').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: ['{{ price_dates|raw }}'],
datasets: [{
label: 'Price (PLN)',
data: [{{ price_list }}],
borderWidth: 1
}]
datasets: [
{
label: 'Price (PLN)',
data: {{ price_list|raw }},
yAxisID: 'yPrice',
tension: 0.1,
borderWidth: 1
},
{
label: 'Stock (units)',
data: {{ stock_list|raw }},
yAxisID: 'yStock',
tension: 0.1,
borderWidth: 1
}
]
},
options: {
responsive: true,
animation: false,
plugins: {
title: {
display: true,
text: 'Price in time'
},
},
scales: {
x: {
title: {
@@ -76,16 +92,44 @@
text: 'Date'
}
},
y: {
yPrice: {
type: 'linear',
position: 'left',
beginAtZero: true,
title: {
display: true,
text: 'Price'
text: 'Price (PLN)'
},
grid: {
drawOnChartArea: false
}
},
yStock: {
type: 'linear',
position: 'right',
beginAtZero: true,
title: {
display: true,
text: 'Stock (units)'
},
grid: {
drawOnChartArea: false
}
}
},
plugins: {
legend: {
display: true,
position: 'top'
},
title: {
display: true,
text: 'Price and Stock in time'
},
}
}
});
</script>
{% endcache %}
{% endblock %}

View File

@@ -1,35 +1,64 @@
{% extends "template.html.twig" %}
{% block content %}
{% cache 'list_' ~ listType %}
<div class="table-responsive">
<table class='table table-hover'>
<thead>
<tr>
<th></th>
<th></th>
<th>Name</th>
<th class="d-none d-md-table-cell">Code</th>
<th>Categories</th>
<th></th>
<th>Price</th>
<th class="text-end">Lowest Price</th>
<th class="text-end">Current Price</th>
<th></th>
</tr>
</thead>
{% for product in products %}
<tr>
<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 == true %}{% else %}{% endif %}</a></td>
<td class="align-middle" style="width: 120px;"><img src='{{ product.image }}&width=70' class='border rounded p-1' alt='{{ product.name }}'/></td>
<td class="align-middle"><a href='{{ path('app_product', {'productId': product.id}) }}' class="text-decoration-none">{{ product.name }}</a></td>
<td class="align-middle d-none d-md-table-cell">{{ product.subTitle }}</td>
<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 == true %}{% else %}{% endif %}</a></td>
<td class="align-middle" style="width: 120px;"><img src='{{ product.image }}&width=70' class='img-thumbnail' alt='{{ product.name }}'/></td>
<td class="align-middle">
<ul class='nav'>
{% for category in product.categories %}
<li class="nav-item"><a class="nav-link" href="{{ path('app_category', {'category': category}) }}"> {{ category }} </a></li>
{% endfor %}
</ul>
<a href='{{ path('app_product', {'productId': product.id}) }}' class="text-decoration-none">{{ product.name }}</a>
{% if product.currentStock.stock > 0 %}
<span class="badge text-bg-light">stock: {{ product.currentStock.stock }}</span>
{% else %}
<span class="badge text-bg-warning">out of stock</span>
{% endif %}
{% if product.isDiscontinued() %}
<span class="badge text-bg-secondary" data-bs-toggle="tooltip" data-bs-title="Last update: {{ product.updated_at }}">is discontinued</span>
{% endif %}
{% if product.isNew() %}
<span class="badge text-bg-success">is new</span>
{% endif %}
<span class="badge text-bg-light">{{ product.subTitle }}</span>
</td>
<td class="align-middle">
<nav aria-label="breadcrumb" style="--bs-breadcrumb-divider: '>';" >
<ol class="breadcrumb">
{% 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://pl.ryobitools.eu/{{ product.url }}'>link</a></td>
<td class="align-middle">{{ product.price.last.price }}</td>
<td class="align-middle">{% if product.price.last.price != product.price.last.productStandardPrice %}<span class="text-decoration-line-through text-warning">{{ product.price.last.productStandardPrice }}</span> <span class="text-success">{{ ((1 - product.price.last.price / product.price.last.productStandardPrice)*100)|number_format(0) }}%</span>{% else %}{% endif %}</td>
<td class="align-middle text-end">{% if product.lowestPrice.price != product.price.last.price %}{{ product.lowestPrice.price | format_currency('PLN', {}, 'pl') }}{% endif %}</td>
<td class="align-middle text-end">{{ product.price.last.price | format_currency('PLN', {}, 'pl') }}</td>
<td class="align-middle">
<div class="d-flex flex-row">
{% if product.price.last.price != product.price.last.productStandardPrice %}<span
class="badge text-bg-warning text-decoration-line-through flex-fill">{{ product.price.last.productStandardPrice | format_currency('PLN', {}, 'pl') }}</span>&nbsp;<span
class="badge text-bg-success flex-fill">{{ ((1 - product.price.last.price / product.price.last.productStandardPrice)*100)|number_format(0) }}%</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endcache %}
{% endblock %}

View File

@@ -5,10 +5,11 @@
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Ryobi crawler</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous"/>
<link href="/templates/output/style.css" rel="stylesheet" />
<script src="/templates/js/script.js" defer></script>
</head>
<body>
<h1 class="text-3xl font-bold underline">test header</h1>
<nav class="navbar navbar-expand-lg sticky-top bg-body-tertiary border-bottom border-secondary border-1">
<div class="container-fluid">
<a class="navbar-brand" href="{{ path('app_home') }}">Crawler</a>
@@ -18,13 +19,26 @@
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="{{ path('app_promos') }}">Promos</a>
</li>
{% cache "menu_count" %}
<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>
</li>
<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>
</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>
{% endcache %}
</ul>
<form class="d-flex col-lg-6 col-sm-8" role="search" action="{{ path('app_search') }}">
<input class="form-control me-2" type="search" name="search" placeholder="Search term eg. 36v or RCS18X" value="{{ search|default('') }}" aria-label="Search">
<form class="form-floating d-flex col-lg-6 col-sm-8" role="search" action="{{ path('app_search') }}">
<input class="form-control me-2 form-control-sm" type="search" id="floatingInputValue" name="search" placeholder="Search term eg. 36v or RCS18X" value="{{ search|default('') }}">
<button class="btn btn-outline-success" type="submit">Search</button>
<label for="floatingInputValue">Search term eg. 36v or RCS18X</label>
</form>
</div>
</div>