feat: cafe register flow
This commit is contained in:
parent
3b6d973846
commit
c0b8710ee5
13 changed files with 574 additions and 68 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
VITE_API_BASE_URL=https://reservation-staging.winsoo.ir/api/
|
||||||
VITE_AUTH_BASE_URL=https://accounts-staging.gooyal.ir
|
VITE_AUTH_BASE_URL=https://accounts-staging.gooyal.ir
|
||||||
VITE_AUTH_CLIENT_ID=
|
VITE_AUTH_CLIENT_ID=
|
||||||
VITE_AUTH_CLIENT_SECRET=
|
VITE_AUTH_CLIENT_SECRET=
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
VITE_APP_BASE_URL=https://reservation-staging.winsoo.ir/
|
VITE_APP_BASE_URL=https://reservation-staging.winsoo.ir/
|
||||||
|
VITE_API_BASE_URL=https://reservation-staging.winsoo.ir/api/
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
export interface CafeFeature {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const mockCafeFeatures: CafeFeature[] = [
|
|
||||||
{ id: 'open-space', label: 'فضای باز' },
|
|
||||||
{ id: 'family-friendly', label: 'فضای مناسب خانواده' },
|
|
||||||
{ id: 'smoking-allowed', label: 'امکان کشیدن سیگار' },
|
|
||||||
{ id: 'parking', label: 'پارکینگ' },
|
|
||||||
{ id: 'delivery', label: 'امکان سفارش' },
|
|
||||||
]
|
|
||||||
23
src/hooks/useMatchEvents.ts
Normal file
23
src/hooks/useMatchEvents.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { fetchMatchEvents, MatchEvent } from '../services/campaignApi'
|
||||||
|
|
||||||
|
export function useMatchEvents() {
|
||||||
|
const [matches, setMatches] = useState<MatchEvent[]>([])
|
||||||
|
const [now, setNow] = useState(() => new Date())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMatchEvents()
|
||||||
|
.then(setMatches)
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(new Date()), 1000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// drop matches whose kickoff has already passed — only upcoming ones should ever be selectable
|
||||||
|
const upcomingMatches = matches.filter((match) => new Date(match.match_datetime).getTime() > now.getTime())
|
||||||
|
|
||||||
|
return { matches: upcomingMatches, now }
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { formatPersianNumber } from '../utils/formatNumber'
|
import { formatPersianNumber } from '../utils/formatNumber'
|
||||||
|
import { createCafeSchedule, MatchEvent } from '../services/campaignApi'
|
||||||
const matchOptions = [
|
import { useMatchEvents } from '../hooks/useMatchEvents'
|
||||||
'برزیل-مراکش (۳ روز دیگر-۲:۰۰)',
|
import { formatMatchLabel } from '../utils/matchCountdown'
|
||||||
'آرژانتین-فرانسه (۲ روز دیگر-۱۸:۰۰)',
|
import { getCafeUuid } from '../utils/cafe'
|
||||||
'اسپانیا-آلمان (۱ روز دیگر-۲۰:۳۰)',
|
|
||||||
]
|
|
||||||
|
|
||||||
const ENTRY_FEE_STEP = 10000
|
const ENTRY_FEE_STEP = 10000
|
||||||
const DESCRIPTION_MAX_LENGTH = 200
|
const DESCRIPTION_MAX_LENGTH = 200
|
||||||
|
|
@ -51,8 +49,19 @@ function PlusIcon({ className = '' }: { className?: string }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MatchSelect({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
function MatchSelect({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
now,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
options: MatchEvent[]
|
||||||
|
now: Date
|
||||||
|
onChange: (uuid: string) => void
|
||||||
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const selectedMatch = options.find((match) => match.uuid === value)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative mt-5">
|
<div className="relative mt-5">
|
||||||
|
|
@ -62,25 +71,27 @@ function MatchSelect({ value, onChange }: { value: string; onChange: (value: str
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 text-right"
|
className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 text-right"
|
||||||
>
|
>
|
||||||
<span className={value ? 'text-gray-900' : 'font-light text-[#999999]'}>{value || 'بازی مورد نظر را انتخاب کنید'}</span>
|
<span className={selectedMatch ? 'text-gray-900' : 'font-light text-[#999999]'}>
|
||||||
|
{selectedMatch ? formatMatchLabel(selectedMatch, now) : 'بازی مورد نظر را انتخاب کنید'}
|
||||||
|
</span>
|
||||||
<ChevronDownIcon className={open ? 'rotate-180' : ''} />
|
<ChevronDownIcon className={open ? 'rotate-180' : ''} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||||
<div className="absolute inset-x-0 top-full z-20 mt-2 overflow-hidden rounded-xl bg-white shadow-lg">
|
<div className="absolute inset-x-0 top-full z-20 mt-2 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg">
|
||||||
{matchOptions.map((option) => (
|
{options.map((match) => (
|
||||||
<button
|
<button
|
||||||
key={option}
|
key={match.uuid}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(option)
|
onChange(match.uuid)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}}
|
}}
|
||||||
className="block w-full border-b border-gray-100 px-4 py-3 text-right text-gray-900 last:border-b-0 hover:bg-gray-50"
|
className="block w-full border-b border-gray-100 px-4 py-3 text-right text-gray-900 last:border-b-0 hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
{option}
|
{formatMatchLabel(match, now)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -181,10 +192,31 @@ function DescriptionField({ value, onChange }: { value: string; onChange: (value
|
||||||
|
|
||||||
export default function AddEvent() {
|
export default function AddEvent() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [match, setMatch] = useState('')
|
const [selectedMatchUuid, setSelectedMatchUuid] = useState('')
|
||||||
|
const { matches, now } = useMatchEvents()
|
||||||
const [capacity, setCapacity] = useState(1)
|
const [capacity, setCapacity] = useState(1)
|
||||||
const [entryFee, setEntryFee] = useState(100000)
|
const [entryFee, setEntryFee] = useState(100000)
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setError('')
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
await createCafeSchedule(getCafeUuid() ?? '', {
|
||||||
|
match_event_uuid: selectedMatchUuid,
|
||||||
|
total_tickets: capacity,
|
||||||
|
ticket_price: String(entryFee),
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
navigate('/owner/events/success')
|
||||||
|
} catch {
|
||||||
|
setError('ثبت رویداد با خطا مواجه شد. لطفاً دوباره تلاش کنید.')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white pb-24">
|
<div className="min-h-screen bg-white pb-24">
|
||||||
|
|
@ -196,11 +228,11 @@ export default function AddEvent() {
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<MatchSelect value={match} onChange={setMatch} />
|
<MatchSelect value={selectedMatchUuid} options={matches} now={now} onChange={setSelectedMatchUuid} />
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<label className="mb-3 block text-right text-lg font-normal text-gray-900">ظرفیت</label>
|
<label className="mb-3 block text-right text-lg font-normal text-gray-900">ظرفیت</label>
|
||||||
<Stepper value={capacity} step={1} min={1} suffix="نفر" onChange={setCapacity} />
|
<Stepper value={capacity} step={1} min={1} suffix="نفر" editable onChange={setCapacity} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
|
|
@ -209,16 +241,19 @@ export default function AddEvent() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DescriptionField value={description} onChange={setDescription} />
|
<DescriptionField value={description} onChange={setDescription} />
|
||||||
|
|
||||||
|
{error && <p className="mt-4 text-right text-sm text-red-500">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="fixed inset-x-0 bottom-0 flex justify-end bg-white px-4 py-4">
|
<div className="pointer-events-none fixed inset-x-0 bottom-0 flex justify-end bg-white px-4 py-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate('/owner/events/success')}
|
disabled={isSubmitting}
|
||||||
className="flex items-center gap-2 rounded-full bg-[#223D5D] px-6 py-3 font-semibold text-white"
|
onClick={handleSubmit}
|
||||||
|
className="pointer-events-auto flex items-center gap-2 rounded-full bg-[#223D5D] px-6 py-3 font-semibold text-white disabled:opacity-50"
|
||||||
>
|
>
|
||||||
ادامه
|
{isSubmitting ? 'در حال ارسال...' : 'ادامه'}
|
||||||
<ChevronIcon className="h-4 w-4" />
|
{!isSubmitting && <ChevronIcon className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,11 @@ import { ReactNode, useState } from "react";
|
||||||
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
import { useNavigate, useParams, useLocation } from "react-router-dom";
|
||||||
import { MapContainer, TileLayer, Marker, ZoomControl } from "react-leaflet";
|
import { MapContainer, TileLayer, Marker, ZoomControl } from "react-leaflet";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import L from "leaflet";
|
import "../utils/leafletIcons";
|
||||||
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 { mockCafes, Cafe } from "../data/mockCafes";
|
||||||
import InfoIcon from "../components/InfoIcon";
|
import InfoIcon from "../components/InfoIcon";
|
||||||
import ReservationModal from "../components/ReservationModal";
|
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() {
|
function ChevronLeftIcon() {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,23 @@
|
||||||
import { ReactNode, useState } from "react";
|
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { MapContainer, TileLayer, Marker, ZoomControl, useMapEvents } from "react-leaflet";
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import "../utils/leafletIcons";
|
||||||
import InfoIcon from "../components/InfoIcon";
|
import InfoIcon from "../components/InfoIcon";
|
||||||
import { mockCafeFeatures } from "../data/mockCafeFeatures";
|
import {
|
||||||
|
CafeFeature,
|
||||||
|
City,
|
||||||
|
fetchCafeFeatures,
|
||||||
|
fetchCities,
|
||||||
|
registerCafe,
|
||||||
|
registerCafeOwner,
|
||||||
|
} from "../services/campaignApi";
|
||||||
|
import { getCafeUuid, setCafeUuid } from "../utils/cafe";
|
||||||
|
import { toEnglishDigits } from "../utils/formatNumber";
|
||||||
|
import { toE164 } from "../utils/phone";
|
||||||
|
|
||||||
|
// Tehran — default pin until the owner picks their cafe's actual location
|
||||||
|
const DEFAULT_CAFE_LOCATION = { lat: 35.6892, lng: 51.389 };
|
||||||
|
|
||||||
function ArrowIcon({ className = "" }: { className?: string }) {
|
function ArrowIcon({ className = "" }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -19,6 +35,20 @@ function ArrowIcon({ className = "" }: { className?: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChevronDownIcon({ className = "" }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`h-5 w-5 shrink-0 text-gray-400 transition-transform ${className}`}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CheckIcon() {
|
function CheckIcon() {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -102,6 +132,117 @@ function FormField({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CitySelectField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
options: City[];
|
||||||
|
onChange: (uuid: string) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selectedCity = options.find((city) => city.uuid === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mt-5">
|
||||||
|
<label className="mb-3 block text-right text-lg font-normal text-gray-900">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="flex h-14 w-full items-center justify-between rounded-2xl border border-gray-200 px-4 text-right"
|
||||||
|
>
|
||||||
|
<span className={selectedCity ? "text-gray-900" : "font-light text-[#999999]"}>
|
||||||
|
{selectedCity ? selectedCity.city_name : "شهر کافه را انتخاب کنید"}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon className={open ? "rotate-180" : ""} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||||
|
<div className="absolute inset-x-0 top-full z-20 mt-2 max-h-60 overflow-y-auto rounded-xl bg-white shadow-lg">
|
||||||
|
{options.map((city) => (
|
||||||
|
<button
|
||||||
|
key={city.uuid}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(city.uuid);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="block w-full border-b border-gray-100 px-4 py-3 text-right text-gray-900 last:border-b-0 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{city.city_name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocationPicker({ onSelect }: { onSelect: (lat: number, lng: number) => void }) {
|
||||||
|
// keep the handlers object reference stable — react-leaflet re-binds map
|
||||||
|
// listeners (map.off/map.on) whenever this reference changes, and doing
|
||||||
|
// that mid-drag (e.g. triggered by an unrelated re-render) was silently
|
||||||
|
// aborting the user's pan gesture. a ref lets onSelect stay fresh without
|
||||||
|
// recreating the object every render.
|
||||||
|
const onSelectRef = useRef(onSelect);
|
||||||
|
useEffect(() => {
|
||||||
|
onSelectRef.current = onSelect;
|
||||||
|
}, [onSelect]);
|
||||||
|
|
||||||
|
const handlers = useMemo(
|
||||||
|
() => ({
|
||||||
|
click(e: { latlng: { lat: number; lng: number } }) {
|
||||||
|
onSelectRef.current(e.latlng.lat, e.latlng.lng);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useMapEvents(handlers);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CafeLocationMap({
|
||||||
|
location,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
location: { lat: number; lng: number };
|
||||||
|
onChange: (lat: number, lng: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mt-5">
|
||||||
|
<label className="mb-3 block text-right text-lg font-normal text-gray-900">
|
||||||
|
موقعیت کافه روی نقشه
|
||||||
|
</label>
|
||||||
|
<MapContainer
|
||||||
|
center={[location.lat, location.lng]}
|
||||||
|
zoom={15}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
zoomControl={false}
|
||||||
|
className="h-48 w-full overflow-hidden rounded-2xl"
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution="© Addwin Maps"
|
||||||
|
url="https://maps.addwin.ir/styles/basic/{z}/{x}/{y}.png"
|
||||||
|
maxZoom={19}
|
||||||
|
minZoom={8}
|
||||||
|
/>
|
||||||
|
<Marker position={[location.lat, location.lng]} />
|
||||||
|
<ZoomControl position="topright" />
|
||||||
|
<LocationPicker onSelect={onChange} />
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UploadBox({
|
function UploadBox({
|
||||||
label,
|
label,
|
||||||
buttonLabel,
|
buttonLabel,
|
||||||
|
|
@ -165,10 +306,34 @@ export default function CafeOwnerRegister() {
|
||||||
const [cardNumber, setCardNumber] = useState("");
|
const [cardNumber, setCardNumber] = useState("");
|
||||||
|
|
||||||
const [cafeName, setCafeName] = useState("");
|
const [cafeName, setCafeName] = useState("");
|
||||||
|
const [selectedCityUuid, setSelectedCityUuid] = useState("");
|
||||||
const [address, setAddress] = useState("");
|
const [address, setAddress] = useState("");
|
||||||
const [cafePhone, setCafePhone] = useState("");
|
const [cafePhone, setCafePhone] = useState("");
|
||||||
|
const [cafeLocation, setCafeLocation] = useState(DEFAULT_CAFE_LOCATION);
|
||||||
const [selectedFeatures, setSelectedFeatures] = useState<string[]>([]);
|
const [selectedFeatures, setSelectedFeatures] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [cities, setCities] = useState<City[]>([]);
|
||||||
|
const [cafeFeatures, setCafeFeatures] = useState<CafeFeature[]>([]);
|
||||||
|
// returned by /campaign/cafe-owners/ — sent as owner_uuid in the final /campaign/cafes/ request
|
||||||
|
const [ownerUuid, setOwnerUuid] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialStep === 1 && getCafeUuid()) {
|
||||||
|
navigate("/owner/panel", { replace: true });
|
||||||
|
}
|
||||||
|
}, [initialStep, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCities()
|
||||||
|
.then(setCities)
|
||||||
|
.catch(() => {});
|
||||||
|
fetchCafeFeatures()
|
||||||
|
.then(setCafeFeatures)
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleFeature = (feature: string) => {
|
const toggleFeature = (feature: string) => {
|
||||||
setSelectedFeatures((prev) =>
|
setSelectedFeatures((prev) =>
|
||||||
prev.includes(feature)
|
prev.includes(feature)
|
||||||
|
|
@ -177,6 +342,39 @@ export default function CafeOwnerRegister() {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleContinue = async () => {
|
||||||
|
setError("");
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
if (step === 1) {
|
||||||
|
const owner = await registerCafeOwner({
|
||||||
|
full_name: fullName,
|
||||||
|
phone_number: toE164(phone),
|
||||||
|
national_id: toEnglishDigits(nationalId),
|
||||||
|
pan_number: toEnglishDigits(cardNumber),
|
||||||
|
});
|
||||||
|
setOwnerUuid(owner.uuid);
|
||||||
|
setStep(2);
|
||||||
|
} else {
|
||||||
|
const cafe = await registerCafe({
|
||||||
|
owner_uuid: ownerUuid,
|
||||||
|
city_uuid: selectedCityUuid,
|
||||||
|
name: cafeName,
|
||||||
|
phone_number: cafePhone ? toE164(cafePhone) : "",
|
||||||
|
location: { type: "Point", coordinates: [cafeLocation.lng, cafeLocation.lat] },
|
||||||
|
address,
|
||||||
|
feature_uuids: selectedFeatures,
|
||||||
|
});
|
||||||
|
setCafeUuid(cafe.uuid);
|
||||||
|
navigate("/owner/register/success");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("ثبت اطلاعات با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white pb-24">
|
<div className="min-h-screen bg-white pb-24">
|
||||||
<div className="h-16 w-full bg-gray-200" />
|
<div className="h-16 w-full bg-gray-200" />
|
||||||
|
|
@ -230,6 +428,12 @@ export default function CafeOwnerRegister() {
|
||||||
value={cafeName}
|
value={cafeName}
|
||||||
onChange={setCafeName}
|
onChange={setCafeName}
|
||||||
/>
|
/>
|
||||||
|
<CitySelectField
|
||||||
|
label="شهر"
|
||||||
|
value={selectedCityUuid}
|
||||||
|
options={cities}
|
||||||
|
onChange={setSelectedCityUuid}
|
||||||
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
label="آدرس"
|
label="آدرس"
|
||||||
placeholder="موقعیت کافه را با جزئیات وارد کنید"
|
placeholder="موقعیت کافه را با جزئیات وارد کنید"
|
||||||
|
|
@ -242,6 +446,10 @@ export default function CafeOwnerRegister() {
|
||||||
value={cafePhone}
|
value={cafePhone}
|
||||||
onChange={setCafePhone}
|
onChange={setCafePhone}
|
||||||
/>
|
/>
|
||||||
|
<CafeLocationMap
|
||||||
|
location={cafeLocation}
|
||||||
|
onChange={(lat, lng) => setCafeLocation({ lat, lng })}
|
||||||
|
/>
|
||||||
|
|
||||||
<UploadBox label="تصاویر (اختیاری)" buttonLabel="افزودن تصویر" />
|
<UploadBox label="تصاویر (اختیاری)" buttonLabel="افزودن تصویر" />
|
||||||
<UploadBox label="مجوز کسب و کار" buttonLabel="افزودن تصویر مجوز" />
|
<UploadBox label="مجوز کسب و کار" buttonLabel="افزودن تصویر مجوز" />
|
||||||
|
|
@ -251,36 +459,37 @@ export default function CafeOwnerRegister() {
|
||||||
ویژگیهای کافه
|
ویژگیهای کافه
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
{mockCafeFeatures.map((feature) => (
|
{cafeFeatures.map((feature) => (
|
||||||
<FeatureChip
|
<FeatureChip
|
||||||
key={feature.id}
|
key={feature.uuid}
|
||||||
label={feature.label}
|
label={feature.name}
|
||||||
checked={selectedFeatures.includes(feature.id)}
|
checked={selectedFeatures.includes(feature.uuid)}
|
||||||
onToggle={() => toggleFeature(feature.id)}
|
onToggle={() => toggleFeature(feature.uuid)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{error && <p className="mt-4 text-right text-sm text-red-500">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="fixed inset-x-0 bottom-0 flex items-center justify-between gap-3 bg-white px-4 py-4">
|
<div className="pointer-events-none fixed inset-x-0 bottom-0 flex items-center justify-between gap-3 bg-white px-4 py-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white"
|
className="pointer-events-auto rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white"
|
||||||
>
|
>
|
||||||
پشتیبانی
|
پشتیبانی
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
disabled={isSubmitting}
|
||||||
step === 1 ? setStep(2) : navigate("/owner/register/success")
|
onClick={handleContinue}
|
||||||
}
|
className="pointer-events-auto flex items-center gap-2 rounded-full bg-[#223D5D] px-6 py-3 font-semibold text-white disabled:opacity-50"
|
||||||
className="flex items-center gap-2 rounded-full bg-[#223D5D] px-6 py-3 font-semibold text-white"
|
|
||||||
>
|
>
|
||||||
ادامه
|
{isSubmitting ? "در حال ارسال..." : "ادامه"}
|
||||||
<ArrowIcon className="h-4 w-4" />
|
{!isSubmitting && <ArrowIcon className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,11 @@ import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import BookIcon from '../components/BookIcon'
|
import BookIcon from '../components/BookIcon'
|
||||||
import { mockCafes, Cafe } from '../data/mockCafes'
|
import { mockCafes, Cafe } from '../data/mockCafes'
|
||||||
|
import { MatchEvent } from '../services/campaignApi'
|
||||||
|
import { useMatchEvents } from '../hooks/useMatchEvents'
|
||||||
|
import { formatMatchLabel } from '../utils/matchCountdown'
|
||||||
|
|
||||||
const cityOptions = ['تهران', 'اصفهان', 'مشهد']
|
const cityOptions = ['تهران', 'اصفهان', 'مشهد']
|
||||||
const matchOptions = [
|
|
||||||
'برزیل-مراکش (۳ روز دیگر-۲:۰۰)',
|
|
||||||
'آرژانتین-فرانسه (۲ روز دیگر-۱۸:۰۰)',
|
|
||||||
'اسپانیا-آلمان (۱ روز دیگر-۲۰:۳۰)',
|
|
||||||
]
|
|
||||||
|
|
||||||
function ChevronIcon({ className = '' }: { className?: string }) {
|
function ChevronIcon({ className = '' }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -118,6 +116,58 @@ function SelectorRow({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MatchSelectorRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
now,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
options: MatchEvent[]
|
||||||
|
now: Date
|
||||||
|
onChange: (uuid: string) => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const selectedMatch = options.find((match) => match.uuid === value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative px-4 mt-4">
|
||||||
|
<p className="text-sm text-gray-500 mb-1">{label}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="w-full flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-4 py-3"
|
||||||
|
>
|
||||||
|
<span className="text-gray-900">{selectedMatch ? formatMatchLabel(selectedMatch, now) : ''}</span>
|
||||||
|
<ChevronDownIcon className={open ? 'rotate-180' : ''} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||||
|
<div className="absolute inset-x-4 top-full z-20 mt-2 max-h-72 overflow-y-auto rounded-xl bg-white shadow-lg">
|
||||||
|
{options.map((match) => (
|
||||||
|
<button
|
||||||
|
key={match.uuid}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(match.uuid)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
className="block w-full border-b border-gray-100 px-4 py-3 text-right text-gray-900 last:border-b-0 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{formatMatchLabel(match, now)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function MyReservationsRow() {
|
function MyReservationsRow() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
|
@ -157,7 +207,9 @@ function CafeCard({ cafe }: { cafe: Cafe }) {
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [view, setView] = useState<'list' | 'map'>('list')
|
const [view, setView] = useState<'list' | 'map'>('list')
|
||||||
const [city, setCity] = useState(cityOptions[0])
|
const [city, setCity] = useState(cityOptions[0])
|
||||||
const [match, setMatch] = useState(matchOptions[0])
|
const [selectedMatchUuid, setSelectedMatchUuid] = useState('')
|
||||||
|
const { matches, now } = useMatchEvents()
|
||||||
|
const effectiveMatchUuid = selectedMatchUuid || matches[0]?.uuid || ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white pb-24">
|
<div className="min-h-screen bg-white pb-24">
|
||||||
|
|
@ -166,7 +218,13 @@ export default function Home() {
|
||||||
<MyReservationsRow />
|
<MyReservationsRow />
|
||||||
|
|
||||||
<SelectorRow label="شهر" value={city} options={cityOptions} onChange={setCity} />
|
<SelectorRow label="شهر" value={city} options={cityOptions} onChange={setCity} />
|
||||||
<SelectorRow label="بازی انتخابی" value={match} options={matchOptions} onChange={setMatch} />
|
<MatchSelectorRow
|
||||||
|
label="بازی انتخابی"
|
||||||
|
value={effectiveMatchUuid}
|
||||||
|
options={matches}
|
||||||
|
now={now}
|
||||||
|
onChange={setSelectedMatchUuid}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-6 flex items-center justify-between px-4">
|
<div className="mt-6 flex items-center justify-between px-4">
|
||||||
<h2 className="font-semibold text-gray-900">کافهها</h2>
|
<h2 className="font-semibold text-gray-900">کافهها</h2>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { requestOtp, verifyOtp } from '../services/authApi'
|
import { requestOtp, verifyOtp } from '../services/authApi'
|
||||||
|
import { fetchMyCafes } from '../services/campaignApi'
|
||||||
import { storeAuthTokens } from '../utils/authToken'
|
import { storeAuthTokens } from '../utils/authToken'
|
||||||
|
import { setCafeUuid } from '../utils/cafe'
|
||||||
import { toEnglishDigits, toPersianDigits } from '../utils/formatNumber'
|
import { toEnglishDigits, toPersianDigits } from '../utils/formatNumber'
|
||||||
import { toE164 } from '../utils/phone'
|
import { toE164 } from '../utils/phone'
|
||||||
import { getUserRole } from '../utils/role'
|
import { getUserRole } from '../utils/role'
|
||||||
|
|
@ -106,7 +108,18 @@ export default function LoginOtp() {
|
||||||
const tokens = await verifyOtp(toE164(phone), otp.join(''))
|
const tokens = await verifyOtp(toE164(phone), otp.join(''))
|
||||||
storeAuthTokens(tokens.access_token, tokens.refresh_token)
|
storeAuthTokens(tokens.access_token, tokens.refresh_token)
|
||||||
const role = getUserRole()
|
const role = getUserRole()
|
||||||
navigate(role === 'cafe_owner' ? '/owner/panel' : '/')
|
|
||||||
|
if (role === 'cafe_owner') {
|
||||||
|
const myCafes = await fetchMyCafes().catch(() => [])
|
||||||
|
if (myCafes.length > 0) {
|
||||||
|
setCafeUuid(myCafes[0].uuid)
|
||||||
|
navigate('/owner/panel')
|
||||||
|
} else {
|
||||||
|
navigate('/owner/register')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('کد وارد شده صحیح نیست')
|
setError('کد وارد شده صحیح نیست')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
131
src/services/campaignApi.ts
Normal file
131
src/services/campaignApi.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import api from './api'
|
||||||
|
|
||||||
|
export interface RegisterCafeOwnerPayload {
|
||||||
|
full_name: string
|
||||||
|
phone_number: string
|
||||||
|
national_id: string
|
||||||
|
pan_number: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CafeOwnerResponse {
|
||||||
|
uuid: string
|
||||||
|
user_uuid: string
|
||||||
|
full_name: string
|
||||||
|
phone_number: string
|
||||||
|
national_id: string
|
||||||
|
pan_number: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerCafeOwner(payload: RegisterCafeOwnerPayload): Promise<CafeOwnerResponse> {
|
||||||
|
const { data } = await api.post<CafeOwnerResponse>('/campaign/cafe-owners/', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface City {
|
||||||
|
uuid: string
|
||||||
|
city_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCities(): Promise<City[]> {
|
||||||
|
const { data } = await api.get<City[]>('/campaign/cities/')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchEvent {
|
||||||
|
uuid: string
|
||||||
|
title: string
|
||||||
|
team_home: string
|
||||||
|
team_away: string
|
||||||
|
match_datetime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMatchEvents(): Promise<MatchEvent[]> {
|
||||||
|
const { data } = await api.get<MatchEvent[]>('/campaign/match-events/')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CafeFeature {
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
icon: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCafeFeatures(): Promise<CafeFeature[]> {
|
||||||
|
const { data } = await api.get<CafeFeature[]>('/campaign/cafe-features/')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoPoint {
|
||||||
|
type: 'Point'
|
||||||
|
coordinates: [number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterCafePayload {
|
||||||
|
owner_uuid: string
|
||||||
|
city_uuid: string
|
||||||
|
name: string
|
||||||
|
phone_number: string
|
||||||
|
location: GeoPoint
|
||||||
|
address: string
|
||||||
|
feature_uuids: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CafeResponse extends RegisterCafePayload {
|
||||||
|
uuid: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerCafe(payload: RegisterCafePayload): Promise<CafeResponse> {
|
||||||
|
const { data } = await api.post<CafeResponse>('/campaign/cafes/', payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CafeMedia {
|
||||||
|
uuid: string
|
||||||
|
cafe_uuid: string
|
||||||
|
url: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyCafe {
|
||||||
|
uuid: string
|
||||||
|
owner_uuid: string
|
||||||
|
name: string
|
||||||
|
phone_number: string
|
||||||
|
location: GeoPoint
|
||||||
|
address: string
|
||||||
|
is_approved: boolean
|
||||||
|
created_at: string
|
||||||
|
city: City
|
||||||
|
features: CafeFeature[]
|
||||||
|
media: CafeMedia[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMyCafes(): Promise<MyCafe[]> {
|
||||||
|
// bare array, same as /campaign/cities/, /campaign/cafe-features/, etc. — NOT wrapped in { success, data }
|
||||||
|
const { data } = await api.get<MyCafe[]>('/campaign/cafes/my/')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CafeSchedule {
|
||||||
|
uuid: string
|
||||||
|
cafe_uuid: string
|
||||||
|
match_event: MatchEvent
|
||||||
|
total_tickets: number
|
||||||
|
ticket_price: string
|
||||||
|
remaining_tickets: number
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCafeSchedulePayload {
|
||||||
|
match_event_uuid: string
|
||||||
|
total_tickets: number
|
||||||
|
ticket_price: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCafeSchedule(cafeUuid: string, payload: CreateCafeSchedulePayload): Promise<CafeSchedule> {
|
||||||
|
const { data } = await api.post<CafeSchedule>(`/campaign/cafes/${cafeUuid}/schedules/`, payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
9
src/utils/cafe.ts
Normal file
9
src/utils/cafe.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
const CAFE_UUID_STORAGE_KEY = 'cafe_uuid'
|
||||||
|
|
||||||
|
export function setCafeUuid(uuid: string) {
|
||||||
|
localStorage.setItem(CAFE_UUID_STORAGE_KEY, uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCafeUuid(): string | null {
|
||||||
|
return localStorage.getItem(CAFE_UUID_STORAGE_KEY)
|
||||||
|
}
|
||||||
12
src/utils/leafletIcons.ts
Normal file
12
src/utils/leafletIcons.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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'
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
37
src/utils/matchCountdown.ts
Normal file
37
src/utils/matchCountdown.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { toPersianDigits } from './formatNumber'
|
||||||
|
|
||||||
|
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
function formatKickoffTime(matchDate: Date) {
|
||||||
|
const time = matchDate.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
return toPersianDigits(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
// under 24h: live HH:MM:SS countdown. otherwise: "X روز دیگر". both paired with the kickoff clock time.
|
||||||
|
export function formatMatchCountdown(matchDatetimeIso: string, now: Date): string {
|
||||||
|
const matchDate = new Date(matchDatetimeIso)
|
||||||
|
const diffMs = matchDate.getTime() - now.getTime()
|
||||||
|
const kickoffTime = formatKickoffTime(matchDate)
|
||||||
|
|
||||||
|
if (diffMs <= 0) {
|
||||||
|
return `اکنون-${kickoffTime}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffMs >= ONE_DAY_MS) {
|
||||||
|
const days = Math.floor(diffMs / ONE_DAY_MS)
|
||||||
|
return `${toPersianDigits(String(days))} روز دیگر-${kickoffTime}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSeconds = Math.floor(diffMs / 1000)
|
||||||
|
const hh = String(Math.floor(totalSeconds / 3600)).padStart(2, '0')
|
||||||
|
const mm = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0')
|
||||||
|
const ss = String(totalSeconds % 60).padStart(2, '0')
|
||||||
|
return `${toPersianDigits(`${hh}:${mm}:${ss}`)}-${kickoffTime}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMatchLabel(
|
||||||
|
match: { team_home: string; team_away: string; match_datetime: string },
|
||||||
|
now: Date,
|
||||||
|
): string {
|
||||||
|
return `${match.team_home}-${match.team_away} (${formatMatchCountdown(match.match_datetime, now)})`
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue