Added price tracking cards
This commit is contained in:
293
public/viewFilament.php
Normal file
293
public/viewFilament.php
Normal file
@@ -0,0 +1,293 @@
|
||||
<?php include '../src/session_check.php'; ?>
|
||||
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<!-- Required meta tags -->
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!--favicon-->
|
||||
<link rel="icon" href="../assets/images/favicon-32x32.png" type="image/png">
|
||||
<!--plugins-->
|
||||
<link href="../assets/plugins/vectormap/jquery-jvectormap-2.0.2.css" rel="stylesheet">
|
||||
<link href="../assets/plugins/simplebar/css/simplebar.css" rel="stylesheet">
|
||||
<link href="../assets/plugins/perfect-scrollbar/css/perfect-scrollbar.css" rel="stylesheet">
|
||||
<link href="../assets/plugins/metismenu/css/metisMenu.min.css" rel="stylesheet">
|
||||
<!-- loader-->
|
||||
<link href="../assets/css/pace.min.css" rel="stylesheet"/>
|
||||
<script src="../assets/js/pace.min.js"></script>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="../assets/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="../assets/css/bootstrap-extended.css" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
<link href="../assets/sass/app.css" rel="stylesheet">
|
||||
<link href="../assets/css/icons.css" rel="stylesheet">
|
||||
<link href='https://unpkg.com/boxicons@2.1.4/css/boxicons.min.css' rel='stylesheet'>
|
||||
<!-- Theme Style CSS -->
|
||||
<link rel="stylesheet" href="../assets/sass/dark-theme.css">
|
||||
<link rel="stylesheet" href="../assets/sass/semi-dark.css">
|
||||
<link rel="stylesheet" href="../assets/sass/bordered-theme.css">
|
||||
|
||||
<title>TOD Dashboard</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!--wrapper-->
|
||||
<div class="wrapper">
|
||||
<!--sidebar wrapper -->
|
||||
<?php include '../src/nav.php'; ?>
|
||||
<!--end sidebar wrapper -->
|
||||
<!--start header -->
|
||||
<?php include '../src/header.php'; ?>
|
||||
<!--end header -->
|
||||
|
||||
<!--start page wrapper -->
|
||||
<div class="page-wrapper">
|
||||
<div class="page-content">
|
||||
<!--start page content -->
|
||||
|
||||
|
||||
|
||||
<div class="container">
|
||||
<div class="row" id="filamentCardContainer">
|
||||
<!-- Cards will be inserted here dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!--end page content -->
|
||||
</div>
|
||||
</div>
|
||||
<!--end page wrapper -->
|
||||
|
||||
<!--start overlay-->
|
||||
<div class="overlay mobile-toggle-icon"></div>
|
||||
<!--end overlay-->
|
||||
<!--Start Back To Top Button-->
|
||||
<a href="javaScript:;" class="back-to-top"><i class='bx bxs-up-arrow-alt'></i></a>
|
||||
<!--End Back To Top Button-->
|
||||
<footer class="page-footer">
|
||||
<p class="mb-0">Copyright © 2024. All right reserved.</p>
|
||||
</footer>
|
||||
</div>
|
||||
<!--end wrapper-->
|
||||
|
||||
|
||||
<!-- search modal -->
|
||||
<div class="modal" id="SearchModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-fullscreen-md-down">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header gap-2">
|
||||
<div class="position-relative popup-search w-100">
|
||||
<input class="form-control form-control-lg ps-5 border border-3 border-primary" type="search" placeholder="Search">
|
||||
<span class="position-absolute top-50 search-show ms-3 translate-middle-y start-0 top-50 fs-4"><i class='bx bx-search'></i></span>
|
||||
</div>
|
||||
<button type="button" class="btn-close d-md-none" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- end search modal -->
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="../assets/js/bootstrap.bundle.min.js"></script>
|
||||
<!--plugins-->
|
||||
<script src="../assets/js/jquery.min.js"></script>
|
||||
<script src="../assets/plugins/simplebar/js/simplebar.min.js"></script>
|
||||
<script src="../assets/plugins/metismenu/js/metisMenu.min.js"></script>
|
||||
<script src="../assets/plugins/perfect-scrollbar/js/perfect-scrollbar.js"></script>
|
||||
<script src="../assets/plugins/apexcharts-bundle/js/apexcharts.min.js"></script>
|
||||
<!--app JS-->
|
||||
<script src="../assets/js/app.js"></script>
|
||||
|
||||
<script src="../assets/js/index.js"></script>
|
||||
<script src="../assets/plugins/peity/jquery.peity.min.js"></script>
|
||||
<script>
|
||||
$(".data-attributes span").peity("donut")
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const container = document.getElementById('filamentCardContainer');
|
||||
|
||||
// Function to generate random colors
|
||||
function getRandomColor() {
|
||||
const letters = '0123456789ABCDEF';
|
||||
let color = '#';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
color += letters[Math.floor(Math.random() * 16)];
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
// Skeleton Loader Template
|
||||
function showSkeletonLoader() {
|
||||
container.innerHTML = `
|
||||
<div class="col-lg-4 mb-1">
|
||||
<div class="card radius-10 overflow-hidden skeleton-loader" style="height: 200px;">
|
||||
<div class="card-body d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<div class="skeleton-text mb-2" style="width: 70px; height: 20px;"></div>
|
||||
<div class="skeleton-text" style="width: 50px; height: 30px;"></div>
|
||||
</div>
|
||||
<div class="skeleton-button" style="width: 40px; height: 40px;"></div>
|
||||
</div>
|
||||
<div class="skeleton-chart" style="height: 120px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Debounce function to reduce DOM updates
|
||||
function debounceRender(func, delay) {
|
||||
let inDebounce;
|
||||
return function() {
|
||||
const context = this, args = arguments;
|
||||
clearTimeout(inDebounce);
|
||||
inDebounce = setTimeout(() => func.apply(context, args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
fetch('../src/filamentTracker/getFilamentPrices.php')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
container.innerHTML = ''; // Clear Skeleton
|
||||
|
||||
Object.keys(data.data).forEach(filament => {
|
||||
const prices = data.data[filament].prices.map(entry => entry.price);
|
||||
const timestamps = data.data[filament].prices.map(entry => entry.recordedAt);
|
||||
const latestPrice = prices[prices.length - 1] || 0;
|
||||
const previousPrice = prices[prices.length - 2] || latestPrice;
|
||||
const priceDifference = latestPrice - previousPrice;
|
||||
|
||||
let priceChangeIndicator;
|
||||
if (priceDifference > 0) {
|
||||
priceChangeIndicator = `<span class="text-danger ms-4">
|
||||
<i class="bx bx-up-arrow-alt"></i> +£${priceDifference.toFixed(2)}</span>`;
|
||||
} else if (priceDifference < 0) {
|
||||
priceChangeIndicator = `<span class="text-success ms-4">
|
||||
<i class="bx bx-down-arrow-alt"></i> £${Math.abs(priceDifference).toFixed(2)}</span>`;
|
||||
} else {
|
||||
priceChangeIndicator = `<span class="text-muted ms-4">
|
||||
<i class="bx bx-minus"></i> £0.00</span>`;
|
||||
}
|
||||
|
||||
const amazonUrl = data.data[filament].amazonUrl || '#';
|
||||
const chartId = `chart-${filament.replace(/\s+/g, '-')}`;
|
||||
const chartColor = getRandomColor();
|
||||
|
||||
// Create Card HTML with cart button
|
||||
const cardHTML = `
|
||||
<div class="col-lg-4 mb-1">
|
||||
<div class="card radius-10 overflow-hidden">
|
||||
<div class="card-body d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<a href="${amazonUrl}" target="_blank" class="mb-0 filament-name">${filament}</a>
|
||||
<h5 class="mb-0">
|
||||
£${latestPrice} ${priceChangeIndicator}
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div id="${chartId}"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.insertAdjacentHTML('beforeend', cardHTML);
|
||||
|
||||
// Observer to Wait for DOM Insertion
|
||||
const observer = new MutationObserver(debounceRender(() => {
|
||||
const chartElement = document.getElementById(chartId);
|
||||
if (chartElement) {
|
||||
const chartOptions = {
|
||||
series: [{
|
||||
name: "Price",
|
||||
data: prices
|
||||
}],
|
||||
chart: {
|
||||
type: "area",
|
||||
height: 110,
|
||||
toolbar: {
|
||||
show: false
|
||||
},
|
||||
zoom: {
|
||||
enabled: false
|
||||
},
|
||||
dropShadow: {
|
||||
enabled: true,
|
||||
top: 3,
|
||||
left: 14,
|
||||
blur: 4,
|
||||
opacity: 0.12,
|
||||
color: chartColor
|
||||
},
|
||||
sparkline: {
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
markers: {
|
||||
size: 0,
|
||||
colors: [chartColor],
|
||||
strokeColors: "#fff",
|
||||
strokeWidth: 2,
|
||||
hover: {
|
||||
size: 7
|
||||
}
|
||||
},
|
||||
dataLabels: {
|
||||
enabled: false
|
||||
},
|
||||
stroke: {
|
||||
show: true,
|
||||
width: 2.4,
|
||||
curve: "smooth"
|
||||
},
|
||||
colors: [chartColor],
|
||||
xaxis: {
|
||||
categories: timestamps,
|
||||
labels: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
fill: {
|
||||
opacity: 1
|
||||
},
|
||||
tooltip: {
|
||||
theme: "dark",
|
||||
x: {
|
||||
show: false
|
||||
},
|
||||
y: {
|
||||
formatter: function (value, { dataPointIndex }) {
|
||||
const date = new Date(timestamps[dataPointIndex]);
|
||||
return `£${value} - ${date.toLocaleDateString()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const chart = new ApexCharts(chartElement, chartOptions);
|
||||
chart.render();
|
||||
observer.disconnect();
|
||||
}
|
||||
}, 300));
|
||||
|
||||
observer.observe(container, { childList: true, subtree: true });
|
||||
});
|
||||
} else {
|
||||
container.innerHTML = '<p class="text-center">No filament data available.</p>';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error fetching filament data:", error);
|
||||
});
|
||||
|
||||
showSkeletonLoader();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -4,11 +4,7 @@ require '../db.php';
|
||||
require_once '../envLoader.php';
|
||||
loadEnv(__DIR__ . '/../../.env');
|
||||
|
||||
// Check if the user is logged in
|
||||
if (!isset($_SESSION['userId'])) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'User not authenticated.']);
|
||||
exit;
|
||||
}
|
||||
include '../src/session_check.php';
|
||||
|
||||
use Goutte\Client;
|
||||
|
||||
|
||||
@@ -4,31 +4,32 @@ require '../db.php';
|
||||
require_once '../envLoader.php';
|
||||
loadEnv(__DIR__ . '/../../.env');
|
||||
|
||||
// Check if the user is logged in
|
||||
if (!isset($_SESSION['userId'])) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'User not authenticated.']);
|
||||
exit;
|
||||
}
|
||||
include '../src/session_check.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
// Fetch all filament prices from the database
|
||||
try {
|
||||
// Fetch all filament prices with a limit of 180 entries per filament using JOIN
|
||||
$stmt = $pdo->query("
|
||||
SELECT
|
||||
ft.filamentName,
|
||||
SELECT ft.filamentName,
|
||||
ft.brand,
|
||||
ft.material,
|
||||
ft.color,
|
||||
ft.amazonUrl,
|
||||
fp.price,
|
||||
fp.recordedAt
|
||||
FROM
|
||||
filamentTracker ft
|
||||
JOIN
|
||||
filamentPriceHistory fp ON ft.id = fp.filamentId
|
||||
ORDER BY
|
||||
ft.filamentName,
|
||||
fp.recordedAt ASC
|
||||
FROM filamentTracker ft
|
||||
JOIN filamentPriceHistory fp ON ft.id = fp.filamentId
|
||||
JOIN (
|
||||
SELECT filamentId, recordedAt
|
||||
FROM (
|
||||
SELECT filamentId, recordedAt,
|
||||
ROW_NUMBER() OVER (PARTITION BY filamentId ORDER BY recordedAt DESC) as rn
|
||||
FROM filamentPriceHistory
|
||||
) ranked
|
||||
WHERE rn <= 180
|
||||
) filtered ON fp.filamentId = filtered.filamentId AND fp.recordedAt = filtered.recordedAt
|
||||
ORDER BY ft.filamentName, fp.recordedAt ASC
|
||||
");
|
||||
|
||||
$filaments = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
@@ -44,6 +45,7 @@ try {
|
||||
'brand' => $filament['brand'],
|
||||
'material' => $filament['material'],
|
||||
'color' => $filament['color'],
|
||||
'amazonUrl' => $filament['amazonUrl'],
|
||||
'prices' => []
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
<?php
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('display_startup_errors', 1);
|
||||
error_reporting(E_ALL);
|
||||
|
||||
require '../../vendor/autoload.php';
|
||||
require '../db.php';
|
||||
require_once '../envLoader.php';
|
||||
loadEnv(__DIR__ . '/../../.env');
|
||||
|
||||
use Goutte\Client;
|
||||
|
||||
function scrapeFilamentPrice($productName, $productUrl) {
|
||||
$client = new Client();
|
||||
$crawler = $client->request('GET', $productUrl);
|
||||
|
||||
// Extract the current price
|
||||
$price = $crawler->filter('span.a-price-whole')->first()->text();
|
||||
$price = str_replace(',', '', $price); // Clean the price string
|
||||
|
||||
// Extract the original price (if available)
|
||||
$originalPriceNode = $crawler->filter('span.a-price.a-text-price')->first();
|
||||
$originalPrice = $originalPriceNode->count() ? str_replace(',', '', $originalPriceNode->text()) : null;
|
||||
|
||||
// Calculate the discount percentage
|
||||
$discountPercentage = null;
|
||||
if ($originalPrice) {
|
||||
$discountPercentage = round(100 - (($price / $originalPrice) * 100), 2);
|
||||
}
|
||||
|
||||
$currency = 'GBP';
|
||||
|
||||
return [
|
||||
'productName' => $productName,
|
||||
'productUrl' => $productUrl,
|
||||
'price' => $price,
|
||||
'originalPrice' => $originalPrice,
|
||||
'discountPercentage' => $discountPercentage,
|
||||
'currency' => $currency
|
||||
];
|
||||
}
|
||||
|
||||
// Handle Form Submission (Add New Filament to Track)
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$productName = $_POST['productName'];
|
||||
$productUrl = $_POST['productUrl'];
|
||||
|
||||
$result = scrapeFilamentPrice($productName, $productUrl);
|
||||
|
||||
try {
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO filamentPrices (productName, productUrl, price, originalPrice, discountPercentage, currency)
|
||||
VALUES (:productName, :productUrl, :price, :originalPrice, :discountPercentage, :currency)
|
||||
");
|
||||
$stmt->execute([
|
||||
':productName' => $result['productName'],
|
||||
':productUrl' => $result['productUrl'],
|
||||
':price' => $result['price'],
|
||||
':originalPrice' => $result['originalPrice'],
|
||||
':discountPercentage' => $result['discountPercentage'],
|
||||
':currency' => $result['currency']
|
||||
]);
|
||||
|
||||
echo json_encode(['status' => 'success', 'message' => 'Filament price recorded successfully!']);
|
||||
} catch (PDOException $e) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'Failed to record price: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Scheduled Scraping for Existing URLs (Cron Job)
|
||||
if (isset($argv[1]) && $argv[1] === 'cron') {
|
||||
try {
|
||||
$stmt = $pdo->query("SELECT * FROM filamentPrices GROUP BY productUrl");
|
||||
$filaments = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($filaments as $filament) {
|
||||
$result = scrapeFilamentPrice($filament['productName'], $filament['productUrl']);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO filamentPrices (productName, productUrl, price, originalPrice, discountPercentage, currency)
|
||||
VALUES (:productName, :productUrl, :price, :originalPrice, :discountPercentage, :currency)
|
||||
");
|
||||
$stmt->execute([
|
||||
':productName' => $result['productName'],
|
||||
':productUrl' => $result['productUrl'],
|
||||
':price' => $result['price'],
|
||||
':originalPrice' => $result['originalPrice'],
|
||||
':discountPercentage' => $result['discountPercentage'],
|
||||
':currency' => $result['currency']
|
||||
]);
|
||||
|
||||
echo "Recorded: {$result['productName']} - £{$result['price']} (Discount: {$result['discountPercentage']}%)\n";
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
echo "Failed to fetch filament data: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
?>
|
||||
Reference in New Issue
Block a user