feat: support added to the app

This commit is contained in:
Afra ‌ 2026-07-22 11:31:14 +03:30
parent 9382cdb666
commit 18996426ac
8 changed files with 139 additions and 36 deletions

View file

@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.3327 10.7493V14.2493C13.3327 17.166 12.166 18.3327 9.24935 18.3327H5.74935C2.83268 18.3327 1.66602 17.166 1.66602 14.2493V10.7493C1.66602 7.83268 2.83268 6.66602 5.74935 6.66602H9.24935C12.166 6.66602 13.3327 7.83268 13.3327 10.7493Z" stroke="#376195" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.3327 5.74935V9.24935C18.3327 12.166 17.166 13.3327 14.2493 13.3327H13.3327V10.7493C13.3327 7.83268 12.166 6.66602 9.24935 6.66602H6.66602V5.74935C6.66602 2.83268 7.83268 1.66602 10.7493 1.66602H14.2493C17.166 1.66602 18.3327 2.83268 18.3327 5.74935Z" stroke="#376195" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 785 B

View file

@ -5,6 +5,7 @@
// icons are referenced and tree-shake the rest — this file is the *only* place all 17 // icons are referenced and tree-shake the rest — this file is the *only* place all 17
// icons are ever imported, so an icon that no call site uses never makes it into a chunk. // icons are ever imported, so an icon that no call site uses never makes it into a chunk.
import ArrowRight from '../../assets/icons/arrow-right.svg?react' import ArrowRight from '../../assets/icons/arrow-right.svg?react'
import Copy from '../../assets/icons/copy.svg?react'
import Book from '../../assets/icons/book.svg?react' import Book from '../../assets/icons/book.svg?react'
import Calendar from '../../assets/icons/calendar.svg?react' import Calendar from '../../assets/icons/calendar.svg?react'
import Call from '../../assets/icons/call.svg?react' import Call from '../../assets/icons/call.svg?react'
@ -28,6 +29,7 @@ import Success from '../../assets/icons/success.svg?react'
// is used, with no second list to keep in sync. // is used, with no second list to keep in sync.
export const icons = { export const icons = {
arrowRight: ArrowRight, arrowRight: ArrowRight,
copy: Copy,
book: Book, book: Book,
calendar: Calendar, calendar: Calendar,
call: Call, call: Call,

View file

@ -0,0 +1,76 @@
import { useState } from 'react'
import { createPortal } from 'react-dom'
import Icon from './Icon'
const SUPPORT_PHONE = '02111111111'
const SUPPORT_PHONE_DISPLAY = '۰۲۱-۱۱۱۱۱۱۱۱'
function isMobileDevice() {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
}
export default function SupportButton({ className }: { className?: string }) {
const [open, setOpen] = useState(false)
const [copied, setCopied] = useState(false)
const handleClick = () => {
if (isMobileDevice()) {
window.location.href = `tel:${SUPPORT_PHONE}`
} else {
setOpen(true)
}
}
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(SUPPORT_PHONE)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// silent fallback
}
}
return (
<>
<button
type="button"
onClick={handleClick}
className={className ?? 'rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white'}
>
پشتیبانی
</button>
{open &&
createPortal(
<>
<div className="fixed inset-0 z-9998" onClick={() => setOpen(false)} />
<div className="fixed inset-x-4 top-20 z-9999 rounded-2xl bg-white px-5 py-4 shadow-xl sm:inset-x-auto sm:left-1/2 sm:w-96 sm:-translate-x-1/2">
<button
type="button"
onClick={() => setOpen(false)}
className="absolute left-4 top-4 flex h-7 w-7 items-center justify-center text-gray-400 hover:text-gray-600"
aria-label="بستن"
>
✕
</button>
<div className="flex items-center justify-end gap-3 pt-1">
<button
type="button"
onClick={handleCopy}
aria-label="کپی شماره"
className="shrink-0 text-gray-400 transition-colors hover:text-gray-700"
>
<Icon name="copy" className="h-5 w-5" />
</button>
<p dir="ltr" className="text-right text-gray-900">
{copied ? 'کپی شد!' : `شماره تماس پشتیبانی: ${SUPPORT_PHONE_DISPLAY}`}
</p>
</div>
</div>
</>,
document.body,
)}
</>
)
}

View file

@ -1,6 +1,7 @@
import { ReactNode, useEffect, useState } from 'react' import { ReactNode, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import Icon from '../components/Icon' import Icon from '../components/Icon'
import SupportButton from '../components/SupportButton'
import { useNow } from '../hooks/useNow' import { useNow } from '../hooks/useNow'
import { CafeSchedule, fetchCafeSchedules } from '../services/campaignApi' import { CafeSchedule, fetchCafeSchedules } from '../services/campaignApi'
import { getCafeUuid } from '../utils/cafe' import { getCafeUuid } from '../utils/cafe'
@ -96,9 +97,7 @@ export default function CafeOwnerPanel() {
</div> </div>
</div> </div>
<button type="button" className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 text-white shadow-lg"> <SupportButton className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white shadow-lg" />
پشتیبانی
</button>
</div> </div>
) )
} }

View file

@ -6,6 +6,7 @@ import { MapContainer, TileLayer, Marker, ZoomControl, useMapEvents } from "reac
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import "../utils/leafletIcons"; import "../utils/leafletIcons";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import SupportButton from "../components/SupportButton";
import { import {
CafeFeature, CafeFeature,
CafeMediaItem, CafeMediaItem,
@ -831,12 +832,7 @@ export default function CafeOwnerRegister() {
</div> </div>
<div className="pointer-events-none 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 <SupportButton className="pointer-events-auto rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white" />
type="button"
className="pointer-events-auto rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white"
>
پشتیبانی
</button>
<button <button
type="button" type="button"
disabled={isSubmitting} disabled={isSubmitting}

View file

@ -2,10 +2,15 @@ import { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useLocation, useNavigate, useParams } from "react-router-dom";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { CafeEvent, mockCafeEvents } from "../data/mockCafeEvents"; import { CafeEvent, mockCafeEvents } from "../data/mockCafeEvents";
import { EventReservation, fetchEventReservations } from "../services/campaignApi"; import {
EventReservation,
fetchEventReservations,
participateReservation,
} from "../services/campaignApi";
import { getCafeUuid } from "../utils/cafe"; import { getCafeUuid } from "../utils/cafe";
import HostBanner from "../assets/images/banners/Host-banner.jpg"; import HostBanner from "../assets/images/banners/Host-banner.jpg";
import { formatPersianNumber } from "../utils/formatNumber"; import { formatPersianNumber } from "../utils/formatNumber";
import { fromE164 } from "../utils/phone";
function toEnglishDigits(value: string) { function toEnglishDigits(value: string) {
const persianDigits = "۰۱۲۳۴۵۶۷۸۹"; const persianDigits = "۰۱۲۳۴۵۶۷۸۹";
@ -47,13 +52,14 @@ function downloadReservationsCsv(reservations: EventReservation[]) {
function ReservationCard({ function ReservationCard({
reservation, reservation,
selected, isToggling,
onToggle, onToggle,
}: { }: {
reservation: EventReservation; reservation: EventReservation;
selected: boolean; isToggling: boolean;
onToggle: () => void; onToggle: () => void;
}) { }) {
const participated = reservation.is_participated;
return ( return (
<div className="flex items-center justify-between rounded-2xl shadow-[0px_4px_12px_0px_#0000001F] p-6"> <div className="flex items-center justify-between rounded-2xl shadow-[0px_4px_12px_0px_#0000001F] p-6">
<div className="flex flex-col items-start gap-2 text-right"> <div className="flex flex-col items-start gap-2 text-right">
@ -66,15 +72,19 @@ function ReservationCard({
</span> </span>
</div> </div>
<p className="text-gray-900">{reservation.full_name}</p> <p className="text-gray-900">{reservation.full_name}</p>
<p className="text-gray-500">{reservation.phone_number}</p> <p dir="ltr" className="text-left text-gray-500">{fromE164(reservation.phone_number)}</p>
</div>
<div className="flex flex-col items-center self-end gap-2">
<button
type="button"
onClick={onToggle}
disabled={isToggling}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[1.5px] border-[#223D5D] transition-colors disabled:opacity-50 ${participated ? "bg-[#223D5D]" : "bg-transparent"}`}
>
{participated && <Icon name="check" className="h-3 w-3 text-white" />}
</button>
<p className="text-xs text-black">تایید ورود</p>
</div> </div>
<button
type="button"
onClick={onToggle}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-2 border-[#223D5D] ${selected ? "bg-[#223D5D]" : "bg-transparent"}`}
>
{selected && <Icon name="check" className="h-3 w-3 text-white" />}
</button>
</div> </div>
); );
} }
@ -84,7 +94,7 @@ export default function EventReservations() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [togglingIds, setTogglingIds] = useState<Set<string>>(new Set());
const [reservations, setReservations] = useState<EventReservation[]>([]); const [reservations, setReservations] = useState<EventReservation[]>([]);
const event: CafeEvent | undefined = const event: CafeEvent | undefined =
@ -107,12 +117,24 @@ export default function EventReservations() {
[reservations, query], [reservations, query],
); );
const toggleSelected = (reservationId: string) => { const handleParticipate = async (reservation: EventReservation) => {
setSelectedIds((prev) => if (togglingIds.has(reservation.uuid)) return;
prev.includes(reservationId) const newValue = !reservation.is_participated;
? prev.filter((id) => id !== reservationId) setTogglingIds((prev) => new Set([...prev, reservation.uuid]));
: [...prev, reservationId], try {
); await participateReservation(reservation.uuid, newValue);
setReservations((prev) =>
prev.map((r) => (r.uuid === reservation.uuid ? { ...r, is_participated: newValue } : r)),
);
} catch {
// leave checkbox at current state on failure
} finally {
setTogglingIds((prev) => {
const next = new Set(prev);
next.delete(reservation.uuid);
return next;
});
}
}; };
const handleDownload = () => { const handleDownload = () => {
@ -128,7 +150,10 @@ export default function EventReservations() {
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
className="mt-4 flex w-full items-center justify-start gap-2 px-4" className="mt-4 flex w-full items-center justify-start gap-2 px-4"
> >
<Icon name="arrowRight" className="h-5 w-5 -scale-x-100 text-gray-900" /> <Icon
name="arrowRight"
className="h-5 w-5 -scale-x-100 text-gray-900"
/>
<span className="text-md font-semibold text-gray-900"> <span className="text-md font-semibold text-gray-900">
رزروهای {event?.matchLabel ?? ""} رزروهای {event?.matchLabel ?? ""}
</span> </span>
@ -153,8 +178,8 @@ export default function EventReservations() {
<ReservationCard <ReservationCard
key={reservation.uuid} key={reservation.uuid}
reservation={reservation} reservation={reservation}
selected={selectedIds.includes(reservation.uuid)} isToggling={togglingIds.has(reservation.uuid)}
onToggle={() => toggleSelected(reservation.uuid)} onToggle={() => handleParticipate(reservation)}
/> />
))} ))}
</div> </div>

View file

@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import SupportButton from '../components/SupportButton'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { MapContainer, TileLayer, Marker } from 'react-leaflet' import { MapContainer, TileLayer, Marker } from 'react-leaflet'
@ -351,12 +352,7 @@ export default function Home() {
</div> </div>
)} )}
<button <SupportButton className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 font-semibold text-white shadow-lg" />
type="button"
className="fixed bottom-4 right-4 rounded-full bg-[#223D5D] px-5 py-3 text-white shadow-lg"
>
پشتیبانی
</button>
</div> </div>
) )
} }

View file

@ -207,6 +207,7 @@ export interface EventReservation {
full_name: string full_name: string
phone_number: string phone_number: string
status: string status: string
is_participated: boolean
created_at: string created_at: string
} }
@ -215,6 +216,10 @@ export async function fetchEventReservations(cafeUuid: string, eventUuid: string
return data return data
} }
export async function participateReservation(uuid: string, isParticipated: boolean): Promise<void> {
await api.patch(`/campaign/reservations/${uuid}/participate/`, { is_participated: isParticipated })
}
export interface MyReservation { export interface MyReservation {
uuid: string uuid: string
reservation_code: string reservation_code: string