diff --git a/package.json b/package.json
index d0ee15d..ee7f152 100644
--- a/package.json
+++ b/package.json
@@ -13,12 +13,16 @@
"dependencies": {
"@tanstack/react-query": "^5.9.0",
"axios": "^1.6.6",
+ "leaflet": "^1.9.4",
"react": "^19.2.6",
- "react-dom": "^19.2.6"
+ "react-dom": "^19.2.6",
+ "react-leaflet": "^5.0.0",
+ "react-router-dom": "^7.17.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.1",
+ "@types/leaflet": "^1.9.21",
"@types/node": "^20.5.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cebedaf..3f41338 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,12 +14,21 @@ importers:
axios:
specifier: ^1.6.6
version: 1.17.0
+ leaflet:
+ specifier: ^1.9.4
+ version: 1.9.4
react:
specifier: ^19.2.6
version: 19.2.7
react-dom:
specifier: ^19.2.6
version: 19.2.7(react@19.2.7)
+ react-leaflet:
+ specifier: ^5.0.0
+ version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ react-router-dom:
+ specifier: ^7.17.0
+ version: 7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
devDependencies:
'@eslint/js':
specifier: ^10.0.1
@@ -27,6 +36,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.3.1
version: 4.3.1(vite@8.0.16(@types/node@20.19.43)(jiti@2.7.0))
+ '@types/leaflet':
+ specifier: ^1.9.21
+ version: 1.9.21
'@types/node':
specifier: ^20.5.1
version: 20.19.43
@@ -232,6 +244,13 @@ packages:
'@oxc-project/types@0.133.0':
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
+ '@react-leaflet/core@3.0.0':
+ resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
+ peerDependencies:
+ leaflet: ^1.9.0
+ react: ^19.0.0
+ react-dom: ^19.0.0
+
'@rolldown/binding-android-arm64@1.0.3':
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -441,9 +460,15 @@ packages:
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+ '@types/geojson@7946.0.16':
+ resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
+
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+ '@types/leaflet@1.9.21':
+ resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
+
'@types/node@20.19.43':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
@@ -589,6 +614,10 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -862,6 +891,9 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+ leaflet@1.9.4:
+ resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
+
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -1032,6 +1064,30 @@ packages:
peerDependencies:
react: ^19.2.7
+ react-leaflet@5.0.0:
+ resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==}
+ peerDependencies:
+ leaflet: ^1.9.0
+ react: ^19.0.0
+ react-dom: ^19.0.0
+
+ react-router-dom@7.17.0:
+ resolution: {integrity: sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+
+ react-router@7.17.0:
+ resolution: {integrity: sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
react@19.2.7:
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
engines: {node: '>=0.10.0'}
@@ -1053,6 +1109,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ set-cookie-parser@2.7.2:
+ resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -1377,6 +1436,12 @@ snapshots:
'@oxc-project/types@0.133.0': {}
+ '@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ leaflet: 1.9.4
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
'@rolldown/binding-android-arm64@1.0.3':
optional: true
@@ -1512,8 +1577,14 @@ snapshots:
'@types/estree@1.0.9': {}
+ '@types/geojson@7946.0.16': {}
+
'@types/json-schema@7.0.15': {}
+ '@types/leaflet@1.9.21':
+ dependencies:
+ '@types/geojson': 7946.0.16
+
'@types/node@20.19.43':
dependencies:
undici-types: 6.21.0
@@ -1691,6 +1762,8 @@ snapshots:
convert-source-map@2.0.0: {}
+ cookie@1.1.1: {}
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -1950,6 +2023,8 @@ snapshots:
dependencies:
json-buffer: 3.0.1
+ leaflet@1.9.4: {}
+
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -2080,6 +2155,27 @@ snapshots:
react: 19.2.7
scheduler: 0.27.0
+ react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ '@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ leaflet: 1.9.4
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
+ react-router-dom@7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ react-router: 7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+
+ react-router@7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ cookie: 1.1.1
+ react: 19.2.7
+ set-cookie-parser: 2.7.2
+ optionalDependencies:
+ react-dom: 19.2.7(react@19.2.7)
+
react@19.2.7: {}
rolldown@1.0.3:
@@ -2109,6 +2205,8 @@ snapshots:
semver@7.8.4: {}
+ set-cookie-parser@2.7.2: {}
+
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
diff --git a/src/App.tsx b/src/App.tsx
index ae18f15..c98b041 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,6 +1,26 @@
-import React from 'react'
+import { useEffect } from 'react'
+import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'
import Home from './pages/Home'
+import CafeDetail from './pages/CafeDetail'
+
+function ScrollToTop() {
+ const { pathname } = useLocation()
+
+ useEffect(() => {
+ window.scrollTo(0, 0)
+ }, [pathname])
+
+ return null
+}
export default function App() {
- return
+ return (
+
+
+
+ } />
+ } />
+
+
+ )
}
diff --git a/src/assets/call.svg b/src/assets/call.svg
new file mode 100644
index 0000000..693c4bc
--- /dev/null
+++ b/src/assets/call.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/info.svg b/src/assets/info.svg
new file mode 100644
index 0000000..1b66977
--- /dev/null
+++ b/src/assets/info.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/location.svg b/src/assets/location.svg
new file mode 100644
index 0000000..462a973
--- /dev/null
+++ b/src/assets/location.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/minus.svg b/src/assets/minus.svg
new file mode 100644
index 0000000..e1d50eb
--- /dev/null
+++ b/src/assets/minus.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/plus.svg b/src/assets/plus.svg
new file mode 100644
index 0000000..fc2de2c
--- /dev/null
+++ b/src/assets/plus.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/components/InfoIcon.tsx b/src/components/InfoIcon.tsx
new file mode 100644
index 0000000..fa2bd6c
--- /dev/null
+++ b/src/components/InfoIcon.tsx
@@ -0,0 +1,15 @@
+export default function InfoIcon({ className = 'h-5 w-5 shrink-0 text-gray-400' }: { className?: string }) {
+ return (
+
+ )
+}
diff --git a/src/components/ReservationModal.tsx b/src/components/ReservationModal.tsx
new file mode 100644
index 0000000..0994263
--- /dev/null
+++ b/src/components/ReservationModal.tsx
@@ -0,0 +1,211 @@
+import { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
+import { Cafe } from "../data/mockCafes";
+import { formatPersianNumber } from "../utils/formatNumber";
+import InfoIcon from "./InfoIcon";
+
+interface ReservationModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ cafe: Cafe;
+}
+
+const mockPhone = "۰۹۱۲۳۴۵۶۷۸۹";
+// mock wallet balance — will come from the user's wallet once auth is implemented
+const walletBalance = 10000;
+
+function MinusIcon({ className = "" }: { className?: string }) {
+ return (
+
+ );
+}
+
+function PlusIcon({ className = "" }: { className?: string }) {
+ return (
+
+ );
+}
+
+function PaymentRow({
+ label,
+ amount,
+ emphasize = false,
+ bottomBorder = false,
+ infoIconParam = false,
+}: {
+ label: string;
+ amount: number;
+ emphasize?: boolean;
+ bottomBorder?: boolean;
+ infoIconParam?: boolean;
+}) {
+ return (
+
+
+
+ {label}
+
+ {infoIconParam && (
+
+ )}
+
+
+ {formatPersianNumber(amount)} تومان
+
+
+ );
+}
+
+export default function ReservationModal({
+ isOpen,
+ onClose,
+ cafe,
+}: ReservationModalProps) {
+ const [fullName, setFullName] = useState("");
+ const [count, setCount] = useState(1);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = previousOverflow;
+ };
+ }, [isOpen]);
+
+ if (!isOpen) return null;
+
+ const reservationAmount = cafe.entryFeeAmount * count;
+ const payableAmount = Math.max(0, reservationAmount - walletBalance);
+
+ return createPortal(
+ <>
+
+
+
+
+
+
+ {cafe.name}
+
+
+ {cafe.match}
+
+
+
+
+ setFullName(e.target.value)}
+ placeholder="نام و نام خانوادگی"
+ className="w-full rounded-2xl border border-gray-200 px-4 py-3 text-right"
+ />
+
+
+
+
+ {/* readOnly mock for now — once OTP auth ships, this comes from the auth context */}
+
+
+
+
+
+
+
+
+ {formatPersianNumber(count)} نفر
+
+
+
+
+
+
+ پرداخت
+
+
+
+
+
+
+
+
+ توجه داشته باشید که مسئولیت رزرو و حضور بهموقع در کافه بر عهده
+ کاربر است.
+
+
+
+
+
+
+
+
+ >,
+ document.body,
+ );
+}
diff --git a/src/data/mockCafes.ts b/src/data/mockCafes.ts
new file mode 100644
index 0000000..6f720da
--- /dev/null
+++ b/src/data/mockCafes.ts
@@ -0,0 +1,55 @@
+export interface Cafe {
+ id: number
+ name: string
+ match: string
+ entryFee: string
+ entryFeeAmount: number
+ remainingCapacity: number
+ tags: string[]
+ location: { lat: number; lng: number }
+ phone: string
+ address: string
+ images: string[] // empty array for now, will show gray placeholder
+}
+
+export const mockCafes: Cafe[] = [
+ {
+ id: 1,
+ name: 'کافه بیبای',
+ match: 'بازی برزیل-مراکش (پنجشنبه ۲۷ خرداد - ساعت ۲:۰۰)',
+ entryFee: '۲,۰۰۰,۰۰۰ تومان',
+ entryFeeAmount: 2000000,
+ remainingCapacity: 3,
+ tags: ['پارکینگ دارد', 'فضای سیگار', 'امکان سفارش'],
+ location: { lat: 35.7219, lng: 51.3347 },
+ phone: '۰۲۱۹۱۰۲۰۲۱۲۰۲',
+ address: 'ونک، خدامی، نبش نیروی انتظامی، پلاک ۲',
+ images: [],
+ },
+ {
+ id: 2,
+ name: 'کافه بیبای شعبه ۲',
+ match: 'بازی برزیل-مراکش (پنجشنبه ۲۷ خرداد - ساعت ۲:۰۰)',
+ entryFee: '۱,۵۰۰,۰۰۰ تومان',
+ entryFeeAmount: 1500000,
+ remainingCapacity: 10,
+ tags: ['فضای سیگار', 'امکان سفارش'],
+ location: { lat: 35.755, lng: 51.4165 },
+ phone: '۰۲۱۸۸۴۴۵۵۶۶',
+ address: 'جردن، خیابان آفریقا، پلاک ۱۲',
+ images: [],
+ },
+ {
+ id: 3,
+ name: 'کافه ستاره',
+ match: 'بازی برزیل-مراکش (پنجشنبه ۲۷ خرداد - ساعت ۲:۰۰)',
+ entryFee: 'رایگان',
+ entryFeeAmount: 0,
+ remainingCapacity: 15,
+ tags: ['پارکینگ دارد'],
+ location: { lat: 35.6892, lng: 51.389 },
+ phone: '۰۲۱۷۷۳۳۲۲۱۱',
+ address: 'انقلاب، خیابان ۱۶ آذر، پلاک ۵',
+ images: [],
+ },
+]
diff --git a/src/pages/CafeDetail.tsx b/src/pages/CafeDetail.tsx
new file mode 100644
index 0000000..4a0b445
--- /dev/null
+++ b/src/pages/CafeDetail.tsx
@@ -0,0 +1,272 @@
+import { ReactNode, useState } from "react";
+import { useNavigate, useParams, useLocation } from "react-router-dom";
+import { MapContainer, TileLayer, Marker, ZoomControl } from "react-leaflet";
+import "leaflet/dist/leaflet.css";
+import L from "leaflet";
+import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png";
+import markerIcon from "leaflet/dist/images/marker-icon.png";
+import markerShadow from "leaflet/dist/images/marker-shadow.png";
+import { mockCafes, Cafe } from "../data/mockCafes";
+import InfoIcon from "../components/InfoIcon";
+import ReservationModal from "../components/ReservationModal";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+delete (L.Icon.Default.prototype as any)._getIconUrl;
+L.Icon.Default.mergeOptions({
+ iconUrl: markerIcon,
+ iconRetinaUrl: markerIcon2x,
+ shadowUrl: markerShadow,
+});
+
+function ChevronLeftIcon() {
+ return (
+
+ );
+}
+
+function ChevronRightIcon() {
+ return (
+
+ );
+}
+
+function PhoneIcon() {
+ return (
+
+ );
+}
+
+function MapPinIcon() {
+ return (
+
+ );
+}
+
+function Pill({
+ children,
+ className = "bg-gray-100 text-gray-700",
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function ImageCarousel({ images }: { images: string[] }) {
+ const [index, setIndex] = useState(0);
+ const slideCount = Math.max(images.length, 1);
+
+ return (
+
+
+
+
+ {Array.from({ length: slideCount }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
+
+function toEnglishDigits(value: string) {
+ const persianDigits = "۰۱۲۳۴۵۶۷۸۹";
+ return value.replace(/[۰-۹]/g, (digit) => String(persianDigits.indexOf(digit)));
+}
+
+async function openNavigationPicker(cafe: Cafe) {
+ const mapsUrl = `https://www.google.com/maps/dir/?api=1&destination=${cafe.location.lat},${cafe.location.lng}`;
+
+ if (navigator.share) {
+ try {
+ await navigator.share({ title: cafe.name, url: mapsUrl });
+ } catch {
+ // user dismissed the native share sheet
+ }
+ } else {
+ window.open(mapsUrl, "_blank");
+ }
+}
+
+export default function CafeDetail() {
+ const { id } = useParams();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const [isReservationOpen, setIsReservationOpen] = useState(false);
+
+ const cafe: Cafe | undefined =
+ (location.state as { cafe?: Cafe } | null)?.cafe ??
+ mockCafes.find((c) => c.id === Number(id));
+
+ if (!cafe) {
+ return (
+
+ کافه پیدا نشد
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {cafe.name}
+
+
{cafe.match}
+
+
+
ورودی: نفری {cafe.entryFee}
+
ظرفیت باقی مانده: {cafe.remainingCapacity}نفر
+ {/*
*/}
+
+ {/*
*/}
+ {cafe.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+
+ آدرس
+
+
+
+
+
+
+
+
+
+
+
+
+
آدرس
+
{cafe.address}
+
+
+
+
+
+
+
+
+
+ توجه داشته باشید که مسئولیت رزرو و حضور بهموقع در کافه بر عهده
+ کاربر است.
+
+
+
+
+
+
+
+
+
setIsReservationOpen(false)}
+ cafe={cafe}
+ />
+
+ );
+}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 9f8ae19..376a05d 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -1,70 +1,143 @@
import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { mockCafes, Cafe } from '../data/mockCafes'
-type Cafe = {
- id: number
- name: string
- seats: number
- image: string | null
-}
-
-const mockCafes: Cafe[] = [
- { id: 1, name: 'کافه بیبای', seats: 8, image: null },
- { id: 2, name: 'کافه بیبای', seats: 10, image: null },
- { id: 3, name: 'کافه ستاره', seats: 15, image: null },
+const cityOptions = ['تهران', 'اصفهان', 'مشهد']
+const matchOptions = [
+ 'برزیل-مراکش (۳ روز دیگر-۲:۰۰)',
+ 'آرژانتین-فرانسه (۲ روز دیگر-۱۸:۰۰)',
+ 'اسپانیا-آلمان (۱ روز دیگر-۲۰:۳۰)',
]
-function ChevronDownIcon() {
+function ChevronDownIcon({ className = '' }: { className?: string }) {
return (
-