This commit is contained in:
Flatlogic Bot 2026-03-10 15:19:21 +00:00
parent 0202f5c65d
commit f6d1ea8ef2
9 changed files with 892 additions and 534 deletions

View File

@ -1,403 +1,148 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
:root {
color-scheme: light;
--bg: #f6f7f9;
--surface: #ffffff;
--border: #e3e6eb;
--text: #0f172a;
--muted: #6b7280;
--accent: #111827;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 12px;
}
body {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
background-size: 400% 400%;
animation: gradient 15s ease infinite;
color: #212529;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 14px;
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 15px;
}
.main-wrapper {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
width: 100%;
padding: 20px;
box-sizing: border-box;
position: relative;
z-index: 1;
.app-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
.app-header {
background: var(--surface);
}
.chat-container {
width: 100%;
max-width: 600px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 20px;
display: flex;
flex-direction: column;
height: 85vh;
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
overflow: hidden;
.nav-link {
color: var(--muted);
font-weight: 500;
}
.chat-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
background: rgba(255, 255, 255, 0.5);
font-weight: 700;
font-size: 1.1rem;
display: flex;
justify-content: space-between;
align-items: center;
.nav-link:hover,
.nav-link:focus {
color: var(--text);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
.hero-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 24px;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
.content-card,
.metric-card,
.mini-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 20px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
::-webkit-scrollbar-track {
background: transparent;
.metric-card h3 {
font-size: 1.6rem;
font-weight: 600;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.message {
max-width: 85%;
padding: 0.85rem 1.1rem;
border-radius: 16px;
line-height: 1.5;
font-size: 0.95rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.message.visitor {
align-self: flex-end;
background: linear-gradient(135deg, #212529 0%, #343a40 100%);
color: #fff;
border-bottom-right-radius: 4px;
}
.message.bot {
align-self: flex-start;
background: #ffffff;
color: #212529;
border-bottom-left-radius: 4px;
}
.chat-input-area {
padding: 1.25rem;
background: rgba(255, 255, 255, 0.5);
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.chat-input-area form {
display: flex;
gap: 0.75rem;
}
.chat-input-area input {
flex: 1;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 0.75rem 1rem;
outline: none;
background: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
}
.chat-input-area input:focus {
border-color: #23a6d5;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
}
.chat-input-area button {
background: #212529;
color: #fff;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
}
.chat-input-area button:hover {
background: #000;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
/* Background Animations */
.bg-animations {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
.blob {
position: absolute;
width: 500px;
height: 500px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
filter: blur(80px);
animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
}
.blob-1 {
top: -10%;
left: -10%;
background: rgba(238, 119, 82, 0.4);
}
.blob-2 {
bottom: -10%;
right: -10%;
background: rgba(35, 166, 213, 0.4);
animation-delay: -7s;
width: 600px;
height: 600px;
}
.blob-3 {
top: 40%;
left: 30%;
background: rgba(231, 60, 126, 0.3);
animation-delay: -14s;
width: 450px;
height: 450px;
}
@keyframes move {
0% { transform: translate(0, 0) rotate(0deg) scale(1); }
33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
100% { transform: translate(0, 0) rotate(360deg) scale(1); }
}
.header-link {
font-size: 14px;
color: #fff;
text-decoration: none;
background: rgba(0, 0, 0, 0.2);
padding: 0.5rem 1rem;
border-radius: 8px;
transition: all 0.3s ease;
}
.header-link:hover {
background: rgba(0, 0, 0, 0.4);
text-decoration: none;
}
/* Admin Styles */
.admin-container {
max-width: 900px;
margin: 3rem auto;
padding: 2.5rem;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 24px;
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
border: 1px solid rgba(255, 255, 255, 0.4);
position: relative;
z-index: 1;
}
.admin-container h1 {
margin-top: 0;
color: #212529;
font-weight: 800;
.mini-card {
padding: 16px;
}
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0 8px;
margin-top: 1.5rem;
margin-bottom: 0;
}
.table th {
background: transparent;
border: none;
padding: 1rem;
color: #6c757d;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 1px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.table td {
background: #fff;
padding: 1rem;
border: none;
vertical-align: middle;
}
.table tr td:first-child { border-radius: 12px 0 0 12px; }
.table tr td:last-child { border-radius: 0 12px 12px 0; }
.form-group {
margin-bottom: 1.25rem;
.empty-state {
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
padding: 20px;
text-align: center;
color: var(--muted);
background: #fafafb;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
font-size: 0.9rem;
.form-control,
.form-select {
border-radius: var(--radius-sm);
border-color: var(--border);
}
.form-control {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
background: #fff;
transition: all 0.3s ease;
box-sizing: border-box;
.form-control:focus,
.form-select:focus {
border-color: #94a3b8;
box-shadow: 0 0 0 0.2rem rgba(148, 163, 184, 0.25);
}
.form-control:focus {
outline: none;
border-color: #23a6d5;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
.btn {
border-radius: var(--radius-sm);
font-weight: 600;
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
.btn-dark {
background: var(--accent);
border-color: var(--accent);
}
.header-links {
display: flex;
gap: 1rem;
.btn-dark:hover {
background: #0b1120;
border-color: #0b1120;
}
.admin-card {
background: rgba(255, 255, 255, 0.6);
padding: 2rem;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.5);
margin-bottom: 2.5rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
.btn-outline-dark {
border-color: var(--accent);
color: var(--accent);
}
.admin-card h3 {
margin-top: 0;
margin-bottom: 1.5rem;
font-weight: 700;
.btn-outline-dark:hover {
background: var(--accent);
color: #fff;
}
.btn-delete {
background: #dc3545;
color: white;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
.toast-container {
z-index: 1055;
}
.btn-add {
background: #212529;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
margin-top: 1rem;
.badge {
border-radius: 999px;
font-weight: 500;
}
.btn-save {
background: #0088cc;
color: white;
border: none;
padding: 0.8rem 1.5rem;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
width: 100%;
transition: all 0.3s ease;
footer {
background: var(--surface);
margin-top: auto;
}
.webhook-url {
font-size: 0.85em;
color: #555;
margin-top: 0.5rem;
@media (max-width: 768px) {
.hero-panel {
padding: 20px;
}
}
.history-table-container {
overflow-x: auto;
background: rgba(255, 255, 255, 0.4);
padding: 1rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.history-table {
width: 100%;
}
.history-table-time {
width: 15%;
white-space: nowrap;
font-size: 0.85em;
color: #555;
}
.history-table-user {
width: 35%;
background: rgba(255, 255, 255, 0.3);
border-radius: 8px;
padding: 8px;
}
.history-table-ai {
width: 50%;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
padding: 8px;
}
.no-messages {
text-align: center;
color: #777;
}

View File

@ -1,39 +1,7 @@
document.addEventListener('DOMContentLoaded', () => {
const chatForm = document.getElementById('chat-form');
const chatInput = document.getElementById('chat-input');
const chatMessages = document.getElementById('chat-messages');
const appendMessage = (text, sender) => {
const msgDiv = document.createElement('div');
msgDiv.classList.add('message', sender);
msgDiv.textContent = text;
chatMessages.appendChild(msgDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
};
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const message = chatInput.value.trim();
if (!message) return;
appendMessage(message, 'visitor');
chatInput.value = '';
try {
const response = await fetch('api/chat.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
const data = await response.json();
// Artificial delay for realism
setTimeout(() => {
appendMessage(data.reply, 'bot');
}, 500);
} catch (error) {
console.error('Error:', error);
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
}
});
const toastEl = document.querySelector('.toast');
if (toastEl && window.bootstrap) {
const toast = new bootstrap.Toast(toastEl, { delay: 3800 });
toast.show();
}
});

129
includes/app.php Normal file
View File

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../db/config.php';
@date_default_timezone_set('UTC');
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
function h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
function ensure_tables(): void
{
$pdo = db();
$pdo->exec(
"CREATE TABLE IF NOT EXISTS stores (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(150) NOT NULL,
slug VARCHAR(150) NOT NULL,
contact_email VARCHAR(190) DEFAULT NULL,
currency CHAR(3) NOT NULL DEFAULT 'USD',
created_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS products (
id INT AUTO_INCREMENT PRIMARY KEY,
store_id INT NOT NULL,
sku VARCHAR(64) NOT NULL,
name VARCHAR(180) NOT NULL,
price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
stock INT NOT NULL DEFAULT 0,
status VARCHAR(24) NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL,
INDEX (store_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
function slugify(string $value): string
{
$slug = strtolower(trim($value));
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
$slug = trim($slug, '-');
return $slug !== '' ? $slug : 'store';
}
function flash_set(string $type, string $message): void
{
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
}
function flash_get(): ?array
{
if (empty($_SESSION['flash'])) {
return null;
}
$flash = $_SESSION['flash'];
unset($_SESSION['flash']);
return $flash;
}
function get_stores(): array
{
$stmt = db()->query(
"SELECT s.*, COUNT(p.id) AS product_count
FROM stores s
LEFT JOIN products p ON p.store_id = s.id
GROUP BY s.id
ORDER BY s.created_at DESC"
);
return $stmt->fetchAll();
}
function get_store(int $id): ?array
{
$stmt = db()->prepare("SELECT * FROM stores WHERE id = :id LIMIT 1");
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$store = $stmt->fetch();
return $store ?: null;
}
function get_products(?int $storeId = null, int $limit = 50): array
{
if ($storeId) {
$stmt = db()->prepare(
"SELECT p.*, s.name AS store_name
FROM products p
JOIN stores s ON s.id = p.store_id
WHERE p.store_id = :store_id
ORDER BY p.created_at DESC
LIMIT :limit"
);
$stmt->bindValue(':store_id', $storeId, PDO::PARAM_INT);
} else {
$stmt = db()->prepare(
"SELECT p.*, s.name AS store_name
FROM products p
JOIN stores s ON s.id = p.store_id
ORDER BY p.created_at DESC
LIMIT :limit"
);
}
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
function get_product(int $id): ?array
{
$stmt = db()->prepare(
"SELECT p.*, s.name AS store_name, s.currency
FROM products p
JOIN stores s ON s.id = p.store_id
WHERE p.id = :id
LIMIT 1"
);
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->execute();
$product = $stmt->fetch();
return $product ?: null;
}

82
includes/layout.php Normal file
View File

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/app.php';
function render_header(string $title, string $pageDescription = ''): void
{
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
$description = $pageDescription !== '' ? $pageDescription : $projectDescription;
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><?= h($title) ?></title>
<?php if ($description): ?>
<meta name="description" content="<?= h($description) ?>" />
<meta property="og:description" content="<?= h($description) ?>" />
<meta property="twitter:description" content="<?= h($description) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= h($projectImageUrl) ?>" />
<meta property="twitter:image" content="<?= h($projectImageUrl) ?>" />
<?php endif; ?>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/assets/css/custom.css?v=<?= time() ?>">
</head>
<body>
<div class="app-shell">
<header class="app-header border-bottom">
<div class="container-fluid px-4 py-3 d-flex align-items-center justify-content-between">
<div>
<p class="text-uppercase small text-muted mb-1">Store Ops</p>
<h1 class="h5 mb-0"><?= h($title) ?></h1>
</div>
<nav class="d-flex gap-3 align-items-center">
<a class="nav-link px-0" href="/index.php">Overview</a>
<a class="nav-link px-0" href="/stores.php">Stores</a>
<a class="nav-link px-0" href="/products.php">Products</a>
<a class="nav-link px-0 text-muted" href="#" aria-disabled="true">Orders</a>
</nav>
</div>
</header>
<main class="container-fluid px-4 py-4">
<?php
$flash = flash_get();
if ($flash) {
$type = $flash['type'] === 'error' ? 'danger' : $flash['type'];
?>
<div class="toast-container position-fixed top-0 end-0 p-3">
<div class="toast text-bg-<?= h($type) ?> border-0" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body"><?= h($flash['message']) ?></div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
</div>
</div>
<?php
}
}
function render_footer(): void
{
?>
</main>
<footer class="border-top py-3">
<div class="container-fluid px-4 d-flex flex-wrap justify-content-between text-muted small">
<span>UTC <?= h(date('Y-m-d H:i')) ?></span>
<span>Multi-store operations workspace</span>
</div>
</footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
<script src="/assets/js/main.js?v=<?= time() ?>" defer></script>
</body>
</html>
<?php
}

266
index.php
View File

@ -1,150 +1,126 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION;
$now = date('Y-m-d H:i:s');
require_once __DIR__ . '/includes/layout.php';
ensure_tables();
$stores = get_stores();
$recentProducts = get_products(null, 5);
$stats = [
'stores' => count($stores),
'products' => count($recentProducts),
];
render_header('Operations Overview', 'Multi-tenant store operations dashboard for catalog and order readiness.');
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
<section class="hero-panel mb-4">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
<div>
<p class="text-muted small mb-2">Multi-store readiness</p>
<h2 class="display-6 mb-2">Run catalog operations across every store.</h2>
<p class="text-muted mb-0">Create stores, keep SKU inventory accurate, and prepare the order lifecycle with clear ownership.</p>
</div>
</main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
</footer>
</body>
</html>
<div class="d-flex gap-2">
<a class="btn btn-dark" href="/stores.php">Create a store</a>
<a class="btn btn-outline-dark" href="/products.php">Add product</a>
</div>
</div>
</section>
<section class="row g-3 mb-4">
<div class="col-12 col-md-4">
<div class="metric-card">
<p class="text-muted small mb-1">Active stores</p>
<h3 class="mb-0"><?= h((string)$stats['stores']) ?></h3>
</div>
</div>
<div class="col-12 col-md-4">
<div class="metric-card">
<p class="text-muted small mb-1">Recent products</p>
<h3 class="mb-0"><?= h((string)$stats['products']) ?></h3>
</div>
</div>
<div class="col-12 col-md-4">
<div class="metric-card">
<p class="text-muted small mb-1">Order pipeline</p>
<p class="mb-0 text-muted">Enable next</p>
</div>
</div>
</section>
<section class="content-card mb-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h4 class="mb-1">Recent product activity</h4>
<p class="text-muted small mb-0">Latest SKUs added across the workspace.</p>
</div>
<a class="btn btn-sm btn-outline-dark" href="/products.php">View all</a>
</div>
<?php if (empty($recentProducts)): ?>
<div class="empty-state">
<p class="mb-2">No products yet. Start by creating a store and adding its first SKU.</p>
<a class="btn btn-dark btn-sm" href="/stores.php">Create store</a>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>SKU</th>
<th>Product</th>
<th>Store</th>
<th>Status</th>
<th class="text-end">Stock</th>
</tr>
</thead>
<tbody>
<?php foreach ($recentProducts as $product): ?>
<tr>
<td class="text-muted"><?= h($product['sku']) ?></td>
<td><a href="/product.php?id=<?= h((string)$product['id']) ?>" class="link-dark"><?= h($product['name']) ?></a></td>
<td><?= h($product['store_name']) ?></td>
<td><span class="badge bg-light text-dark border"><?= h(ucfirst($product['status'])) ?></span></td>
<td class="text-end"><?= h((string)$product['stock']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<section class="content-card">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h4 class="mb-1">Next slice: Orders & payments</h4>
<p class="text-muted small mb-0">Track statuses from paid to delivered with payment reconciliation.</p>
</div>
<span class="badge text-bg-light border">Coming next</span>
</div>
<div class="row g-3">
<div class="col-12 col-md-4">
<div class="mini-card">
<p class="text-muted small mb-1">Order states</p>
<p class="mb-0">New Paid Packed Shipped Delivered</p>
</div>
</div>
<div class="col-12 col-md-4">
<div class="mini-card">
<p class="text-muted small mb-1">Payments</p>
<p class="mb-0">Record partials, refunds, and settlement notes.</p>
</div>
</div>
<div class="col-12 col-md-4">
<div class="mini-card">
<p class="text-muted small mb-1">Customer ledger</p>
<p class="mb-0">Centralize contact, address, and order history.</p>
</div>
</div>
</div>
</section>
<?php render_footer(); ?>

104
product.php Normal file
View File

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/layout.php';
ensure_tables();
$productId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$productId = (int)($_POST['id'] ?? 0);
$status = trim($_POST['status'] ?? 'active');
$stock = (int)($_POST['stock'] ?? 0);
if ($productId) {
$stmt = db()->prepare(
"UPDATE products SET status = :status, stock = :stock WHERE id = :id"
);
$stmt->bindValue(':status', $status);
$stmt->bindValue(':stock', $stock, PDO::PARAM_INT);
$stmt->bindValue(':id', $productId, PDO::PARAM_INT);
$stmt->execute();
flash_set('success', 'Product updated.');
} else {
flash_set('error', 'Product not found.');
}
header('Location: /product.php?id=' . $productId);
exit;
}
$product = $productId ? get_product($productId) : null;
if (!$product) {
render_header('Product not found');
?>
<div class="content-card">
<h4 class="mb-2">Product not found</h4>
<p class="text-muted mb-3">The requested product does not exist.</p>
<a class="btn btn-dark" href="/products.php">Back to catalog</a>
</div>
<?php
render_footer();
exit;
}
render_header($product['name'], 'Product detail with inventory status.');
?>
<section class="row g-4">
<div class="col-12 col-lg-7">
<div class="content-card h-100">
<p class="text-muted small mb-1">SKU <?= h($product['sku']) ?></p>
<h3 class="mb-2"><?= h($product['name']) ?></h3>
<p class="text-muted mb-4">Store: <?= h($product['store_name']) ?></p>
<div class="row g-3">
<div class="col-6">
<div class="mini-card">
<p class="text-muted small mb-1">Price</p>
<p class="mb-0"><?= h($product['currency']) ?> <?= h(number_format((float)$product['price'], 2)) ?></p>
</div>
</div>
<div class="col-6">
<div class="mini-card">
<p class="text-muted small mb-1">Stock on hand</p>
<p class="mb-0"><?= h((string)$product['stock']) ?></p>
</div>
</div>
</div>
<div class="mini-card mt-4">
<p class="text-muted small mb-1">Status</p>
<span class="badge bg-light text-dark border"><?= h(ucfirst($product['status'])) ?></span>
</div>
</div>
</div>
<div class="col-12 col-lg-5">
<div class="content-card h-100">
<h4 class="mb-3">Update inventory</h4>
<form method="post" class="vstack gap-3">
<input type="hidden" name="id" value="<?= h((string)$product['id']) ?>">
<div>
<label class="form-label">Status</label>
<select name="status" class="form-select">
<?php foreach (['active', 'paused', 'archived'] as $state): ?>
<option value="<?= h($state) ?>" <?= $product['status'] === $state ? 'selected' : '' ?>>
<?= h(ucfirst($state)) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">Stock</label>
<input type="number" name="stock" class="form-control" value="<?= h((string)$product['stock']) ?>" min="0">
</div>
<button class="btn btn-dark w-100" type="submit">Save updates</button>
</form>
<p class="text-muted small mt-3 mb-0">Use this for quick stock corrections before orders are fulfilled.</p>
</div>
</div>
</section>
<?php render_footer(); ?>

156
products.php Normal file
View File

@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/layout.php';
ensure_tables();
$stores = get_stores();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$storeId = (int)($_POST['store_id'] ?? 0);
$sku = trim($_POST['sku'] ?? '');
$name = trim($_POST['name'] ?? '');
$price = (float)($_POST['price'] ?? 0);
$stock = (int)($_POST['stock'] ?? 0);
$status = trim($_POST['status'] ?? 'active');
if (!$storeId) {
flash_set('error', 'Select a store for the product.');
} elseif ($sku === '' || $name === '') {
flash_set('error', 'SKU and product name are required.');
} elseif ($price < 0 || $stock < 0) {
flash_set('error', 'Price and stock must be zero or greater.');
} else {
$stmt = db()->prepare(
"INSERT INTO products (store_id, sku, name, price, stock, status, created_at)
VALUES (:store_id, :sku, :name, :price, :stock, :status, :created_at)"
);
$stmt->bindValue(':store_id', $storeId, PDO::PARAM_INT);
$stmt->bindValue(':sku', $sku);
$stmt->bindValue(':name', $name);
$stmt->bindValue(':price', $price);
$stmt->bindValue(':stock', $stock, PDO::PARAM_INT);
$stmt->bindValue(':status', $status);
$stmt->bindValue(':created_at', date('Y-m-d H:i:s'));
$stmt->execute();
flash_set('success', 'Product added to the catalog.');
}
header('Location: /products.php');
exit;
}
$filterStore = isset($_GET['store_id']) ? (int)$_GET['store_id'] : null;
$products = get_products($filterStore, 50);
render_header('Product Catalog', 'Manage SKUs, pricing, and inventory for every store.');
?>
<section class="row g-4">
<div class="col-12 col-lg-4">
<div class="content-card h-100">
<h4 class="mb-3">Add product</h4>
<?php if (empty($stores)): ?>
<div class="alert alert-warning mb-0">Create a store first to add products.</div>
<?php else: ?>
<form method="post" class="vstack gap-3">
<div>
<label class="form-label">Store</label>
<select name="store_id" class="form-select" required>
<option value="">Select store</option>
<?php foreach ($stores as $store): ?>
<option value="<?= h((string)$store['id']) ?>"><?= h($store['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">SKU</label>
<input type="text" name="sku" class="form-control" placeholder="SKU-001" required>
</div>
<div>
<label class="form-label">Product name</label>
<input type="text" name="name" class="form-control" placeholder="Walnut Desk Lamp" required>
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label">Price</label>
<input type="number" step="0.01" name="price" class="form-control" value="0.00" min="0">
</div>
<div class="col-6">
<label class="form-label">Stock</label>
<input type="number" name="stock" class="form-control" value="0" min="0">
</div>
</div>
<div>
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="archived">Archived</option>
</select>
</div>
<button class="btn btn-dark w-100" type="submit">Add product</button>
</form>
<?php endif; ?>
<p class="text-muted small mt-3 mb-0">Assign every SKU to a store to keep inventory separated.</p>
</div>
</div>
<div class="col-12 col-lg-8">
<div class="content-card">
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3 gap-2">
<div>
<h4 class="mb-1">Catalog list</h4>
<p class="text-muted small mb-0">Filter by store to narrow inventory.</p>
</div>
<form method="get" class="d-flex align-items-center gap-2">
<select name="store_id" class="form-select form-select-sm">
<option value="">All stores</option>
<?php foreach ($stores as $store): ?>
<option value="<?= h((string)$store['id']) ?>" <?= $filterStore === (int)$store['id'] ? 'selected' : '' ?>>
<?= h($store['name']) ?>
</option>
<?php endforeach; ?>
</select>
<button class="btn btn-sm btn-outline-dark" type="submit">Filter</button>
</form>
</div>
<?php if (empty($products)): ?>
<div class="empty-state">
<p class="mb-2">No products match this filter.</p>
<p class="text-muted small mb-0">Add a new SKU to get started.</p>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>SKU</th>
<th>Product</th>
<th>Store</th>
<th>Status</th>
<th class="text-end">Price</th>
<th class="text-end">Stock</th>
</tr>
</thead>
<tbody>
<?php foreach ($products as $product): ?>
<tr>
<td class="text-muted"><?= h($product['sku']) ?></td>
<td><a class="link-dark" href="/product.php?id=<?= h((string)$product['id']) ?>"><?= h($product['name']) ?></a></td>
<td><?= h($product['store_name']) ?></td>
<td><span class="badge bg-light text-dark border"><?= h(ucfirst($product['status'])) ?></span></td>
<td class="text-end">$<?= h(number_format((float)$product['price'], 2)) ?></td>
<td class="text-end"><?= h((string)$product['stock']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</section>
<?php render_footer(); ?>

83
store.php Normal file
View File

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/layout.php';
ensure_tables();
$storeId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$store = $storeId ? get_store($storeId) : null;
if (!$store) {
render_header('Store not found');
?>
<div class="content-card">
<h4 class="mb-2">Store not found</h4>
<p class="text-muted mb-3">The store you requested does not exist. Return to the directory to pick another.</p>
<a class="btn btn-dark" href="/stores.php">Back to stores</a>
</div>
<?php
render_footer();
exit;
}
$products = get_products($storeId, 10);
render_header($store['name'], 'Store catalog overview and quick access to products.');
?>
<section class="content-card mb-4">
<div class="d-flex flex-wrap justify-content-between gap-3">
<div>
<p class="text-muted small mb-1">Store profile</p>
<h3 class="mb-1"><?= h($store['name']) ?></h3>
<p class="text-muted mb-0">Slug: <?= h($store['slug']) ?> · Currency: <?= h($store['currency']) ?></p>
</div>
<div class="d-flex gap-2">
<a class="btn btn-outline-dark" href="/products.php?store_id=<?= h((string)$store['id']) ?>">View products</a>
<a class="btn btn-dark" href="/products.php">Add product</a>
</div>
</div>
</section>
<section class="content-card">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h4 class="mb-1">Recent products</h4>
<p class="text-muted small mb-0">Latest inventory updates for this store.</p>
</div>
<span class="badge text-bg-light border"><?= h((string)count($products)) ?> items</span>
</div>
<?php if (empty($products)): ?>
<div class="empty-state">
<p class="mb-2">No products created for this store yet.</p>
<a class="btn btn-dark btn-sm" href="/products.php">Add the first SKU</a>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>SKU</th>
<th>Product</th>
<th>Status</th>
<th class="text-end">Stock</th>
</tr>
</thead>
<tbody>
<?php foreach ($products as $product): ?>
<tr>
<td class="text-muted"><?= h($product['sku']) ?></td>
<td><a class="link-dark" href="/product.php?id=<?= h((string)$product['id']) ?>"><?= h($product['name']) ?></a></td>
<td><span class="badge bg-light text-dark border"><?= h(ucfirst($product['status'])) ?></span></td>
<td class="text-end"><?= h((string)$product['stock']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<?php render_footer(); ?>

115
stores.php Normal file
View File

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/layout.php';
ensure_tables();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = trim($_POST['name'] ?? '');
$slugInput = trim($_POST['slug'] ?? '');
$email = trim($_POST['contact_email'] ?? '');
$currency = strtoupper(trim($_POST['currency'] ?? 'USD'));
if ($name === '') {
flash_set('error', 'Store name is required.');
} elseif (!preg_match('/^[A-Z]{3}$/', $currency)) {
flash_set('error', 'Currency must be a 3-letter ISO code.');
} else {
$slug = $slugInput !== '' ? slugify($slugInput) : slugify($name);
$stmt = db()->prepare(
"INSERT INTO stores (name, slug, contact_email, currency, created_at)
VALUES (:name, :slug, :email, :currency, :created_at)"
);
$stmt->bindValue(':name', $name);
$stmt->bindValue(':slug', $slug);
$stmt->bindValue(':email', $email !== '' ? $email : null);
$stmt->bindValue(':currency', $currency);
$stmt->bindValue(':created_at', date('Y-m-d H:i:s'));
$stmt->execute();
flash_set('success', 'Store created successfully.');
}
header('Location: /stores.php');
exit;
}
$stores = get_stores();
render_header('Store Directory', 'Create and manage tenant stores with isolated catalogs.');
?>
<section class="row g-4">
<div class="col-12 col-lg-4">
<div class="content-card h-100">
<h4 class="mb-3">Create a store</h4>
<form method="post" class="vstack gap-3">
<div>
<label class="form-label">Store name</label>
<input type="text" name="name" class="form-control" placeholder="Atlas Home Goods" required>
</div>
<div>
<label class="form-label">Slug</label>
<input type="text" name="slug" class="form-control" placeholder="atlas-home-goods">
</div>
<div>
<label class="form-label">Contact email</label>
<input type="email" name="contact_email" class="form-control" placeholder="ops@store.com">
</div>
<div>
<label class="form-label">Currency</label>
<input type="text" name="currency" class="form-control" value="USD" maxlength="3">
</div>
<button class="btn btn-dark w-100" type="submit">Create store</button>
</form>
<p class="text-muted small mt-3 mb-0">Each store has its own catalog, inventory, and future order pipeline.</p>
</div>
</div>
<div class="col-12 col-lg-8">
<div class="content-card">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h4 class="mb-1">Stores</h4>
<p class="text-muted small mb-0">Multi-tenant roster with catalog counts.</p>
</div>
<span class="badge text-bg-light border"><?= h((string)count($stores)) ?> total</span>
</div>
<?php if (empty($stores)): ?>
<div class="empty-state">
<p class="mb-2">No stores added yet.</p>
<p class="text-muted small mb-0">Create the first store to unlock product management.</p>
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Store</th>
<th>Slug</th>
<th>Contact</th>
<th class="text-end">Products</th>
</tr>
</thead>
<tbody>
<?php foreach ($stores as $store): ?>
<tr>
<td>
<a class="link-dark" href="/store.php?id=<?= h((string)$store['id']) ?>">
<?= h($store['name']) ?>
</a>
</td>
<td class="text-muted"><?= h($store['slug']) ?></td>
<td><?= h($store['contact_email'] ?? '—') ?></td>
<td class="text-end"><?= h((string)$store['product_count']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</section>
<?php render_footer(); ?>