Fix Pages: build as IIFE with inlined CSS; strip type=module for Gitea MIME compatibility
This commit is contained in:
parent
94df4033ae
commit
9d4a9fd802
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
42
assets/index.js
Normal file
File diff suppressed because one or more lines are too long
@ -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
2719
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
457
src/App.tsx
Normal file
457
src/App.tsx
Normal 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
52
src/cart.ts
Normal 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
2
src/format.ts
Normal 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
26
src/index.css
Normal 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
10
src/main.tsx
Normal 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
51
src/products.ts
Normal 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
38
tailwind.config.js
Normal 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
20
tsconfig.json
Normal 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
27
vite.config.ts
Normal 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]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user