Initial commit: Meat Monster TypeScript Fullstack

This commit is contained in:
Quintine committed 2026-05-20 13:25:41 +10:00
commit eed1ef4266
41 files changed
+8319

No files matched your search

@@ -0,0 +1 @@
/home/quintine/.gemini/config/projects/d056a1d0-9164-42ab-b195-31088b846099.json
+8
View File
@@ -0,0 +1,8 @@
# Environment variables declared in this file are NOT automatically loaded by Prisma.
# Please add `import "dotenv/config";` to your `prisma.config.ts` file, or use the Prisma CLI with Bun
# to load environment variables from .env files: https://pris.ly/prisma-config-env-vars.
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
DATABASE_URL="file:./dev.db"
+10
View File
@@ -0,0 +1,10 @@
.next/
node_modules/
dist/
.env
backend/dev.db
backend/prisma/dev.db
*.db-journal
frontend/dist/
backend/dist/
.DS_Store
+34
View File
@@ -0,0 +1,34 @@
# Stage 1: Build Frontend
FROM node:20-slim AS frontend-build
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# Stage 2: Build Backend
FROM node:20-slim AS backend-build
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm install
COPY backend/ ./
RUN npx prisma generate
RUN npm run build || (mkdir -p dist && npx tsc -p tsconfig.json)
# Stage 3: Final Image
FROM node:20-slim
WORKDIR /app
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
COPY --from=frontend-build /app/frontend/dist ./frontend/dist
COPY --from=backend-build /app/backend/dist ./backend/dist
COPY --from=backend-build /app/backend/node_modules ./backend/node_modules
COPY --from=backend-build /app/backend/prisma ./backend/prisma
COPY --from=backend-build /app/backend/package*.json ./backend/
WORKDIR /app/backend
ENV NODE_ENV=production
EXPOSE 3000
# Script to run migrations and start server
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/server.js"]
+5
View File
@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/src/generated/prisma
+2869
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node dist/server.js",
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@prisma/client": "^7.8.0",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^25.9.1",
"prisma": "^7.8.0",
"ts-node-dev": "^2.0.0",
"typescript": "^6.0.3"
}
}
+14
View File
@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "StockItem" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"category" TEXT NOT NULL,
"price" REAL NOT NULL,
"unit" TEXT NOT NULL DEFAULT 'kg',
"available" BOOLEAN NOT NULL DEFAULT true,
"description" TEXT NOT NULL,
"bulk1Threshold" REAL NOT NULL DEFAULT 0,
"bulk1Price" REAL NOT NULL DEFAULT 0,
"bulk2Threshold" REAL NOT NULL DEFAULT 0,
"bulk2Price" REAL NOT NULL DEFAULT 0
);
-- CreateTable
CREATE TABLE "Order" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"totalWeight" REAL NOT NULL,
"estimatedTotal" REAL NOT NULL,
"timestamp" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"items" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "Config" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"adminCode" TEXT NOT NULL DEFAULT '1234'
);
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+39
View File
@@ -0,0 +1,39 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
}
model StockItem {
id Int @id @default(autoincrement())
name String
category String
price Float
unit String @default("kg")
available Boolean @default(true)
description String
bulk1Threshold Float @default(0)
bulk1Price Float @default(0)
bulk2Threshold Float @default(0)
bulk2Price Float @default(0)
}
model Order {
id Int @id @default(autoincrement())
name String
phone String
totalWeight Float
estimatedTotal Float
timestamp DateTime @default(now())
items String // Storing items as a JSON string for simplicity with SQLite
}
model Config {
id Int @id @default(autoincrement())
adminCode String @default("1234")
}
+169
View File
@@ -0,0 +1,169 @@
import express from 'express';
import cors from 'cors';
import { PrismaClient } from '@prisma/client';
import path from 'path';
const prisma = new PrismaClient();
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
// API Routes
// Fetch all data (Stock, Orders, Config)
app.get('/api/data', async (req, res) => {
try {
const stock = await prisma.stockItem.findMany();
const orders = await prisma.order.findMany();
const config = await prisma.config.findFirst();
// Parse order items from string to JSON
const parsedOrders = orders.map(order => ({
...order,
items: JSON.parse(order.items)
}));
res.json({ stock, orders: parsedOrders, config });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to fetch data' });
}
});
// Submit a new order
app.post('/api/orders', async (req, res) => {
try {
const { name, phone, items, totalWeight, estimatedTotal } = req.body;
const order = await prisma.order.create({
data: {
name,
phone,
totalWeight,
estimatedTotal,
items: JSON.stringify(items)
}
});
res.json(order);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to submit order' });
}
});
// Delete an order
app.delete('/api/orders/:id', async (req, res) => {
try {
const { id } = req.params;
await prisma.order.delete({ where: { id: parseInt(id) } });
res.sendStatus(200);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to delete order' });
}
});
// Clear all orders
app.delete('/api/orders', async (req, res) => {
try {
await prisma.order.deleteMany();
res.sendStatus(200);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to clear orders' });
}
});
// Update or Create Stock Item
app.post('/api/stock', async (req, res) => {
try {
const item = req.body;
if (item.id) {
const updated = await prisma.stockItem.update({
where: { id: item.id },
data: {
...item,
id: undefined // Don't update ID
}
});
res.json(updated);
} else {
const created = await prisma.stockItem.create({ data: item });
res.json(created);
}
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to update stock' });
}
});
// Delete Stock Item
app.delete('/api/stock/:id', async (req, res) => {
try {
const { id } = req.params;
await prisma.stockItem.delete({ where: { id: parseInt(id) } });
res.sendStatus(200);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to delete stock item' });
}
});
// Update Config
app.post('/api/config', async (req, res) => {
try {
const { adminCode } = req.body;
const config = await prisma.config.findFirst();
if (config) {
await prisma.config.update({
where: { id: config.id },
data: { adminCode }
});
} else {
await prisma.config.create({ data: { adminCode } });
}
res.sendStatus(200);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to update config' });
}
});
// Reset Database to defaults
app.post('/api/reset', async (req, res) => {
try {
await prisma.order.deleteMany();
await prisma.stockItem.deleteMany();
await prisma.config.deleteMany();
// Seed default admin code
await prisma.config.create({ data: { adminCode: '1234' } });
// Seed some default stock items
await prisma.stockItem.createMany({
data: [
{ name: 'Beef Brisket', category: 'Beef', price: 45, unit: 'kg', description: 'Low and slow smoked brisket', available: true },
{ name: 'Pork Shoulder', category: 'Pork', price: 35, unit: 'kg', description: 'Pulled pork perfection', available: true }
]
});
res.sendStatus(200);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Failed to reset database' });
}
});
// Serve Static Files in Production
const frontendPath = path.join(__dirname, '../../frontend/dist');
app.use(express.static(frontendPath));
app.get('*', (req, res) => {
if (req.path.startsWith('/api')) return;
res.sendFile(path.join(frontendPath, 'index.html'));
});
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
+46
View File
@@ -0,0 +1,46 @@
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
"rootDir": "src",
"outDir": "dist",
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "commonjs",
"target": "es2020",
"types": [],
// For nodejs:
// "lib": ["esnext"],
// "types": ["node"],
// and npm install -D @types/node
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true,
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
"esModuleInterop": true,
}
}
+13
View File
@@ -0,0 +1,13 @@
services:
meat-monster:
build: .
ports:
- "3000:3000"
volumes:
- meat-data:/app/backend/prisma
environment:
- DATABASE_URL=file:./dev.db
restart: always
volumes:
meat-data:
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Meat Monster - Bulk Smoked Meats & Wholesale</title>
<meta name="description" content="Premium smoked meats by the kilogram. Bulk and wholesale orders for the best BBQ experience." />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2833
View File
File diff suppressed because it is too large. Load diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+195
View File
@@ -0,0 +1,195 @@
import React, { useState, useEffect } from 'react';
import { API } from './services/api';
import { Icons } from './components/Icons';
import CustomerView from './components/CustomerView';
import AdminDashboard from './components/AdminDashboard';
import CartModal from './components/CartModal';
function App() {
const [view, setView] = useState<'customer' | 'admin'>('customer');
const [stock, setStock] = useState<any[]>([]);
const [orders, setOrders] = useState<any[]>([]);
const [cart, setCart] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showCartModal, setShowCartModal] = useState(false);
const [showAdminLogin, setShowAdminLogin] = useState(false);
const [adminCodeInput, setAdminCodeInput] = useState('');
const [notification, setNotification] = useState<string | null>(null);
const [adminCode, setAdminCode] = useState('1234');
const [confirmModal, setConfirmModal] = useState<{ show: boolean, message: string, onConfirm: (() => void) | null }>({ show: false, message: '', onConfirm: null });
useEffect(() => {
refreshData();
const intervalId = setInterval(refreshData, 5000);
return () => clearInterval(intervalId);
}, []);
const refreshData = async () => {
try {
const data = await API.fetchData();
setStock(data.stock);
setOrders(data.orders.sort((a: any, b: any) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()));
if (data.config && data.config.adminCode) {
setAdminCode(data.config.adminCode);
}
setIsLoading(false);
} catch (err) {
console.error("Server connection failed.", err);
}
};
const showNotification = (msg: string) => {
setNotification(msg);
setTimeout(() => setNotification(null), 3000);
};
const promptConfirm = (message: string, action: () => void) => {
setConfirmModal({
show: true,
message,
onConfirm: async () => {
await action();
setConfirmModal({ show: false, message: '', onConfirm: null });
}
});
};
const addToCart = (item: any) => {
setCart(prev => {
const existing = prev.find(i => i.id === item.id);
if (existing) {
return prev.map(i => i.id === item.id ? { ...i, qty: i.qty + 1 } : i);
}
return [...prev, { ...item, qty: 1 }];
});
showNotification(`Added 1${item.unit} ${item.name}`);
};
const updateCartQty = (itemId: number, delta: number) => {
setCart(prev => prev.map(i => {
if (i.id === itemId) {
const newQty = i.qty + delta;
return newQty > 0 ? { ...i, qty: newQty } : i;
}
return i;
}));
};
const removeFromCart = (itemId: number) => {
setCart(prev => prev.filter(i => i.id !== itemId));
};
const submitOrder = async (name: string, phone: string) => {
const getPrice = (item: any, qty: number) => {
if (item.bulk2Threshold > 0 && qty >= item.bulk2Threshold && item.bulk2Price > 0) return item.bulk2Price;
if (item.bulk1Threshold > 0 && qty >= item.bulk1Threshold && item.bulk1Price > 0) return item.bulk1Price;
return item.price;
};
const itemsWithFinalPrice = cart.map(i => ({
...i,
finalPricePerUnit: getPrice(i, i.qty),
lineTotal: getPrice(i, i.qty) * i.qty
}));
const orderData = {
name,
phone,
items: itemsWithFinalPrice,
totalWeight: cart.reduce((acc, i) => acc + i.qty, 0),
estimatedTotal: itemsWithFinalPrice.reduce((acc, i) => acc + i.lineTotal, 0)
};
try {
await API.submitOrder(orderData);
setCart([]);
setShowCartModal(false);
refreshData();
showNotification("Order Request Sent! We'll contact you.");
} catch (error) {
showNotification("Error submitting order.");
}
};
const handleAdminLogin = () => {
if (adminCodeInput === adminCode) {
setView('admin');
setShowAdminLogin(false);
setAdminCodeInput('');
showNotification("Logged in as Admin");
} else {
showNotification("Incorrect Admin Code");
}
};
if (isLoading) return <div className="h-screen w-full flex items-center justify-center bg-stone-900 text-red-600 font-bold text-2xl tracking-widest uppercase animate-pulse">Loading Meat Monster...</div>;
return (
<div className="min-h-screen monster-bg pb-20 text-stone-200">
<header className="bg-stone-950 border-b border-red-900 sticky top-0 z-40 shadow-2xl">
<div className="max-w-6xl mx-auto px-4 py-4 flex justify-between items-center">
<div className="flex items-center gap-3 cursor-pointer" onClick={() => setView('customer')}>
<div className="text-red-600"><Icons.Beast /></div>
<div>
<h1 className="text-3xl font-black text-red-600 leading-none tracking-tighter text-glow italic">MEAT MONSTER</h1>
<p className="text-xs text-stone-500 tracking-[0.2em] font-bold uppercase">Bulk & Wholesale</p>
</div>
</div>
<div className="flex items-center gap-4">
{view === 'customer' && (
<button onClick={() => setShowCartModal(true)} className="relative p-2 text-stone-400 hover:text-red-500 transition-colors">
<Icons.ShoppingCart />
{cart.length > 0 && (
<span className="absolute -top-2 -right-2 bg-red-600 text-white text-xs font-bold w-5 h-5 flex items-center justify-center rounded-full">
{cart.reduce((acc, i) => acc + i.qty, 0)}
</span>
)}
</button>
)}
<button onClick={() => { if (view === 'admin') setView('customer'); else setShowAdminLogin(true); }} className={`p-2 rounded-md transition-colors ${view === 'admin' ? 'bg-red-900/50 text-red-200' : 'text-stone-600 hover:text-stone-400'}`}>
{view === 'admin' ? <Icons.X /> : <Icons.Lock />}
</button>
</div>
</div>
</header>
<main className="max-w-6xl mx-auto px-4 py-8">
{notification && <div className="fixed bottom-4 right-4 bg-red-600 text-white px-6 py-3 rounded-lg shadow-2xl animate-bounce z-50 font-bold tracking-wide border border-red-400">{notification}</div>}
{view === 'customer' ? <CustomerView stock={stock} addToCart={addToCart} /> : <AdminDashboard stock={stock} orders={orders} refreshData={refreshData} notify={showNotification} promptConfirm={promptConfirm} />}
</main>
{showCartModal && <CartModal cart={cart} onClose={() => setShowCartModal(false)} onRemove={removeFromCart} onUpdateQty={updateCartQty} onSubmit={submitOrder} />}
{showAdminLogin && (
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-stone-950 border border-red-900 w-full max-w-xs p-6 rounded-xl shadow-2xl flex flex-col gap-4">
<div className="text-center">
<div className="text-red-600 mx-auto w-10 h-10 mb-2"><Icons.Lock /></div>
<h3 className="text-xl font-bold text-white uppercase">Admin Access</h3>
</div>
<input type="password" placeholder="Enter Code" autoFocus className="bg-stone-900 border border-stone-700 rounded-lg p-3 text-center text-white tracking-widest focus:border-red-600 outline-none" value={adminCodeInput} onChange={(e) => setAdminCodeInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleAdminLogin()} />
<div className="flex gap-2">
<button onClick={() => { setShowAdminLogin(false); setAdminCodeInput(''); }} className="flex-1 bg-stone-800 hover:bg-stone-700 text-stone-400 font-bold py-3 rounded-lg">Cancel</button>
<button onClick={handleAdminLogin} className="flex-1 bg-red-700 hover:bg-red-600 text-white font-bold py-3 rounded-lg">Login</button>
</div>
</div>
</div>
)}
{confirmModal.show && (
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center p-4 z-[60]">
<div className="bg-stone-950 border border-stone-700 w-full max-w-sm p-6 rounded-xl shadow-2xl text-center">
<div className="text-red-500 mx-auto w-10 h-10 mb-4"><Icons.Alert /></div>
<h3 className="text-lg font-bold text-white mb-6">{confirmModal.message}</h3>
<div className="flex gap-2">
<button onClick={() => setConfirmModal({ show: false, message: '', onConfirm: null })} className="flex-1 bg-stone-800 hover:bg-stone-700 text-stone-400 font-bold py-3 rounded-lg">Cancel</button>
<button onClick={() => confirmModal.onConfirm?.()} className="flex-1 bg-red-700 hover:bg-red-600 text-white font-bold py-3 rounded-lg">Confirm</button>
</div>
</div>
</div>
)}
</div>
);
}
export default App;
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated

After

Width:  |  Height:  |  Size: 8.5 KiB

+274
View File
@@ -0,0 +1,274 @@
import React, { useState, useEffect } from 'react';
import { Icons } from './Icons';
import { API } from '../services/api';
export default function AdminDashboard({ stock, orders, refreshData, notify, promptConfirm }: any) {
const [tab, setTab] = useState<'orders' | 'stock' | 'settings'>('orders');
const [newAdminCode, setNewAdminCode] = useState('');
const totalRevenue = orders.reduce((acc: number, order: any) => acc + (order.estimatedTotal || 0), 0);
const totalKg = orders.reduce((acc: number, order: any) => acc + (order.totalWeight || 0), 0);
const activeRequests = orders.length;
const handleUpdatePassword = async () => {
if (newAdminCode.length < 4) {
notify("Code must be at least 4 characters");
return;
}
await API.updateConfig({ adminCode: newAdminCode });
setNewAdminCode('');
notify("Admin Password Updated");
refreshData();
};
const deleteOrder = (id: number) => {
promptConfirm("Delete this order record?", async () => {
await API.deleteOrder(id);
refreshData();
notify("Order Deleted");
});
};
const addNewStockItem = async () => {
const newItem = {
name: "New Meat Item",
category: "Beef",
price: 20,
unit: "kg",
available: false,
description: "Description here",
bulk1Threshold: 0, bulk1Price: 0,
bulk2Threshold: 0, bulk2Price: 0
};
await API.updateStock(newItem);
refreshData();
notify("New Item Added");
};
const updateStockItem = async (id: number, updates: any) => {
const currentItem = stock.find((s: any) => s.id === id) || { id };
await API.updateStock({ ...currentItem, ...updates });
refreshData();
notify("Stock updated");
};
const deleteStockItem = (id: number) => {
promptConfirm("Permanently delete this item from stock?", async () => {
await API.deleteStock(id);
refreshData();
notify("Item deleted");
});
};
const clearAllOrders = () => {
promptConfirm("WARNING: This will delete ALL current order history. Proceed?", async () => {
await API.clearOrders();
refreshData();
notify("All Orders Cleared");
});
};
const resetDatabase = () => {
promptConfirm("Wipe all data and reset to defaults?", async () => {
await API.resetDB();
refreshData();
notify("Database Reset to Defaults");
});
};
return (
<div className="bg-stone-900 rounded-xl border border-stone-800 overflow-hidden min-h-[600px] shadow-2xl">
<div className="flex border-b border-stone-800 bg-stone-950">
<button onClick={() => setTab('orders')} className={`flex-1 py-4 font-bold text-center transition-colors uppercase tracking-wider text-sm ${tab === 'orders' ? 'bg-stone-900 text-red-500 border-t-2 border-red-500' : 'text-stone-600 hover:text-stone-300'}`}>Requests</button>
<button onClick={() => setTab('stock')} className={`flex-1 py-4 font-bold text-center transition-colors uppercase tracking-wider text-sm ${tab === 'stock' ? 'bg-stone-900 text-red-500 border-t-2 border-red-500' : 'text-stone-600 hover:text-stone-300'}`}>Inventory</button>
<button onClick={() => setTab('settings')} className={`flex-1 py-4 font-bold text-center transition-colors uppercase tracking-wider text-sm ${tab === 'settings' ? 'bg-stone-900 text-red-500 border-t-2 border-red-500' : 'text-stone-600 hover:text-stone-300'}`}>Settings</button>
</div>
<div className="p-6">
{tab === 'stock' && (
<div className="space-y-6">
<div className="flex justify-between items-end border-b border-stone-800 pb-4">
<h3 className="text-xl font-bold text-white">Stock Configuration</h3>
<p className="text-xs text-stone-500">Edit names, prices, and descriptions</p>
</div>
<div className="grid gap-4">
{stock.map((item: any) => (
<AdminStockItem key={item.id} item={item} onUpdate={(data: any) => updateStockItem(item.id, data)} onDelete={() => deleteStockItem(item.id)} />
))}
</div>
<div className="flex justify-between items-center pt-10 border-t border-stone-800 mt-10">
<button onClick={addNewStockItem} className="bg-stone-800 hover:bg-stone-700 text-white px-4 py-2 rounded font-bold flex items-center gap-2 border border-stone-700"><Icons.Plus /> Add New Stock Item</button>
</div>
</div>
)}
{tab === 'orders' && (
<div className="space-y-6">
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="bg-[#0c0a09] p-4 rounded border border-stone-800 text-center shadow-lg">
<p className="text-stone-500 text-[10px] md:text-xs uppercase font-bold tracking-widest mb-1">Est. Revenue</p>
<p className="text-2xl md:text-3xl font-black text-green-500">${totalRevenue.toFixed(2)}</p>
</div>
<div className="bg-[#0c0a09] p-4 rounded border border-stone-800 text-center shadow-lg">
<p className="text-stone-500 text-[10px] md:text-xs uppercase font-bold tracking-widest mb-1">Total Prep (kg)</p>
<p className="text-2xl md:text-3xl font-black text-stone-200">{totalKg}<span className="text-lg text-stone-600 ml-1">kg</span></p>
</div>
<div className="bg-[#0c0a09] p-4 rounded border border-stone-800 text-center shadow-lg">
<p className="text-stone-500 text-[10px] md:text-xs uppercase font-bold tracking-widest mb-1">Requests</p>
<p className="text-2xl md:text-3xl font-black text-red-500">{activeRequests}</p>
</div>
</div>
<h3 className="text-xl font-bold text-white mb-4 border-b border-stone-800 pb-2">Incoming List</h3>
{orders.length === 0 ? (
<div className="text-center py-10 bg-stone-950 rounded border border-dashed border-stone-800">
<p className="text-stone-500 italic">No active orders. Time to smoke some meat!</p>
</div>
) : (
<div className="space-y-4">
{orders.map((order: any) => (
<div key={order.id} className="bg-stone-950 p-5 rounded-lg border border-stone-800 relative group hover:border-stone-700 transition-colors shadow-md">
<div className="flex justify-between items-start mb-4">
<div>
<h4 className="font-bold text-lg text-red-400">{order.name}</h4>
<p className="text-sm text-stone-300 font-mono mb-1">{order.phone}</p>
<p className="text-xs text-stone-500">{new Date(order.timestamp).toLocaleString()}</p>
</div>
<button onClick={() => deleteOrder(order.id)} className="text-stone-600 hover:text-red-500 transition-colors bg-stone-900 p-2 rounded"><Icons.Trash /></button>
</div>
<div className="bg-stone-900 rounded p-3 border border-stone-800/50">
{order.items.map((item: any, idx: number) => (
<div key={idx} className="flex justify-between text-sm py-2 border-b border-stone-800 last:border-0">
<div>
<span className="text-stone-300 font-bold block">{item.name}</span>
<span className="text-stone-500 text-xs">{item.qty}{item.unit} @ ${item.finalPricePerUnit || item.price}/{item.unit}</span>
</div>
<span className="text-stone-400 font-mono">${(item.lineTotal || 0).toFixed(2)}</span>
</div>
))}
<div className="flex justify-between mt-3 pt-3 border-t border-stone-800 font-bold">
<span className="text-white">Total Est.</span>
<span className="text-red-500">${order.estimatedTotal?.toFixed(2)}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{tab === 'settings' && (
<div className="max-w-md mx-auto text-center pt-10">
<div className="text-stone-500 mb-4 mx-auto flex justify-center"><Icons.Settings /></div>
<h3 className="text-xl font-bold text-white mb-6">Admin Settings</h3>
<div className="bg-stone-950 p-6 rounded-xl border border-stone-800 mb-8">
<label className="block text-xs font-bold text-stone-500 uppercase mb-2 text-left">Update Admin Password</label>
<input type="text" placeholder="New Code" value={newAdminCode} onChange={(e) => setNewAdminCode(e.target.value)} className="w-full bg-stone-900 border border-stone-700 rounded p-3 text-white mb-4 focus:border-red-600 outline-none" />
<button onClick={handleUpdatePassword} disabled={!newAdminCode} className="w-full bg-red-700 hover:bg-red-600 disabled:bg-stone-800 disabled:text-stone-600 text-white font-bold py-3 rounded transition-colors">Update Password</button>
</div>
<div className="space-y-4 border-t border-stone-800 pt-8">
<p className="text-xs font-bold text-stone-500 uppercase">Danger Zone</p>
<button onClick={clearAllOrders} className="w-full py-3 border border-red-900/50 text-red-500 hover:bg-red-900/20 rounded text-sm font-bold transition-colors">Clear All Order History</button>
<button onClick={resetDatabase} className="text-xs text-stone-600 hover:text-red-500 underline block w-full">Reset Entire Database to Defaults</button>
</div>
</div>
)}
</div>
</div>
);
}
function AdminStockItem({ item, onUpdate, onDelete }: any) {
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState<any>({});
useEffect(() => {
setEditData({
name: item.name || "",
category: item.category || "",
description: item.description || "",
price: item.price || 0,
unit: item.unit || "kg",
bulk1Threshold: item.bulk1Threshold || 0,
bulk1Price: item.bulk1Price || 0,
bulk2Threshold: item.bulk2Threshold || 0,
bulk2Price: item.bulk2Price || 0
});
}, [item]);
const handleSave = () => {
onUpdate({
...editData,
price: parseFloat(editData.price),
bulk1Threshold: parseFloat(editData.bulk1Threshold),
bulk1Price: parseFloat(editData.bulk1Price),
bulk2Threshold: parseFloat(editData.bulk2Threshold),
bulk2Price: parseFloat(editData.bulk2Price)
});
setIsEditing(false);
};
return (
<div className="bg-stone-950 p-4 rounded-lg border border-stone-800 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
{!isEditing ? (
<div className="flex-1">
<p className="font-bold text-white text-lg">{item.name}</p>
<p className="text-xs text-stone-500 italic">{item.description}</p>
</div>
) : (
<div className="flex-1 flex flex-col gap-2 mr-4">
<input type="text" value={editData.name} onChange={e => setEditData({ ...editData, name: e.target.value })} className="bg-black text-white border border-stone-700 rounded px-2 py-1 font-bold w-full" placeholder="Item Name" />
<textarea value={editData.description} onChange={e => setEditData({ ...editData, description: e.target.value })} className="bg-black text-stone-400 border border-stone-700 rounded px-2 py-1 text-xs w-full h-16" placeholder="Description" />
</div>
)}
{!isEditing && <span className={`text-[10px] px-1.5 py-0.5 rounded font-bold uppercase ${item.available ? 'bg-green-900 text-green-400' : 'bg-red-900 text-red-400'}`}>{item.available ? 'Active' : 'Disabled'}</span>}
</div>
<div className="flex items-center gap-2">
{isEditing ? (
<>
<button onClick={handleSave} className="p-2 bg-green-600 hover:bg-green-500 text-white rounded"><Icons.Save /></button>
<button onClick={onDelete} className="p-2 bg-red-900 hover:bg-red-700 text-white rounded"><Icons.Trash /></button>
</>
) : (
<button onClick={() => setIsEditing(true)} className="p-2 bg-stone-800 hover:bg-stone-700 text-stone-400 hover:text-white rounded"><Icons.Edit /></button>
)}
<button onClick={() => onUpdate({ available: !item.available })} className={`px-3 py-2 rounded font-bold text-xs uppercase w-24 text-center ${item.available ? 'bg-stone-800 text-red-400 border border-red-900/30 hover:bg-red-900/20' : 'bg-green-900/20 text-green-400 border border-green-900/30 hover:bg-green-900/40'}`}>{item.available ? 'Sold Out' : 'Activate'}</button>
</div>
</div>
{!isEditing ? (
<div className="text-sm text-stone-500 flex flex-wrap gap-4">
<span className="bg-stone-900 px-2 py-1 rounded">Standard: <b className="text-stone-300">${item.price}/{item.unit}</b></span>
{item.bulk1Threshold > 0 && <span className="bg-stone-900 px-2 py-1 rounded text-yellow-600">Tier 1: <b className="text-yellow-500">${item.bulk1Price}</b> @ {item.bulk1Threshold}{item.unit}+</span>}
{item.bulk2Threshold > 0 && <span className="bg-stone-900 px-2 py-1 rounded text-green-600">Tier 2: <b className="text-green-500">${item.bulk2Price}</b> @ {item.bulk2Threshold}{item.unit}+</span>}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 bg-stone-900 p-3 rounded border border-stone-700">
<div className="space-y-2">
<p className="text-xs font-bold text-stone-400 uppercase">Base Info</p>
<label className="block text-[10px] text-stone-500">Price ($)</label>
<input type="number" value={editData.price} onChange={e => setEditData({ ...editData, price: e.target.value })} className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" />
<label className="block text-[10px] text-stone-500">Unit (kg/ea)</label>
<input type="text" value={editData.unit} onChange={e => setEditData({ ...editData, unit: e.target.value })} className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" />
</div>
<div className="space-y-2 border-l border-stone-800 pl-4">
<p className="text-xs font-bold text-yellow-500 uppercase">Tier 1 Bulk</p>
<label className="block text-[10px] text-stone-500">Threshold ({editData.unit})</label>
<input type="number" value={editData.bulk1Threshold} onChange={e => setEditData({ ...editData, bulk1Threshold: e.target.value })} className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" />
<label className="block text-[10px] text-stone-500">Price ($)</label>
<input type="number" value={editData.bulk1Price} onChange={e => setEditData({ ...editData, bulk1Price: e.target.value })} className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" />
</div>
<div className="space-y-2 border-l border-stone-800 pl-4">
<p className="text-xs font-bold text-green-500 uppercase">Tier 2 Super Bulk</p>
<label className="block text-[10px] text-stone-500">Threshold ({editData.unit})</label>
<input type="number" value={editData.bulk2Threshold} onChange={e => setEditData({ ...editData, bulk2Threshold: e.target.value })} className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" />
<label className="block text-[10px] text-stone-500">Price ($)</label>
<input type="number" value={editData.bulk2Price} onChange={e => setEditData({ ...editData, bulk2Price: e.target.value })} className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" />
</div>
</div>
)}
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import React, { useState } from 'react';
import { Icons } from './Icons';
interface CartItem {
id: number;
name: string;
qty: number;
price: number;
unit: string;
bulk1Threshold: number;
bulk1Price: number;
bulk2Threshold: number;
bulk2Price: number;
}
export default function CartModal({ cart, onClose, onRemove, onUpdateQty, onSubmit }: { cart: CartItem[], onClose: () => void, onRemove: (id: number) => void, onUpdateQty: (id: number, delta: number) => void, onSubmit: (name: string, phone: string) => void }) {
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const getPrice = (item: CartItem, qty: number) => {
if (item.bulk2Threshold > 0 && qty >= item.bulk2Threshold && item.bulk2Price > 0) return item.bulk2Price;
if (item.bulk1Threshold > 0 && qty >= item.bulk1Threshold && item.bulk1Price > 0) return item.bulk1Price;
return item.price;
};
const total = cart.reduce((acc, item) => {
const price = getPrice(item, item.qty);
return acc + (price * item.qty);
}, 0);
const totalWeight = cart.reduce((acc, i) => acc + i.qty, 0);
return (
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-[#1c1917] border border-stone-700 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="p-6 border-b border-stone-800 flex justify-between items-center bg-[#0c0a09]">
<h2 className="text-xl font-black italic text-white flex items-center gap-2 text-glow uppercase"><Icons.Beast /> Your Order</h2>
<button onClick={onClose} className="text-stone-500 hover:text-white"><Icons.X /></button>
</div>
<div className="p-6 overflow-y-auto flex-1 custom-scrollbar">
{cart.length === 0 ? (
<div className="text-center py-10 text-stone-500">
<p>Feed the beast. Add some meat.</p>
<button onClick={onClose} className="mt-4 text-red-500 font-bold hover:underline">Back to Menu</button>
</div>
) : (
<div className="space-y-4">
{cart.map(item => {
const currentPrice = getPrice(item, item.qty);
const isTier2 = item.bulk2Threshold > 0 && item.qty >= item.bulk2Threshold;
const isTier1 = !isTier2 && item.bulk1Threshold > 0 && item.qty >= item.bulk1Threshold;
let nextTierMsg = null;
if (!isTier2 && item.bulk2Threshold > 0) {
const diff = item.bulk2Threshold - item.qty;
if (diff > 0) nextTierMsg = `Add ${diff}${item.unit} for Tier 2 ($${item.bulk2Price})`;
}
if (!isTier1 && !isTier2 && item.bulk1Threshold > 0 && (!nextTierMsg || (item.bulk1Threshold - item.qty) < (item.bulk2Threshold - item.qty))) {
const diff = item.bulk1Threshold - item.qty;
if (diff > 0) nextTierMsg = `Add ${diff}${item.unit} for Tier 1 ($${item.bulk1Price})`;
}
return (
<div key={item.id} className="flex flex-col gap-2 pb-4 border-b border-stone-800 last:border-0">
<div className="flex items-center gap-4">
<div className="flex-1">
<h4 className="font-bold text-stone-200 text-lg">{item.name}</h4>
<div className="text-sm">
<span className={(isTier1 || isTier2) ? "text-green-500 font-bold" : "text-stone-500"}>
${currentPrice}/{item.unit}
</span>
{(isTier1 || isTier2) && <span className="text-xs text-stone-500 line-through ml-2">${item.price}</span>}
</div>
</div>
<div className="flex items-center bg-stone-950 rounded-lg border border-stone-800">
<button onClick={() => onUpdateQty(item.id, -1)} className="p-3 text-stone-400 hover:text-white"><Icons.Minus /></button>
<span className="w-12 text-center font-mono text-white font-bold">{item.qty}{item.unit}</span>
<button onClick={() => onUpdateQty(item.id, 1)} className="p-3 text-stone-400 hover:text-white"><Icons.Plus /></button>
</div>
<button onClick={() => onRemove(item.id)} className="text-stone-600 hover:text-red-500 p-2"><Icons.Trash /></button>
</div>
{nextTierMsg && (
<div className="text-xs text-stone-500 bg-stone-900 py-1 px-2 rounded flex justify-between items-center">
<span>{nextTierMsg}</span>
<Icons.Scale />
</div>
)}
{isTier1 && !isTier2 && <div className="text-xs text-yellow-500 font-bold flex items-center gap-1"><Icons.Check /> Tier 1 Applied!</div>}
{isTier2 && <div className="text-xs text-green-500 font-bold flex items-center gap-1"><Icons.Check /> Tier 2 Super Bulk Applied!</div>}
</div>
);
})}
</div>
)}
</div>
{cart.length > 0 && (
<div className="p-6 bg-[#0c0a09] border-t border-stone-800">
<div className="flex justify-between mb-6 text-xl font-bold">
<span className="text-stone-400">Total Est. ({totalWeight}kg)</span>
<span className="text-red-500 text-2xl">${total.toFixed(2)}</span>
</div>
<div className="space-y-4">
<div className="text-xs bg-stone-950 p-3 rounded border border-stone-800 text-stone-400">
<p className="font-bold text-red-500 mb-1 uppercase">Payment Required</p>
<p>By submitting, you agree to pay upfront via cash. Delivery/Pickup will be negotiated upon confirmation.</p>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-bold text-stone-500 uppercase mb-1">Name</label>
<input type="text" required placeholder="Your Name" value={name} onChange={(e) => setName(e.target.value)} className="w-full bg-stone-900 border border-stone-700 rounded-lg p-4 text-white focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-all" />
</div>
<div>
<label className="block text-xs font-bold text-stone-500 uppercase mb-1">Phone Number</label>
<input type="tel" required placeholder="0400 000 000" value={phone} onChange={(e) => setPhone(e.target.value)} className="w-full bg-stone-900 border border-stone-700 rounded-lg p-4 text-white focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-all" />
</div>
</div>
<button onClick={() => onSubmit(name, phone)} disabled={!name || !phone} className="w-full bg-red-700 hover:bg-red-600 disabled:bg-stone-800 disabled:text-stone-600 text-white font-black uppercase tracking-widest py-4 rounded-lg transition-all shadow-lg shadow-red-900/20 active:scale-[0.98]">Submit Order</button>
</div>
</div>
)}
</div>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import React from 'react';
import { Icons } from './Icons';
interface StockItem {
id: number;
name: string;
category: string;
price: number;
unit: string;
available: boolean;
description: string;
bulk1Threshold: number;
bulk1Price: number;
bulk2Threshold: number;
bulk2Price: number;
}
function StockCard({ item, onAdd, disabled }: { item: StockItem, onAdd?: () => void, disabled?: boolean }) {
const hasTier1 = item.bulk1Threshold > 0 && item.bulk1Price > 0;
const hasTier2 = item.bulk2Threshold > 0 && item.bulk2Price > 0;
return (
<div className="bg-[#131110] border border-stone-800 rounded-xl overflow-hidden hover:border-red-900/50 transition-all group flex flex-col shadow-lg hover:shadow-red-900/10">
<div className="p-6 flex-1 relative">
<div className="flex justify-between items-start mb-3">
<span className="text-[10px] font-bold text-stone-500 uppercase tracking-[0.15em] border border-stone-800 px-2 py-1 rounded bg-stone-900">{item.category}</span>
</div>
<h4 className="text-2xl font-black text-stone-100 mb-1 uppercase italic">{item.name}</h4>
<div className="flex items-baseline gap-1 mb-3">
<span className="text-xl font-bold text-red-500">${item.price}</span>
<span className="text-sm text-stone-500 font-bold">/ {item.unit}</span>
</div>
<div className="space-y-1 mb-4">
{hasTier1 && (
<div className="text-xs font-bold text-stone-400 bg-stone-900/50 border border-stone-800 px-2 py-1 rounded inline-block mr-2">
Buy {item.bulk1Threshold}{item.unit}+: <span className="text-green-500">${item.bulk1Price}</span>
</div>
)}
{hasTier2 && (
<div className="text-xs font-bold text-stone-400 bg-stone-900/50 border border-stone-800 px-2 py-1 rounded inline-block">
Buy {item.bulk2Threshold}{item.unit}+: <span className="text-green-400">${item.bulk2Price}</span>
</div>
)}
</div>
<p className="text-stone-400 text-sm leading-relaxed">{item.description}</p>
</div>
<div className="p-4 bg-stone-950 border-t border-stone-800">
<button
onClick={onAdd}
disabled={disabled}
className={`w-full py-3 rounded-lg font-bold uppercase tracking-wider flex items-center justify-center gap-2 transition-all active:scale-95 ${
disabled
? 'bg-stone-900 text-stone-700 cursor-not-allowed border border-stone-800'
: 'bg-red-700 hover:bg-red-600 text-white shadow-lg shadow-red-900/30'
}`}
>
{disabled ? 'Sold Out' : <><Icons.Plus /> Add to Order</>}
</button>
</div>
</div>
);
}
export default function CustomerView({ stock, addToCart }: { stock: StockItem[], addToCart: (item: StockItem) => void }) {
const availableStock = stock.filter(item => item.available);
const soldOutStock = stock.filter(item => !item.available);
return (
<div className="space-y-8">
<div className="text-center space-y-6 py-10 bg-gradient-to-b from-stone-900/0 to-stone-900/20 rounded-3xl border border-stone-800/50">
<h2 className="text-5xl md:text-6xl font-black text-white uppercase italic tracking-tight">Feed the <span className="text-red-600">Beast</span></h2>
<p className="text-stone-400 max-w-2xl mx-auto text-lg font-light">
Premium smoked meats by the Kilogram. <br/>
<span className="text-red-400 font-bold">Bulk discounts</span> applied automatically at checkout.
</p>
</div>
<div className="bg-red-900/10 border border-red-900/40 p-5 rounded-lg flex items-start gap-3 max-w-3xl mx-auto shadow-[0_0_15px_rgba(220,38,38,0.1)]">
<div className="text-red-500 mt-1 flex-shrink-0"><Icons.Alert /></div>
<div>
<p className="text-red-400 font-bold uppercase tracking-wider text-sm mb-1">Terms of Service</p>
<p className="text-stone-300 text-sm leading-relaxed">
<span className="text-white font-bold underline">Cash payment is required up front</span> before any smoking begins to cover material costs.
Delivery or pickup options can be negotiated directly with us after your request is confirmed.
</p>
</div>
</div>
<div className="pt-4">
<h3 className="text-2xl font-bold text-stone-300 mb-6 flex items-center gap-2"><Icons.Beast /> In The Smoker</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{availableStock.map(item => (
<StockCard key={item.id} item={item} onAdd={() => addToCart(item)} />
))}
{availableStock.length === 0 && <p className="text-stone-500 italic col-span-full text-center py-10 border border-dashed border-stone-800 rounded-xl">Nothing in the smoker right now.</p>}
</div>
</div>
{soldOutStock.length > 0 && (
<div className="opacity-50 grayscale hover:grayscale-0 transition-all duration-500">
<h3 className="text-2xl font-bold text-stone-600 mb-6 border-b border-stone-800 pb-2">Sold Out</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{soldOutStock.map(item => (
<StockCard key={item.id} item={item} disabled={true} />
))}
</div>
</div>
)}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
export const Icons = {
Beast: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-8 h-8"><path d="M12 2a5 5 0 0 0-5 5v2a5 5 0 0 0 10 0V7a5 5 0 0 0-5-5z"/><path d="M12 14a5 5 0 0 0-5 5v2h10v-2a5 5 0 0 0-5-5z"/><path d="M8 7h8"/><path d="M12 2v5"/></svg>,
ShoppingCart: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-6 h-6"><circle cx="8" cy="21" r="1"/><circle cx="19" cy="21" r="1"/><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/></svg>,
Lock: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-6 h-6"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>,
Plus: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>,
Minus: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><line x1="5" y1="12" x2="19" y2="12"/></svg>,
Trash: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>,
X: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>,
Edit: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>,
Save: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>,
Scale: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/><path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/><path d="M7 21h10"/><path d="M12 3v18"/><path d="M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/></svg>,
Settings: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>,
Alert: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-6 h-6"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>,
Check: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><polyline points="20 6 9 17 4 12"/></svg>
};
+24
View File
@@ -0,0 +1,24 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-stone-900 text-stone-200;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
}
.monster-bg {
background: radial-gradient(circle at 50% 50%, #292524 0%, #0c0a09 100%);
}
.text-glow {
text-shadow: 0 0 10px rgba(220, 38, 38, 0.5);
}
/* Custom Scrollbar */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #1c1917; }
::-webkit-scrollbar-thumb { background: #dc2626; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #b91c1c; }
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+24
View File
@@ -0,0 +1,24 @@
export const API_BASE = import.meta.env.VITE_API_URL || '';
export const API = {
fetchData: () => fetch(`${API_BASE}/api/data`).then(res => res.json()),
submitOrder: (order: any) => fetch(`${API_BASE}/api/orders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order)
}).then(res => res.json()),
deleteOrder: (id: number) => fetch(`${API_BASE}/api/orders/${id}`, { method: 'DELETE' }),
clearOrders: () => fetch(`${API_BASE}/api/orders`, { method: 'DELETE' }),
updateStock: (item: any) => fetch(`${API_BASE}/api/stock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item)
}).then(res => res.json()),
deleteStock: (id: number) => fetch(`${API_BASE}/api/stock/${id}`, { method: 'DELETE' }),
updateConfig: (cfg: any) => fetch(`${API_BASE}/api/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg)
}),
resetDB: () => fetch(`${API_BASE}/api/reset`, { method: 'POST' })
};
+18
View File
@@ -0,0 +1,18 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
stone: {
900: '#1c1917',
950: '#0c0a09',
}
}
},
},
plugins: [],
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
+966
View File
@@ -0,0 +1,966 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meat Monster - Bulk Smoked Meats</title>
<!-- React & ReactDOM -->
<script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
<!-- Babel for JSX -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
background-color: #1c1917; /* stone-900 */
color: #e7e5e4; /* stone-200 */
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.monster-bg {
background: radial-gradient(circle at 50% 50%, #292524 0%, #0c0a09 100%);
}
/* Custom Scrollbar */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #1c1917; }
::-webkit-scrollbar-thumb { background: #dc2626; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #b91c1c; }
.text-glow {
text-shadow: 0 0 10px rgba(220, 38, 38, 0.5);
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
// --- ICONS ---
const Icons = {
Beast: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-8 h-8"><path d="M12 2a5 5 0 0 0-5 5v2a5 5 0 0 0 10 0V7a5 5 0 0 0-5-5z"/><path d="M12 14a5 5 0 0 0-5 5v2h10v-2a5 5 0 0 0-5-5z"/><path d="M8 7h8"/><path d="M12 2v5"/></svg>,
ShoppingCart: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-6 h-6"><circle cx="8" cy="21" r="1"/><circle cx="19" cy="21" r="1"/><path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/></svg>,
Lock: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-6 h-6"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>,
Plus: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>,
Minus: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><line x1="5" y1="12" x2="19" y2="12"/></svg>,
Trash: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>,
X: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>,
Edit: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>,
Save: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>,
Scale: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/><path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/><path d="M7 21h10"/><path d="M12 3v18"/><path d="M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"/></svg>,
Settings: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>,
Alert: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-6 h-6"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>,
Check: () => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-5 h-5"><polyline points="20 6 9 17 4 12"/></svg>
};
// --- HELPERS ---
const getPrice = (item, qty) => {
if (item.bulk2Threshold > 0 && qty >= item.bulk2Threshold && item.bulk2Price > 0) {
return item.bulk2Price;
}
if (item.bulk1Threshold > 0 && qty >= item.bulk1Threshold && item.bulk1Price > 0) {
return item.bulk1Price;
}
return item.price;
};
// --- API CLIENT ---
// All calls point to the Node.js server running on localhost:3000
const API = {
fetchData: () => fetch('/api/data').then(res => res.json()),
submitOrder: (order) => fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order)
}).then(res => res.json()),
deleteOrder: (id) => fetch(`/api/orders/${id}`, { method: 'DELETE' }),
clearOrders: () => fetch('/api/orders', { method: 'DELETE' }),
updateStock: (item) => fetch('/api/stock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item)
}),
deleteStock: (id) => fetch(`/api/stock/${id}`, { method: 'DELETE' }),
updateConfig: (cfg) => fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg)
}),
resetDB: () => fetch('/api/reset', { method: 'POST' })
};
// --- APP ---
const { useState, useEffect } = React;
function App() {
const [view, setView] = useState('customer');
const [cart, setCart] = useState([]);
const [stock, setStock] = useState([]);
const [orders, setOrders] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [showCartModal, setShowCartModal] = useState(false);
const [showAdminLogin, setShowAdminLogin] = useState(false);
const [adminCodeInput, setAdminCodeInput] = useState('');
const [notification, setNotification] = useState(null);
const [adminCode, setAdminCode] = useState('1234');
const [confirmModal, setConfirmModal] = useState({ show: false, message: '', onConfirm: null });
// Initial Load and Polling
useEffect(() => {
refreshData();
// Set up polling for real-time feel (check every 5 seconds)
const intervalId = setInterval(refreshData, 5000);
return () => clearInterval(intervalId);
}, []);
const refreshData = async () => {
try {
const data = await API.fetchData();
setStock(data.stock);
setOrders(data.orders.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)));
if (data.config && data.config.adminCode) {
setAdminCode(data.config.adminCode);
}
setIsLoading(false);
} catch (err) {
if (isLoading) {
// Display error when first trying to load if server is down
console.error("Server connection failed. Ensure Node.js server.js is running.");
}
}
};
const showNotification = (msg) => {
setNotification(msg);
setTimeout(() => setNotification(null), 3000);
};
const promptConfirm = (message, action) => {
setConfirmModal({
show: true,
message,
onConfirm: async () => {
await action();
setConfirmModal({ show: false, message: '', onConfirm: null });
}
});
};
// --- ACTIONS ---
const addToCart = (item) => {
setCart(prev => {
const existing = prev.find(i => i.id === item.id);
if (existing) {
return prev.map(i => i.id === item.id ? { ...i, qty: i.qty + 1 } : i);
}
return [...prev, { ...item, qty: 1 }];
});
showNotification(`Added 1${item.unit} ${item.name}`);
};
const removeFromCart = (itemId) => {
setCart(prev => prev.filter(i => i.id !== itemId));
};
const updateCartQty = (itemId, delta) => {
setCart(prev => prev.map(i => {
if (i.id === itemId) {
const newQty = i.qty + delta;
return newQty > 0 ? { ...i, qty: newQty } : i;
}
return i;
}));
};
const submitOrder = async (name, phone) => {
if (!name || !phone || cart.length === 0) return;
const itemsWithFinalPrice = cart.map(i => ({
...i,
finalPricePerUnit: getPrice(i, i.qty),
lineTotal: getPrice(i, i.qty) * i.qty
}));
const orderData = {
name: name,
phone: phone,
items: itemsWithFinalPrice,
totalWeight: cart.reduce((acc, i) => acc + i.qty, 0),
estimatedTotal: itemsWithFinalPrice.reduce((acc, i) => acc + i.lineTotal, 0)
};
try {
await API.submitOrder(orderData);
setCart([]);
setShowCartModal(false);
refreshData();
showNotification("Order Request Sent! We'll contact you.");
} catch (error) {
showNotification("Error: Could not submit order. Server connection failed.");
console.error("Order submission error:", error);
}
};
// --- ADMIN ACTIONS ---
const updateStockItem = async (id, updates) => {
const currentItem = stock.find(s => s.id === id) || { id };
const mergedItem = { ...currentItem, ...updates };
await API.updateStock(mergedItem);
refreshData();
showNotification("Stock updated");
};
const addNewStockItem = async () => {
const newItem = {
name: "New Meat Item",
category: "Beef",
price: 20,
unit: "kg",
available: false,
description: "Description here",
bulk1Threshold: 0, bulk1Price: 0,
bulk2Threshold: 0, bulk2Price: 0
};
await API.updateStock(newItem); // Server assigns ID if missing
refreshData();
showNotification("New Item Added");
};
const deleteStockItem = (id) => {
promptConfirm("Permanently delete this item from stock?", async () => {
await API.deleteStock(id);
refreshData();
showNotification("Item deleted");
});
};
const deleteOrder = (id) => {
promptConfirm("Delete this order record?", async () => {
await API.deleteOrder(id);
refreshData();
showNotification("Order Deleted");
});
};
const updateAdminCode = async (newCode) => {
await API.updateConfig({ adminCode: newCode });
refreshData();
showNotification("Admin Password Updated");
};
const resetDatabase = () => {
promptConfirm("Wipe all data and reset to defaults?", async () => {
await API.resetDB();
refreshData();
showNotification("Database Reset to Defaults");
});
};
const clearAllOrders = () => {
promptConfirm("WARNING: This will delete ALL current order history. Proceed?", async () => {
await API.clearOrders();
refreshData();
showNotification("All Orders Cleared");
});
};
const handleAdminLogin = () => {
if(adminCodeInput === adminCode) {
setView('admin');
setShowAdminLogin(false);
setAdminCodeInput('');
showNotification("Logged in as Admin");
} else {
showNotification("Incorrect Admin Code");
}
};
if (isLoading) return <div className="h-screen w-full flex items-center justify-center bg-stone-900 text-red-600 font-bold text-2xl tracking-widest uppercase animate-pulse">Loading Meat Monster...</div>;
return (
<div className="min-h-screen monster-bg pb-20 text-stone-200">
{/* Header */}
<header className="bg-stone-950 border-b border-red-900 sticky top-0 z-40 shadow-2xl">
<div className="max-w-6xl mx-auto px-4 py-4 flex justify-between items-center">
<div className="flex items-center gap-3 cursor-pointer" onClick={() => setView('customer')}>
<div className="text-red-600">
<Icons.Beast />
</div>
<div>
<h1 className="text-3xl font-black text-red-600 leading-none tracking-tighter text-glow italic">MEAT MONSTER</h1>
<p className="text-xs text-stone-500 tracking-[0.2em] font-bold uppercase">Bulk & Wholesale</p>
</div>
</div>
<div className="flex items-center gap-4">
{view === 'customer' && (
<button
onClick={() => setShowCartModal(true)}
className="relative p-2 text-stone-400 hover:text-red-500 transition-colors"
>
<Icons.ShoppingCart />
{cart.length > 0 && (
<span className="absolute -top-2 -right-2 bg-red-600 text-white text-xs font-bold w-5 h-5 flex items-center justify-center rounded-full">
{cart.reduce((acc, i) => acc + i.qty, 0)}
</span>
)}
</button>
)}
<button
onClick={() => {
if (view === 'admin') setView('customer');
else {
setShowAdminLogin(true);
}
}}
className={`p-2 rounded-md transition-colors ${view === 'admin' ? 'bg-red-900/50 text-red-200' : 'text-stone-600 hover:text-stone-400'}`}
>
{view === 'admin' ? <Icons.X /> : <Icons.Lock />}
</button>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-6xl mx-auto px-4 py-8">
{notification && (
<div className="fixed bottom-4 right-4 bg-red-600 text-white px-6 py-3 rounded-lg shadow-2xl animate-bounce z-50 font-bold tracking-wide border border-red-400">
{notification}
</div>
)}
{view === 'customer' ? (
<CustomerView stock={stock} addToCart={addToCart} />
) : (
<AdminDashboard
stock={stock}
orders={orders}
updateStockItem={updateStockItem}
deleteOrder={deleteOrder}
deleteStockItem={deleteStockItem}
addNewStockItem={addNewStockItem}
onResetDB={resetDatabase}
updateAdminCode={updateAdminCode}
clearAllOrders={clearAllOrders}
notify={showNotification}
/>
)}
</main>
{/* Cart Modal */}
{showCartModal && (
<CartModal
cart={cart}
onClose={() => setShowCartModal(false)}
onRemove={removeFromCart}
onUpdateQty={updateCartQty}
onSubmit={submitOrder}
/>
)}
{/* Admin Login Modal */}
{showAdminLogin && (
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-stone-950 border border-red-900 w-full max-w-xs p-6 rounded-xl shadow-2xl flex flex-col gap-4">
<div className="text-center">
<div className="text-red-600 mx-auto w-10 h-10 mb-2"><Icons.Lock /></div>
<h3 className="text-xl font-bold text-white uppercase">Admin Access</h3>
</div>
<input
type="password"
placeholder="Enter Code"
autoFocus
className="bg-stone-900 border border-stone-700 rounded-lg p-3 text-center text-white tracking-widest focus:border-red-600 outline-none"
value={adminCodeInput}
onChange={(e) => setAdminCodeInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdminLogin()}
/>
<div className="flex gap-2">
<button onClick={() => {setShowAdminLogin(false); setAdminCodeInput('');}} className="flex-1 bg-stone-800 hover:bg-stone-700 text-stone-400 font-bold py-3 rounded-lg">Cancel</button>
<button onClick={handleAdminLogin} className="flex-1 bg-red-700 hover:bg-red-600 text-white font-bold py-3 rounded-lg">Login</button>
</div>
</div>
</div>
)}
{/* Confirmation Modal */}
{confirmModal.show && (
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center p-4 z-[60]">
<div className="bg-stone-950 border border-stone-700 w-full max-w-sm p-6 rounded-xl shadow-2xl text-center">
<div className="text-red-500 mx-auto w-10 h-10 mb-4"><Icons.Alert /></div>
<h3 className="text-lg font-bold text-white mb-6">{confirmModal.message}</h3>
<div className="flex gap-2">
<button
onClick={() => setConfirmModal({show: false, message: '', onConfirm: null})}
className="flex-1 bg-stone-800 hover:bg-stone-700 text-stone-400 font-bold py-3 rounded-lg"
>
Cancel
</button>
<button
onClick={confirmModal.onConfirm}
className="flex-1 bg-red-700 hover:bg-red-600 text-white font-bold py-3 rounded-lg"
>
Confirm
</button>
</div>
</div>
</div>
)}
</div>
);
}
// --- SUB COMPONENTS ---
function CustomerView({ stock, addToCart }) {
const availableStock = stock.filter(item => item.available);
const soldOutStock = stock.filter(item => !item.available);
return (
<div className="space-y-8">
{/* Hero */}
<div className="text-center space-y-6 py-10 bg-gradient-to-b from-stone-900/0 to-stone-900/20 rounded-3xl border border-stone-800/50">
<h2 className="text-5xl md:text-6xl font-black text-white uppercase italic tracking-tight">Feed the <span className="text-red-600">Beast</span></h2>
<p className="text-stone-400 max-w-2xl mx-auto text-lg font-light">
Premium smoked meats by the Kilogram. <br/>
<span className="text-red-400 font-bold">Bulk discounts</span> applied automatically at checkout.
</p>
</div>
{/* Payment/Delivery Notice */}
<div className="bg-red-900/10 border border-red-900/40 p-5 rounded-lg flex items-start gap-3 max-w-3xl mx-auto shadow-[0_0_15px_rgba(220,38,38,0.1)]">
<div className="text-red-500 mt-1 flex-shrink-0"><Icons.Alert /></div>
<div>
<p className="text-red-400 font-bold uppercase tracking-wider text-sm mb-1">Terms of Service</p>
<p className="text-stone-300 text-sm leading-relaxed">
<span className="text-white font-bold underline">Cash payment is required up front</span> before any smoking begins to cover material costs.
Delivery or pickup options can be negotiated directly with us after your request is confirmed.
</p>
</div>
</div>
{/* Menu Grid */}
<div className="pt-4">
<h3 className="text-2xl font-bold text-stone-300 mb-6 flex items-center gap-2"><Icons.Beast /> In The Smoker</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{availableStock.map(item => (
<StockCard key={item.id} item={item} onAdd={() => addToCart(item)} />
))}
{availableStock.length === 0 && <p className="text-stone-500 italic col-span-full text-center py-10 border border-dashed border-stone-800 rounded-xl">Nothing in the smoker right now.</p>}
</div>
</div>
{/* Sold Out Section */}
{soldOutStock.length > 0 && (
<div className="opacity-50 grayscale hover:grayscale-0 transition-all duration-500">
<h3 className="text-2xl font-bold text-stone-600 mb-6 border-b border-stone-800 pb-2">Sold Out</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{soldOutStock.map(item => (
<StockCard key={item.id} item={item} disabled={true} />
))}
</div>
</div>
)}
</div>
);
}
function StockCard({ item, onAdd, disabled }) {
const hasTier1 = item.bulk1Threshold > 0 && item.bulk1Price > 0;
const hasTier2 = item.bulk2Threshold > 0 && item.bulk2Price > 0;
return (
<div className="bg-[#131110] border border-stone-800 rounded-xl overflow-hidden hover:border-red-900/50 transition-all group flex flex-col shadow-lg hover:shadow-red-900/10">
<div className="p-6 flex-1 relative">
{/* Category Tag */}
<div className="flex justify-between items-start mb-3">
<span className="text-[10px] font-bold text-stone-500 uppercase tracking-[0.15em] border border-stone-800 px-2 py-1 rounded bg-stone-900">{item.category}</span>
</div>
<h4 className="text-2xl font-black text-stone-100 mb-1 uppercase italic">{item.name}</h4>
<div className="flex items-baseline gap-1 mb-3">
<span className="text-xl font-bold text-red-500">${item.price}</span>
<span className="text-sm text-stone-500 font-bold">/ kg</span>
</div>
<div className="space-y-1 mb-4">
{hasTier1 && (
<div className="text-xs font-bold text-stone-400 bg-stone-900/50 border border-stone-800 px-2 py-1 rounded inline-block mr-2">
Buy {item.bulk1Threshold}kg+: <span className="text-green-500">${item.bulk1Price}</span>
</div>
)}
{hasTier2 && (
<div className="text-xs font-bold text-stone-400 bg-stone-900/50 border border-stone-800 px-2 py-1 rounded inline-block">
Buy {item.bulk2Threshold}kg+: <span className="text-green-400">${item.bulk2Price}</span>
</div>
)}
</div>
<p className="text-stone-400 text-sm leading-relaxed">{item.description}</p>
</div>
<div className="p-4 bg-stone-950 border-t border-stone-800">
<button
onClick={onAdd}
disabled={disabled}
className={`w-full py-3 rounded-lg font-bold uppercase tracking-wider flex items-center justify-center gap-2 transition-all active:scale-95 ${
disabled
? 'bg-stone-900 text-stone-700 cursor-not-allowed border border-stone-800'
: 'bg-red-700 hover:bg-red-600 text-white shadow-lg shadow-red-900/30'
}`}
>
{disabled ? 'Sold Out' : <><Icons.Plus /> Add to Order</>}
</button>
</div>
</div>
);
}
// --- ADMIN ---
function AdminDashboard({ stock, orders, updateStockItem, deleteOrder, addNewStockItem, deleteStockItem, onResetDB, updateAdminCode, clearAllOrders, notify }) {
const [tab, setTab] = useState('orders');
const [newAdminCode, setNewAdminCode] = useState('');
// Stats Calculations
const totalRevenue = orders.reduce((acc, order) => acc + (order.estimatedTotal || 0), 0);
const totalKg = orders.reduce((acc, order) => acc + (order.totalWeight || 0), 0);
const activeRequests = orders.length;
const handleUpdatePassword = () => {
if(newAdminCode.length < 4) {
notify("Code must be at least 4 characters");
return;
}
updateAdminCode(newAdminCode);
setNewAdminCode('');
};
return (
<div className="bg-stone-900 rounded-xl border border-stone-800 overflow-hidden min-h-[600px] shadow-2xl">
<div className="flex border-b border-stone-800 bg-stone-950">
<button
onClick={() => setTab('orders')}
className={`flex-1 py-4 font-bold text-center transition-colors uppercase tracking-wider text-sm ${tab === 'orders' ? 'bg-stone-900 text-red-500 border-t-2 border-red-500' : 'text-stone-600 hover:text-stone-300'}`}
>
Requests
</button>
<button
onClick={() => setTab('stock')}
className={`flex-1 py-4 font-bold text-center transition-colors uppercase tracking-wider text-sm ${tab === 'stock' ? 'bg-stone-900 text-red-500 border-t-2 border-red-500' : 'text-stone-600 hover:text-stone-300'}`}
>
Inventory
</button>
<button
onClick={() => setTab('settings')}
className={`flex-1 py-4 font-bold text-center transition-colors uppercase tracking-wider text-sm ${tab === 'settings' ? 'bg-stone-900 text-red-500 border-t-2 border-red-500' : 'text-stone-600 hover:text-stone-300'}`}
>
Settings
</button>
</div>
<div className="p-6">
{tab === 'stock' && (
<div className="space-y-6">
<div className="flex justify-between items-end border-b border-stone-800 pb-4">
<h3 className="text-xl font-bold text-white">Stock Configuration</h3>
<p className="text-xs text-stone-500">Edit names, prices, and descriptions</p>
</div>
<div className="grid gap-4">
{stock.map(item => (
<AdminStockItem key={item.id} item={item} onUpdate={(data) => updateStockItem(item.id, data)} onDelete={() => deleteStockItem(item.id)} />
))}
</div>
<div className="flex justify-between items-center pt-10 border-t border-stone-800 mt-10">
<button onClick={addNewStockItem} className="bg-stone-800 hover:bg-stone-700 text-white px-4 py-2 rounded font-bold flex items-center gap-2 border border-stone-700"><Icons.Plus /> Add New Stock Item</button>
</div>
</div>
)}
{tab === 'orders' && (
<div className="space-y-6">
{/* Stats Dashboard */}
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="bg-[#0c0a09] p-4 rounded border border-stone-800 text-center shadow-lg">
<p className="text-stone-500 text-[10px] md:text-xs uppercase font-bold tracking-widest mb-1">Est. Revenue</p>
<p className="text-2xl md:text-3xl font-black text-green-500">${totalRevenue.toFixed(2)}</p>
</div>
<div className="bg-[#0c0a09] p-4 rounded border border-stone-800 text-center shadow-lg">
<p className="text-stone-500 text-[10px] md:text-xs uppercase font-bold tracking-widest mb-1">Total Prep (kg)</p>
<p className="text-2xl md:text-3xl font-black text-stone-200">{totalKg}<span className="text-lg text-stone-600 ml-1">kg</span></p>
</div>
<div className="bg-[#0c0a09] p-4 rounded border border-stone-800 text-center shadow-lg">
<p className="text-stone-500 text-[10px] md:text-xs uppercase font-bold tracking-widest mb-1">Requests</p>
<p className="text-2xl md:text-3xl font-black text-red-500">{activeRequests}</p>
</div>
</div>
<h3 className="text-xl font-bold text-white mb-4 border-b border-stone-800 pb-2">Incoming List</h3>
{orders.length === 0 ? (
<div className="text-center py-10 bg-stone-950 rounded border border-dashed border-stone-800">
<p className="text-stone-500 italic">No active orders. Time to smoke some meat!</p>
</div>
) : (
<div className="space-y-4">
{orders.map(order => (
<div key={order.id} className="bg-stone-950 p-5 rounded-lg border border-stone-800 relative group hover:border-stone-700 transition-colors shadow-md">
<div className="flex justify-between items-start mb-4">
<div>
<h4 className="font-bold text-lg text-red-400">{order.name || order.email}</h4>
<p className="text-sm text-stone-300 font-mono mb-1">{order.phone}</p>
<p className="text-xs text-stone-500">
{order.timestamp ? new Date(order.timestamp).toLocaleString() : 'Just now'}
</p>
</div>
<button onClick={() => deleteOrder(order.id)} className="text-stone-600 hover:text-red-500 transition-colors bg-stone-900 p-2 rounded">
<Icons.Trash />
</button>
</div>
<div className="bg-stone-900 rounded p-3 border border-stone-800/50">
{order.items.map((item, idx) => {
const currentPrice = item.finalPricePerUnit || item.price;
return (
<div key={idx} className="flex justify-between text-sm py-2 border-b border-stone-800 last:border-0">
<div>
<span className="text-stone-300 font-bold block">{item.name}</span>
<span className="text-stone-500 text-xs">
{item.qty}kg @ ${currentPrice}/kg
</span>
</div>
<span className="text-stone-400 font-mono">${(item.lineTotal).toFixed(2)}</span>
</div>
);
})}
<div className="flex justify-between mt-3 pt-3 border-t border-stone-800 font-bold">
<span className="text-white">Total Est.</span>
<span className="text-red-500">${order.estimatedTotal?.toFixed(2)}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{tab === 'settings' && (
<div className="max-w-md mx-auto text-center pt-10">
<div className="text-stone-500 mb-4"><Icons.Settings /></div>
<h3 className="text-xl font-bold text-white mb-6">Admin Settings</h3>
<div className="bg-stone-950 p-6 rounded-xl border border-stone-800 mb-8">
<label className="block text-xs font-bold text-stone-500 uppercase mb-2 text-left">Update Admin Password</label>
<input
type="text"
placeholder="New Code"
value={newAdminCode}
onChange={(e) => setNewAdminCode(e.target.value)}
className="w-full bg-stone-900 border border-stone-700 rounded p-3 text-white mb-4 focus:border-red-600 outline-none"
/>
<button
onClick={handleUpdatePassword}
disabled={!newAdminCode}
className="w-full bg-red-700 hover:bg-red-600 disabled:bg-stone-800 disabled:text-stone-600 text-white font-bold py-3 rounded transition-colors"
>
Update Password
</button>
</div>
<div className="space-y-4 border-t border-stone-800 pt-8">
<p className="text-xs font-bold text-stone-500 uppercase">Danger Zone</p>
<button onClick={clearAllOrders} className="w-full py-3 border border-red-900/50 text-red-500 hover:bg-red-900/20 rounded text-sm font-bold transition-colors">
Clear All Order History
</button>
<button onClick={onResetDB} className="text-xs text-stone-600 hover:text-red-500 underline block w-full">
Reset Entire Database to Defaults
</button>
</div>
</div>
)}
</div>
</div>
);
}
function AdminStockItem({ item, onUpdate, onDelete }) {
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState({});
useEffect(() => {
setEditData({
name: item.name || "",
category: item.category || "",
description: item.description || "",
price: item.price || 0,
bulk1Threshold: item.bulk1Threshold || 0,
bulk1Price: item.bulk1Price || 0,
bulk2Threshold: item.bulk2Threshold || 0,
bulk2Price: item.bulk2Price || 0
});
}, [item]);
const handleSave = () => {
onUpdate({
name: editData.name,
category: editData.category,
description: editData.description,
price: parseFloat(editData.price),
bulk1Threshold: parseFloat(editData.bulk1Threshold),
bulk1Price: parseFloat(editData.bulk1Price),
bulk2Threshold: parseFloat(editData.bulk2Threshold),
bulk2Price: parseFloat(editData.bulk2Price)
});
setIsEditing(false);
};
return (
<div className="bg-stone-950 p-4 rounded-lg border border-stone-800 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
{!isEditing ? (
<div className="flex-1">
<p className="font-bold text-white text-lg">{item.name}</p>
<p className="text-xs text-stone-500 italic">{item.description}</p>
</div>
) : (
<div className="flex-1 flex flex-col gap-2 mr-4">
<input
type="text"
value={editData.name}
onChange={e => setEditData({...editData, name: e.target.value})}
className="bg-black text-white border border-stone-700 rounded px-2 py-1 font-bold w-full"
placeholder="Item Name"
/>
<textarea
value={editData.description}
onChange={e => setEditData({...editData, description: e.target.value})}
className="bg-black text-stone-400 border border-stone-700 rounded px-2 py-1 text-xs w-full h-16"
placeholder="Description"
/>
</div>
)}
{!isEditing && (
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold uppercase ${item.available ? 'bg-green-900 text-green-400' : 'bg-red-900 text-red-400'}`}>
{item.available ? 'Active' : 'Disabled'}
</span>
)}
</div>
<div className="flex items-center gap-2">
{isEditing ? (
<>
<button onClick={handleSave} className="p-2 bg-green-600 hover:bg-green-500 text-white rounded"><Icons.Save /></button>
<button onClick={onDelete} className="p-2 bg-red-900 hover:bg-red-700 text-white rounded"><Icons.Trash /></button>
</>
) : (
<button onClick={() => setIsEditing(true)} className="p-2 bg-stone-800 hover:bg-stone-700 text-stone-400 hover:text-white rounded"><Icons.Edit /></button>
)}
<button
onClick={() => onUpdate({ available: !item.available })}
className={`px-3 py-2 rounded font-bold text-xs uppercase w-24 text-center ${item.available ? 'bg-stone-800 text-red-400 border border-red-900/30 hover:bg-red-900/20' : 'bg-green-900/20 text-green-400 border border-green-900/30 hover:bg-green-900/40'}`}
>
{item.available ? 'Sold Out' : 'Activate'}
</button>
</div>
</div>
{!isEditing ? (
<div className="text-sm text-stone-500 flex flex-wrap gap-4">
<span className="bg-stone-900 px-2 py-1 rounded">Standard: <b className="text-stone-300">${item.price}/kg</b></span>
{item.bulk1Threshold > 0 && (
<span className="bg-stone-900 px-2 py-1 rounded text-yellow-600">
Tier 1: <b className="text-yellow-500">${item.bulk1Price}</b> @ {item.bulk1Threshold}kg+
</span>
)}
{item.bulk2Threshold > 0 && (
<span className="bg-stone-900 px-2 py-1 rounded text-green-600">
Tier 2: <b className="text-green-500">${item.bulk2Price}</b> @ {item.bulk2Threshold}kg+
</span>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 bg-stone-900 p-3 rounded border border-stone-700">
<div className="space-y-2">
<p className="text-xs font-bold text-stone-400 uppercase">Base Info</p>
<label className="block text-[10px] text-stone-500">Price ($)</label>
<input type="number" value={editData.price} onChange={e => setEditData({...editData, price: e.target.value})} className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" />
<label className="block text-[10px] text-stone-500">Category</label>
<input type="text" value={editData.category} onChange={e => setEditData({...editData, category: e.target.value})} className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" />
</div>
<div className="space-y-2 border-l border-stone-800 pl-4">
<p className="text-xs font-bold text-yellow-500 uppercase">Tier 1 Bulk</p>
<label className="block text-[10px] text-stone-500">Threshold (kg)</label>
<input type="number" value={editData.bulk1Threshold} onChange={e => setEditData({...editData, bulk1Threshold: e.target.value})} className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" />
<label className="block text-[10px] text-stone-500">Price ($)</label>
<input type="number" value={editData.bulk1Price} onChange={e => setEditData({...editData, bulk1Price: e.target.value})} className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" />
</div>
<div className="space-y-2 border-l border-stone-800 pl-4">
<p className="text-xs font-bold text-green-500 uppercase">Tier 2 Super Bulk</p>
<label className="block text-[10px] text-stone-500">Threshold (kg)</label>
<input type="number" value={editData.bulk2Threshold} onChange={e => setEditData({...editData, bulk2Threshold: e.target.value})} className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" />
<label className="block text-[10px] text-stone-500">Price ($)</label>
<input type="number" value={editData.bulk2Price} onChange={e => setEditData({...editData, bulk2Price: e.target.value})} className="w-full bg-black text-white border border-stone-700 rounded px-2 py-1 text-sm" />
</div>
</div>
)}
</div>
);
}
function CartModal({ cart, onClose, onRemove, onUpdateQty, onSubmit }) {
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
// Calculate Total with Bulk Logic
const total = cart.reduce((acc, item) => {
const price = getPrice(item, item.qty);
return acc + (price * item.qty);
}, 0);
const totalWeight = cart.reduce((acc, i) => acc + i.qty, 0);
return (
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-[#1c1917] border border-stone-700 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="p-6 border-b border-stone-800 flex justify-between items-center bg-[#0c0a09]">
<h2 className="text-xl font-black italic text-white flex items-center gap-2 text-glow uppercase"><Icons.Beast /> Your Order</h2>
<button onClick={onClose} className="text-stone-500 hover:text-white"><Icons.X /></button>
</div>
<div className="p-6 overflow-y-auto flex-1 custom-scrollbar">
{cart.length === 0 ? (
<div className="text-center py-10 text-stone-500">
<p>Feed the beast. Add some meat.</p>
<button onClick={onClose} className="mt-4 text-red-500 font-bold hover:underline">Back to Menu</button>
</div>
) : (
<div className="space-y-4">
{cart.map(item => {
const currentPrice = getPrice(item, item.qty);
// Check what tier we are in
const isTier2 = item.bulk2Threshold > 0 && item.qty >= item.bulk2Threshold;
const isTier1 = !isTier2 && item.bulk1Threshold > 0 && item.qty >= item.bulk1Threshold;
// Logic for "Next Tier" message
let nextTierMsg = null;
if (!isTier2 && item.bulk2Threshold > 0) {
const diff = item.bulk2Threshold - item.qty;
if (diff > 0) nextTierMsg = `Add ${diff}kg for Tier 2 ($${item.bulk2Price})`;
}
if (!isTier1 && !isTier2 && item.bulk1Threshold > 0 && (!nextTierMsg || (item.bulk1Threshold - item.qty) < (item.bulk2Threshold - item.qty))) {
// Prioritize showing nearest tier
const diff = item.bulk1Threshold - item.qty;
if (diff > 0) nextTierMsg = `Add ${diff}kg for Tier 1 ($${item.bulk1Price})`;
}
return (
<div key={item.id} className="flex flex-col gap-2 pb-4 border-b border-stone-800 last:border-0">
<div className="flex items-center gap-4">
<div className="flex-1">
<h4 className="font-bold text-stone-200 text-lg">{item.name}</h4>
<div className="text-sm">
<span className={(isTier1 || isTier2) ? "text-green-500 font-bold" : "text-stone-500"}>
${currentPrice}/kg
</span>
{(isTier1 || isTier2) && <span className="text-xs text-stone-500 line-through ml-2">${item.price}</span>}
</div>
</div>
<div className="flex items-center bg-stone-950 rounded-lg border border-stone-800">
<button onClick={() => onUpdateQty(item.id, -1)} className="p-3 text-stone-400 hover:text-white"><Icons.Minus /></button>
<span className="w-12 text-center font-mono text-white font-bold">{item.qty}kg</span>
<button onClick={() => onUpdateQty(item.id, 1)} className="p-3 text-stone-400 hover:text-white"><Icons.Plus /></button>
</div>
<button onClick={() => onRemove(item.id)} className="text-stone-600 hover:text-red-500 p-2"><Icons.Trash /></button>
</div>
{/* Discount Progress Bar/Indicator */}
{nextTierMsg && (
<div className="text-xs text-stone-500 bg-stone-900 py-1 px-2 rounded flex justify-between items-center">
<span>{nextTierMsg}</span>
<Icons.Scale />
</div>
)}
{isTier1 && !isTier2 && (
<div className="text-xs text-yellow-500 font-bold flex items-center gap-1">
<Icons.Check /> Tier 1 Applied!
</div>
)}
{isTier2 && (
<div className="text-xs text-green-500 font-bold flex items-center gap-1">
<Icons.Check /> Tier 2 Super Bulk Applied!
</div>
)}
</div>
);
})}
</div>
)}
</div>
{cart.length > 0 && (
<div className="p-6 bg-[#0c0a09] border-t border-stone-800">
<div className="flex justify-between mb-6 text-xl font-bold">
<span className="text-stone-400">Total Est. ({totalWeight}kg)</span>
<span className="text-red-500 text-2xl">${total.toFixed(2)}</span>
</div>
<div className="space-y-4">
{/* Terms Acknowledgement in Cart */}
<div className="text-xs bg-stone-950 p-3 rounded border border-stone-800 text-stone-400">
<p className="font-bold text-red-500 mb-1 uppercase">Payment Required</p>
<p>By submitting, you agree to pay upfront via cash. Delivery/Pickup will be negotiated upon confirmation.</p>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-bold text-stone-500 uppercase mb-1">Name</label>
<input
type="text"
required
placeholder="Your Name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-stone-900 border border-stone-700 rounded-lg p-4 text-white focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-all"
/>
</div>
<div>
<label className="block text-xs font-bold text-stone-500 uppercase mb-1">Phone Number</label>
<input
type="tel"
required
placeholder="0400 000 000"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="w-full bg-stone-900 border border-stone-700 rounded-lg p-4 text-white focus:outline-none focus:border-red-600 focus:ring-1 focus:ring-red-600 transition-all"
/>
</div>
</div>
<button
onClick={() => onSubmit(name, phone)}
disabled={!name || !phone}
className="w-full bg-red-700 hover:bg-red-600 disabled:bg-stone-800 disabled:text-stone-600 text-white font-black uppercase tracking-widest py-4 rounded-lg transition-all shadow-lg shadow-red-900/20 active:scale-[0.98]"
>
Submit Order
</button>
</div>
</div>
)}
</div>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>