Fix Pages: build as IIFE with inlined CSS; strip type=module for Gitea MIME compatibility

This commit is contained in:
Telecom 2026-05-22 17:12:47 +05:00
parent 94df4033ae
commit 9d4a9fd802
16 changed files with 3476 additions and 44 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

42
assets/index.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -6,8 +6,7 @@
<meta name="theme-color" content="#3D3A3A" /> <meta name="theme-color" content="#3D3A3A" />
<title>AQ JAIYQ — кафе и минимаркет в Нуре</title> <title>AQ JAIYQ — кафе и минимаркет в Нуре</title>
<meta name="description" content="AQ JAIYQ — доставка еды из кафе и продуктов из минимаркета в посёлке Нура. Быстрый заказ через WhatsApp." /> <meta name="description" content="AQ JAIYQ — доставка еды из кафе и продуктов из минимаркета в посёлке Нура. Быстрый заказ через WhatsApp." />
<script type="module" crossorigin src="./assets/index-C3ZVCTBo.js"></script> <script src="./assets/index.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BEYdkHi2.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

2719
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "aq-jaiyq",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 4321",
"build": "tsc -b && vite build",
"preview": "vite preview --host 127.0.0.1 --port 4321"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

457
src/App.tsx Normal file
View File

@ -0,0 +1,457 @@
import { useEffect, useRef, useState } from "react";
import { useCart, type CartItem } from "./cart";
import { fmtTenge } from "./format";
import {
popularCafe,
popularMarket,
WHATSAPP_PHONE,
PHONE_CAFE,
PHONE_MARKET,
ADDRESS,
HOURS_CAFE,
HOURS_MARKET,
FULL_MENU_URL,
type Product,
} from "./products";
const TAG_LABEL: Record<string, string> = {
hit: "Хит",
signature: "Фирменное",
preorder: "По предзаказу",
};
export default function App() {
const cart = useCart();
const [drawerOpen, setDrawerOpen] = useState(false);
return (
<div className="min-h-screen flex flex-col">
<Header count={cart.count} onOpenCart={() => setDrawerOpen(true)} />
<main className="flex-1">
<Hero onOpenCart={() => setDrawerOpen(true)} />
<Section
id="cafe"
title="Кафе"
subtitle="Популярные позиции. Полное меню — на отдельной странице."
items={popularCafe}
onAdd={cart.add}
fullMenuUrl={FULL_MENU_URL}
/>
<Section
id="market"
title="Минимаркет"
subtitle="Хлеб, продукты первой необходимости. Круглосуточно."
items={popularMarket}
onAdd={cart.add}
/>
</main>
<Footer />
{cart.count > 0 && !drawerOpen && (
<StickyCartFab
count={cart.count}
total={cart.total}
onClick={() => setDrawerOpen(true)}
/>
)}
<CartDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
items={cart.items}
total={cart.total}
onAdd={cart.add}
onDec={cart.dec}
onRemove={cart.remove}
onClear={cart.clear}
/>
</div>
);
}
function Header({ count, onOpenCart }: { count: number; onOpenCart: () => void }) {
return (
<header className="sticky top-0 z-30 bg-cream/90 backdrop-blur border-b border-charcoal-100">
<div className="mx-auto max-w-5xl px-4 py-3 flex items-center justify-between gap-3">
<a href="#top" className="font-display text-xl sm:text-2xl font-bold text-charcoal-700">
АҚ ЖАЙЫҚ
</a>
<div className="flex items-center gap-2">
<a
href={`tel:${PHONE_CAFE.replace(/\s/g, "")}`}
className="hidden sm:inline-flex btn-ghost !py-2 !px-3 text-sm"
aria-label="Позвонить в кафе"
>
📞 {PHONE_CAFE}
</a>
<button
type="button"
onClick={onOpenCart}
className="btn-primary !py-2 !px-3 text-sm"
aria-label={`Открыть корзину (${count})`}
>
🛒 Корзина{count > 0 ? ` · ${count}` : ""}
</button>
</div>
</div>
</header>
);
}
function Hero({ onOpenCart }: { onOpenCart: () => void }) {
return (
<section id="top" className="px-4 pt-10 pb-12 sm:pt-16 sm:pb-20">
<div className="mx-auto max-w-5xl text-center">
<p className="text-gold-500 uppercase tracking-widest text-xs sm:text-sm mb-3">
Кафе и минимаркет · пос. Нура
</p>
<h1 className="font-display font-extrabold text-charcoal-700 text-4xl sm:text-6xl leading-tight">
АҚ ЖАЙЫҚ
</h1>
<p className="mt-4 text-charcoal-500 text-base sm:text-lg max-w-xl mx-auto">
Домашняя кухня, свежий хлеб и продукты под рукой. Соберите заказ и отправьте в WhatsApp одним кликом.
</p>
<div className="mt-7 flex flex-wrap items-center justify-center gap-3">
<a href="#cafe" className="btn-primary">🍽️ Меню кафе</a>
<a href="#market" className="btn-ghost">🛍️ Минимаркет</a>
<button type="button" onClick={onOpenCart} className="btn-secondary">
🛒 Открыть корзину
</button>
</div>
</div>
</section>
);
}
function Section({
id,
title,
subtitle,
items,
onAdd,
fullMenuUrl,
}: {
id: string;
title: string;
subtitle?: string;
items: Product[];
onAdd: (p: Product) => void;
fullMenuUrl?: string;
}) {
return (
<section id={id} className="px-4 py-10 sm:py-14 border-t border-charcoal-100">
<div className="mx-auto max-w-5xl">
<div className="flex items-end justify-between gap-3 mb-6">
<div>
<h2 className="font-display text-3xl sm:text-4xl font-bold text-charcoal-700">{title}</h2>
{subtitle && <p className="text-charcoal-500 mt-1 text-sm sm:text-base">{subtitle}</p>}
</div>
{fullMenuUrl && (
<a
href={fullMenuUrl}
target="_blank"
rel="noopener noreferrer"
className="text-gold-500 hover:text-gold-600 text-sm whitespace-nowrap"
>
Все позиции →
</a>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((p) => (
<ProductCard key={p.id} product={p} onAdd={() => onAdd(p)} />
))}
</div>
</div>
</section>
);
}
function ProductCard({ product, onAdd }: { product: Product; onAdd: () => void }) {
return (
<article className="bg-white rounded-2xl p-4 shadow-soft hover:shadow-card transition-shadow flex gap-3">
<div className="text-4xl sm:text-5xl shrink-0" aria-hidden="true">
{product.emoji}
</div>
<div className="flex-1 min-w-0 flex flex-col">
<h3 className="font-medium text-charcoal-700 leading-snug">{product.name}</h3>
{product.ingredients && (
<p className="text-xs text-charcoal-400 mt-1 leading-snug">{product.ingredients}</p>
)}
{product.tags && product.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{product.tags.map((t) => (
<span
key={t}
className="text-[10px] uppercase tracking-wider bg-gold-100 text-gold-600 px-2 py-0.5 rounded-full"
>
{TAG_LABEL[t] ?? t}
</span>
))}
</div>
)}
<div className="mt-3 flex items-center justify-between gap-2">
<div className="text-charcoal-700 font-semibold">
{fmtTenge(product.price)}
{product.unit && <span className="text-charcoal-400 text-xs ml-1">/ {product.unit}</span>}
</div>
<button
type="button"
onClick={onAdd}
className="btn-primary !px-3 !py-2 text-sm"
aria-label={`Добавить ${product.name}`}
>
+
</button>
</div>
</div>
</article>
);
}
function StickyCartFab({
count,
total,
onClick,
}: {
count: number;
total: number;
onClick: () => void;
}) {
return (
<div className="sm:hidden fixed bottom-4 inset-x-4 z-20">
<button
type="button"
onClick={onClick}
className="btn-primary w-full shadow-card !py-4 text-base"
>
🛒 Корзина · {count} · {fmtTenge(total)}
</button>
</div>
);
}
function CartDrawer({
open,
onClose,
items,
total,
onAdd,
onDec,
onRemove,
onClear,
}: {
open: boolean;
onClose: () => void;
items: CartItem[];
total: number;
onAdd: (p: Product) => void;
onDec: (id: string) => void;
onRemove: (id: string) => void;
onClear: () => void;
}) {
const closeBtnRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
closeBtnRef.current?.focus();
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [open, onClose]);
const buildWhatsAppUrl = () => {
const lines = items.map(
(i) => `• ${i.product.name} × ${i.qty} — ${fmtTenge(i.product.price * i.qty)}`,
);
const text = [
"Здравствуйте! Хочу заказать:",
...lines,
"",
`Итого: ${fmtTenge(total)}`,
"",
"Имя:",
"Адрес/самовывоз:",
"Время:",
].join("\n");
return `https://wa.me/${WHATSAPP_PHONE}?text=${encodeURIComponent(text)}`;
};
return (
<>
<div
className={`fixed inset-0 z-40 bg-charcoal-700/40 transition-opacity ${
open ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"
}`}
onClick={onClose}
aria-hidden="true"
/>
<aside
role="dialog"
aria-modal="true"
aria-label="Корзина"
className={`fixed top-0 right-0 z-50 h-full w-full sm:max-w-md bg-cream shadow-card flex flex-col transform transition-transform ${
open ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-charcoal-100">
<h2 className="font-display text-xl font-bold text-charcoal-700">Корзина</h2>
<button
ref={closeBtnRef}
type="button"
onClick={onClose}
className="btn-ghost !p-2 !px-3"
aria-label="Закрыть корзину"
>
✕
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
{items.length === 0 ? (
<p className="text-charcoal-400 text-center py-12">
Корзина пуста. Добавьте позицию из меню.
</p>
) : (
<ul className="space-y-3">
{items.map((i) => (
<li key={i.product.id} className="bg-white rounded-xl p-3 flex gap-3 shadow-soft">
<div className="text-3xl shrink-0" aria-hidden="true">
{i.product.emoji}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="font-medium text-charcoal-700 leading-snug">{i.product.name}</p>
<button
type="button"
onClick={() => onRemove(i.product.id)}
className="text-charcoal-300 hover:text-charcoal-600 text-sm"
aria-label={`Удалить ${i.product.name}`}
>
✕
</button>
</div>
<p className="text-xs text-charcoal-400 mt-0.5">
{fmtTenge(i.product.price)}
{i.product.unit && ` / ${i.product.unit}`}
</p>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="inline-flex items-center gap-1 bg-charcoal-50 rounded-lg">
<button
type="button"
onClick={() => onDec(i.product.id)}
className="px-3 py-1 text-charcoal-700 hover:bg-charcoal-100 rounded-l-lg"
aria-label={`Уменьшить ${i.product.name}`}
>
−
</button>
<span className="px-2 min-w-[2ch] text-center text-sm">{i.qty}</span>
<button
type="button"
onClick={() => onAdd(i.product)}
className="px-3 py-1 text-charcoal-700 hover:bg-charcoal-100 rounded-r-lg"
aria-label={`Увеличить ${i.product.name}`}
>
+
</button>
</div>
<div className="font-semibold text-charcoal-700 text-sm">
{fmtTenge(i.product.price * i.qty)}
</div>
</div>
</div>
</li>
))}
</ul>
)}
</div>
<div className="border-t border-charcoal-100 px-4 py-4 space-y-3 bg-cream">
<div className="flex items-center justify-between">
<span className="text-charcoal-500">Итого</span>
<span className="font-display text-2xl font-bold text-charcoal-700">{fmtTenge(total)}</span>
</div>
<a
href={items.length > 0 ? buildWhatsAppUrl() : undefined}
target="_blank"
rel="noopener noreferrer"
aria-disabled={items.length === 0}
className={`btn-primary w-full !py-3 ${
items.length === 0 ? "opacity-50 pointer-events-none" : ""
}`}
>
💬 Заказать в WhatsApp{items.length > 0 ? ` · ${fmtTenge(total)}` : ""}
</a>
<a
href={`tel:${PHONE_CAFE.replace(/\s/g, "")}`}
className="btn-ghost w-full"
>
📞 Просто позвонить
</a>
{items.length > 0 && (
<button
type="button"
onClick={onClear}
className="w-full text-charcoal-400 hover:text-charcoal-600 text-sm py-2"
>
Очистить корзину
</button>
)}
</div>
</aside>
</>
);
}
function Footer() {
return (
<footer className="bg-charcoal-700 text-cream px-4 py-10 mt-10">
<div className="mx-auto max-w-5xl grid gap-6 sm:grid-cols-3">
<div>
<h3 className="font-display text-xl font-bold mb-2">АҚ ЖАЙЫҚ</h3>
<p className="text-charcoal-200 text-sm">{ADDRESS}</p>
<a
href={FULL_MENU_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-block mt-3 text-gold-300 hover:text-gold-200 text-sm"
>
Полное меню кафе →
</a>
</div>
<div>
<h4 className="font-semibold mb-2">Часы работы</h4>
<p className="text-charcoal-200 text-sm">Кафе: {HOURS_CAFE}</p>
<p className="text-charcoal-200 text-sm">Минимаркет: {HOURS_MARKET}</p>
</div>
<div>
<h4 className="font-semibold mb-2">Связь</h4>
<p className="text-sm">
<a
href={`tel:${PHONE_CAFE.replace(/\s/g, "")}`}
className="text-gold-300 hover:text-gold-200"
>
📞 {PHONE_CAFE}
</a>
<span className="text-charcoal-300"> · кафе</span>
</p>
<p className="text-sm mt-1">
<a
href={`tel:${PHONE_MARKET.replace(/\s/g, "")}`}
className="text-gold-300 hover:text-gold-200"
>
📞 {PHONE_MARKET}
</a>
<span className="text-charcoal-300"> · минимаркет</span>
</p>
</div>
</div>
<p className="mx-auto max-w-5xl mt-8 text-charcoal-300 text-xs">© AQ JAIYQ · пос. Нура</p>
</footer>
);
}

52
src/cart.ts Normal file
View File

@ -0,0 +1,52 @@
import { useEffect, useState } from "react";
import type { Product } from "./products";
const KEY = "aq-jaiyq-cart-v1";
export type CartItem = { product: Product; qty: number };
function read(): CartItem[] {
try {
const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as CartItem[]) : [];
} catch {
return [];
}
}
function write(items: CartItem[]) {
localStorage.setItem(KEY, JSON.stringify(items));
window.dispatchEvent(new CustomEvent("cart:update"));
}
export function useCart() {
const [items, setItems] = useState<CartItem[]>(() => read());
useEffect(() => {
const handler = () => setItems(read());
window.addEventListener("cart:update", handler);
return () => window.removeEventListener("cart:update", handler);
}, []);
const add = (product: Product) => {
const cur = read();
const idx = cur.findIndex((i) => i.product.id === product.id);
if (idx >= 0) cur[idx].qty += 1;
else cur.push({ product, qty: 1 });
write(cur);
};
const dec = (id: string) => {
const cur = read()
.map((i) => (i.product.id === id ? { ...i, qty: i.qty - 1 } : i))
.filter((i) => i.qty > 0);
write(cur);
};
const remove = (id: string) => write(read().filter((i) => i.product.id !== id));
const clear = () => write([]);
const total = items.reduce((s, i) => s + i.product.price * i.qty, 0);
const count = items.reduce((s, i) => s + i.qty, 0);
return { items, add, dec, remove, clear, total, count };
}

2
src/format.ts Normal file
View File

@ -0,0 +1,2 @@
export const fmtTenge = (n: number) =>
new Intl.NumberFormat("ru-KZ", { maximumFractionDigits: 0 }).format(n) + " ₸";

26
src/index.css Normal file
View File

@ -0,0 +1,26 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@600;700;800&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply bg-cream text-charcoal-700 font-sans antialiased;
}
}
@layer components {
.btn-primary {
@apply inline-flex items-center justify-center gap-2 bg-gold-400 hover:bg-gold-500 text-charcoal-700 font-medium px-5 py-3 rounded-xl transition-colors;
}
.btn-secondary {
@apply inline-flex items-center justify-center gap-2 bg-charcoal-700 hover:bg-charcoal-600 text-cream font-medium px-5 py-3 rounded-xl transition-colors;
}
.btn-ghost {
@apply inline-flex items-center justify-center gap-2 bg-white hover:bg-charcoal-50 text-charcoal-700 font-medium px-5 py-3 rounded-xl border border-charcoal-100 transition-colors;
}
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

51
src/products.ts Normal file
View File

@ -0,0 +1,51 @@
export type Product = {
id: string;
name: string;
price: number;
unit?: string;
ingredients?: string;
emoji: string;
tags?: string[];
section: "cafe" | "market";
};
export const popularCafe: Product[] = [
{ id: "cf-021", name: "Плов", price: 1200, emoji: "🍚", tags: ["hit"], section: "cafe" },
{ id: "cf-025", name: "Куырдак из баранины", price: 1500, emoji: "🥘", tags: ["hit"], section: "cafe" },
{ id: "cf-049", name: "Шашлык из курицы", price: 1500, emoji: "🍢", tags: ["hit"], section: "cafe" },
{ id: "cf-051", name: "Шашлык из баранины", price: 2000, emoji: "🍖", tags: ["hit"], section: "cafe" },
{
id: "cf-067",
name: "Фирменная закуска «АҚ ЖАЙЫҚ»",
price: 2500,
ingredients: "фарш из брынзы и шпината в тесте, жаренные во фритюре",
emoji: "🥟",
tags: ["signature", "preorder"],
section: "cafe",
},
{ id: "cf-091", name: "Max Классика", price: 1500, emoji: "🌯", tags: ["hit"], section: "cafe" },
{
id: "cf-110",
name: "Казан-кебаб",
price: 30000,
ingredients: "обжаренный картофель, говядина, розочки из теста. 10–12 человек",
emoji: "🍲",
tags: ["signature"],
section: "cafe",
},
];
export const popularMarket: Product[] = [
{ id: "mk-001", name: "Хлеб домашний", price: 165, unit: "шт", emoji: "🍞", tags: ["hit"], section: "market" },
{ id: "mk-002", name: "Хлеб на сыворотке", price: 180, unit: "шт", emoji: "🥖", section: "market" },
{ id: "mk-003", name: "Сахар", price: 569, unit: "кг", emoji: "🧂", section: "market" },
{ id: "mk-004", name: "Картофель", price: 319, unit: "кг", emoji: "🥔", section: "market" },
];
export const WHATSAPP_PHONE = "77054263130";
export const PHONE_CAFE = "+7 705 426 31 30";
export const PHONE_MARKET = "+7 776 031 96 66";
export const ADDRESS = "ул. Ардагерлер 40, поселок Нура, Карагандинская область";
export const HOURS_CAFE = "10:00 – 23:00";
export const HOURS_MARKET = "Круглосуточно";
export const FULL_MENU_URL = "https://aq-jaiyq.kz/cafe";

38
tailwind.config.js Normal file
View File

@ -0,0 +1,38 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
cream: "#F5EFE6",
charcoal: {
50: "#F5F2EE",
100: "#E6E1DA",
200: "#C7C0B8",
300: "#A39A91",
400: "#7A726B",
500: "#5A5450",
600: "#4A4644",
700: "#3D3A3A",
},
gold: {
100: "#F2E9D2",
200: "#E6D6A8",
300: "#D7BB78",
400: "#C9A961",
500: "#B58F49",
600: "#9A7B54",
},
},
fontFamily: {
display: ['"Playfair Display"', "Georgia", "serif"],
sans: ["Inter", "system-ui", "sans-serif"],
},
boxShadow: {
card: "0 10px 30px -10px rgba(61, 58, 58, 0.25)",
soft: "0 4px 12px -4px rgba(61, 58, 58, 0.12)",
},
},
},
plugins: [],
};

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

27
vite.config.ts Normal file
View File

@ -0,0 +1,27 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const stripModuleType = {
name: "strip-module-type",
transformIndexHtml(html: string) {
return html
.replace(/<script type="module"/g, "<script")
.replace(/\s*crossorigin/g, "");
},
};
export default defineConfig({
plugins: [react(), stripModuleType],
base: "./",
build: {
rollupOptions: {
output: {
format: "iife",
inlineDynamicImports: true,
entryFileNames: "assets/index.js",
chunkFileNames: "assets/[name].js",
assetFileNames: "assets/[name].[ext]",
},
},
},
});