diff --git a/package.json b/package.json index b60e04a7..ae2e9364 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "app.salona", "private": true, - "version": "2.96.02", + "version": "2.96.03", "type": "module", "scripts": { "dev": "vite --host", diff --git a/public/Splash.gif b/public/Splash.gif new file mode 100644 index 00000000..917fa79d Binary files /dev/null and b/public/Splash.gif differ diff --git a/src/apps/appointment/utils/types.ts b/src/apps/appointment/utils/types.ts index 357deeba..ecb4f7a2 100644 --- a/src/apps/appointment/utils/types.ts +++ b/src/apps/appointment/utils/types.ts @@ -1,43 +1,43 @@ import { AbilityActionEnum } from "./enums"; export interface IRegisterForm { - name: string; - phone_number: string; - referral_code: string; - first_question_answer: string; - second_question_answer: string; - first_question: string; - second_question: string; + name: string; + phone_number: string; + referral_code: string; + first_question_answer: string; + second_question_answer: string; + first_question: string; + second_question: string; } export interface ILoginForm { - phone_number: string; + phone_number: string; } export interface IOTPForm { - phone_number: string; - otp_code: string; + phone_number: string; + otp_code: string; } export interface Service { - emoji: string; - name: string; - price: number; + emoji: string; + name: string; + price: number; } export interface IUser { - city: string; - date_of_birth: string; - id: number; - name: string; - phone_number: string; - businesscard_link: string; - profile_picture: string; - province: string; - score: number; - services: Service[]; - sms_balance: number; - specialty: string; - user_id: string; - wallet_balance: number; + city: string; + date_of_birth: string; + id: number; + name: string; + phone_number: string; + businesscard_link: string; + profile_picture: string; + province: string; + score: number; + services: Service[]; + sms_balance: number; + specialty: string; + user_id: string; + wallet_balance: number; } // export interface IUser { // id: number; @@ -57,543 +57,534 @@ export interface IUser { // specialty?: string; // } export interface IUserAbility { - ability_action: AbilityActionEnum; - active: boolean; - ability_expire_time: string; - remaining_time: number | "bonus" | "Expired"; + ability_action: AbilityActionEnum; + active: boolean; + ability_expire_time: string; + remaining_time: number | "bonus" | "Expired"; } export interface IUserSettings { - default_cashback_expire_days: number; - default_cashback_percentage: number; - default_cashback_active: boolean; - default_appointment_pattern: string | null; - default_appointment_sms: boolean; - default_reminder_pattern: string | null; - default_reminder_sms: boolean; - default_repair_pattern: string | null; - default_repair_sms: boolean; - default_survay_pattern: string | null; - default_survay_sms: boolean; - language: string; + default_cashback_expire_days: number; + default_cashback_percentage: number; + default_cashback_active: boolean; + default_appointment_pattern: string | null; + default_appointment_sms: boolean; + default_reminder_pattern: string | null; + default_reminder_sms: boolean; + default_repair_pattern: string | null; + default_repair_sms: boolean; + default_survay_pattern: string | null; + default_survay_sms: boolean; + language: string; } export interface IAppointment { - id?: string; - Done: boolean; - name: string; - date: string; - time: string; - description: string; - service: string; - profile_picture?: string; - send_reminder_sms: boolean; - send_reminder_sms_pattern: string | null; - repair_reminder_days: number | null; - send_repair_sms: boolean; - repair_sms_pattern: string | null; - // On Add Only - send_sms: boolean; - pattern: string | null; - sms_card_link: string | null; - phone_number: string; - // On Edit Only - send_sms_after_edit: boolean; - pattern_after_edit: string | null; + id?: string; + Done: boolean; + name: string; + date: string; + time: string; + description: string; + service: string; + price: number; + profile_picture?: string; + send_reminder_sms: boolean; + send_reminder_sms_pattern: string | null; + repair_reminder_days: number | null; + send_repair_sms: boolean; + repair_sms_pattern: string | null; + // On Add Only + send_sms: boolean; + pattern: string | null; + sms_card_link: string | null; + phone_number: string; + // On Edit Only + send_sms_after_edit: boolean; + pattern_after_edit: string | null; } export interface IAppointmentDetail { - id: string; - profile_picture: string | null; - name: string; - date: string; - time: string; - description: string; - phone_number: string; - service: string; + id: string; + profile_picture: string | null; + name: string; + date: string; + time: string; + description: string; + phone_number: string; + service: string; } export interface ICustomer { - id?: number; - name: string; - phone_number: string; - customer_date_of_birth?: string; - description?: string; - profile_picture?: string; - customer_type?: string; + id?: number; + name: string; + phone_number: string; + customer_date_of_birth?: string; + description?: string; + profile_picture?: string; + customer_type?: string; } export interface INewCustomer { - name: string; - phone_number: string; - birth_date?: string; - customer_type?: string; - description?: string; - profile_picture?: string; + name: string; + phone_number: string; + birth_date?: string; + customer_type?: string; + description?: string; + profile_picture?: string; } export interface IUpdateCustomerPayload { - customerId: number; - name?: string; - description?: string; - customer_type?: string; - date_of_birth?: string; // "YYYY-MM-DD" - profile_picture?: File | null; + customerId: number; + name?: string; + description?: string; + customer_type?: string; + date_of_birth?: string; // "YYYY-MM-DD" + profile_picture?: File | null; } export interface INewApiResponse { - customer: { - id: number; - name: string; - phone_number: string; - customer_type: string; - description: string; - profile_picture: string; - }; - message: string; + customer: { + id: number; + name: string; + phone_number: string; + customer_type: string; + description: string; + profile_picture: string; + }; + message: string; } export interface IProduct { - id: number; - name: string; - price: number; - price_after_discount: number | null; - tax: number; - banner: string; - enabled: boolean; + id: number; + name: string; + price: number; + price_after_discount: number | null; + tax: number; + banner: string; + enabled: boolean; } export interface ITransaction { - RefID: string | null; - finished: "unsuccessful" | "successful" | "incomplete"; - product_id: number; - product_name: string; - product_price: number; - transaction_id: number; - transaction_time: string; + RefID: string | null; + finished: "unsuccessful" | "successful" | "incomplete"; + product_id: number; + product_name: string; + product_price: number; + transaction_id: number; + transaction_time: string; } export interface IGroupDiscount { - customer_ids: number[]; - amount: number | string; - service: string; - days: number | string; - pattern: string | null; - sms_card_link: string | null; + customer_ids: number[]; + amount: number | string; + service: string; + days: number | string; + pattern: string | null; + sms_card_link: string | null; } export interface ILoyaltySMS { - customer_ids: number[]; - pattern: string | null; // From api list dynamically - sms_card_link: string | null; + customer_ids: number[]; + pattern: string | null; // From api list dynamically + sms_card_link: string | null; } export interface IRepair { - appointment_id: number; - id: number; - jalali_date: string; - name: string; - phone_number: string; - send_sms: boolean; - service: string; + appointment_id: number; + id: number; + jalali_date: string; + name: string; + phone_number: string; + send_sms: boolean; + service: string; } export interface ISMSTransaction { - amount: number; - date_time: string; - description: string; - receiver: string; - transaction_id: number; + amount: number; + date_time: string; + description: string; + receiver: string; + transaction_id: number; } export interface ITicket { - id: number; - created_at: string; - content: string; - subject: string; - is_open: boolean; + id: number; + created_at: string; + content: string; + subject: string; + is_open: boolean; } export interface ISingleTicket extends ITicket { - replies: Array< - | { - content: string; - date_time: string; - id: number; - type: "user_reply"; - user_id: number; - } - | { - content: string; - date_time: string; - id: number; - type: "super_user_reply"; - user_name: string; - } - >; - user: IUser; + replies: Array< + | { + content: string; + date_time: string; + id: number; + type: "user_reply"; + user_id: number; + } + | { + content: string; + date_time: string; + id: number; + type: "super_user_reply"; + user_name: string; + } + >; + user: IUser; } export interface IEditAppointment { - Done: boolean; - description: string; - id: number; - jalali_date: string; - jalali_time: string; - name: string; - phone_number: string; - service: string; + Done: boolean; + description: string; + id: number; + jalali_date: string; + jalali_time: string; + name: string; + phone_number: string; + service: string; } export interface IReserveAppointmentDTO { - name: string; - description: string; - service: string; - artist_id: number; + name: string; + description: string; + service: string; + artist_id: number; } export interface IReservedAppointment { - id: number; - Done?: boolean; - confrimed: boolean; - creation_time: string; - description: string; - jalali_date: string; - jalali_time: string; - name: string; - phone_number: string; - service: string; + id: number; + Done?: boolean; + confrimed: boolean; + creation_time: string; + description: string; + jalali_date: string; + jalali_time: string; + name: string; + phone_number: string; + service: string; } export interface ISMSPattern { - id: string; - text: string; - smsCount: number; + id: string; + text: string; + smsCount: number; } export interface IShareGiftCardForm { - amount: string; - days: string; - service: string; - text: string; + amount: string; + days: string; + service: string; + text: string; } export interface ILoyaltyCard { - id: number; - card_type: number; - creation_time: string; - customer_id: number; - customer_name: string; - customer_phone_number: string; - description_of_discount: string; - extera_description: string; - link: string; - number_of_visits: number; - predicted_number_of_visits: number; - primary_number_of_visits: number; + id: number; + card_type: number; + creation_time: string; + customer_id: number; + customer_name: string; + customer_phone_number: string; + description_of_discount: string; + extera_description: string; + link: string; + number_of_visits: number; + predicted_number_of_visits: number; + primary_number_of_visits: number; } export interface ILoyaltyCardDTO { - id?: number; - customer_id: number | ""; - card_type: number; - predicted_number_of_visits: number; - primary_number_of_visits: number; - description_of_discount: string; - extera_description: string; + id?: number; + customer_id: number | ""; + card_type: number; + predicted_number_of_visits: number; + primary_number_of_visits: number; + description_of_discount: string; + extera_description: string; } export interface ILoyaltyCardTemplateProps { - data: ILoyaltyCard; - onEmptyItemsClick?: () => void; - isEmptyLoading?: boolean; - onFillItemsClick?: (index: number) => void; - isFillLoading?: boolean; + data: ILoyaltyCard; + onEmptyItemsClick?: () => void; + isEmptyLoading?: boolean; + onFillItemsClick?: (index: number) => void; + isFillLoading?: boolean; } export interface ISurveyAnswers { - question_1: number | null; - question_2: number | null; - question_3: number | null; - question_4: number | null; - question_5: number | null; - question_6: number | null; - question_7: number | null; - question_8: number | null; - question_9: number | null; - question_10: number | null; - final_comment: string | null; + question_1: number | null; + question_2: number | null; + question_3: number | null; + question_4: number | null; + question_5: number | null; + question_6: number | null; + question_7: number | null; + question_8: number | null; + question_9: number | null; + question_10: number | null; + final_comment: string | null; } export interface ISurvey extends ISurveyAnswers { - id: number; - average_score_percent: number; - complete: boolean; - created_at: string; - customer_name: string; - customer_phone_number: string; - link: string; - user_id: number; - question_form: number; + id: number; + average_score_percent: number; + complete: boolean; + created_at: string; + customer_name: string; + customer_phone_number: string; + link: string; + user_id: number; + question_form: number; } export interface IAccounting { - id?: number; - amount: number | string; - date: string; - description: string; + id?: number; + amount: number | string; + date: string; + description: string; } export interface IAccountingTransaction extends IAccounting { - type: "income" | "expense"; + type: "income" | "expense"; } export interface IRangeDates { - startDate: string; - endDate: string; + startDate: string; + endDate: string; } -export type ChartsReportType = - | "dailyIncomes" - | "dailyExpenses" - | "dailyCombination" - | "yearlyMonthToMonth" - | "general"; -export type PersonelStatusType = - | "all" - | "pending" - | "accepted" - | "rejected" - | "canceled"; +export type ChartsReportType = "dailyIncomes" | "dailyExpenses" | "dailyCombination" | "yearlyMonthToMonth" | "general"; +export type PersonelStatusType = "all" | "pending" | "accepted" | "rejected" | "canceled"; export interface IPersonel { - personel_id: number; - accessibilities: number[]; - canceled_time: string | null; - created: string; - decision_time: string | null; - personel_name: string; - personel_phone: string; - status: PersonelStatusType; + personel_id: number; + accessibilities: number[]; + canceled_time: string | null; + created: string; + decision_time: string | null; + personel_name: string; + personel_phone: string; + status: PersonelStatusType; } export interface IAdminPersonel { - admin_id: number; - accessibilities: number[]; - admin_name: string; - admin_phone: string; - canceled_time: string | null; - created: string; - decision_time: string | null; - status: PersonelStatusType; + admin_id: number; + accessibilities: number[]; + admin_name: string; + admin_phone: string; + canceled_time: string | null; + created: string; + decision_time: string | null; + status: PersonelStatusType; } export interface IPersonelAccess { - 1: "add_appointment"; - 2: "get_appointment"; - 3: "edit_appointment"; - 4: "cancel_appointment"; - 5: "customers_club"; - 6: "sms_balance_view"; - 7: "loyalty"; - 8: "discount_sms"; - 9: "market"; - 10: "survey"; - 11: "accountant_enter_and_edit"; - 12: "accountant_check"; + 1: "add_appointment"; + 2: "get_appointment"; + 3: "edit_appointment"; + 4: "cancel_appointment"; + 5: "customers_club"; + 6: "sms_balance_view"; + 7: "loyalty"; + 8: "discount_sms"; + 9: "market"; + 10: "survey"; + 11: "accountant_enter_and_edit"; + 12: "accountant_check"; } export const personelAccessTranslate = { - 1: "افزودن نوبت", - 2: "مشاهده نوبت‌ها", - 3: "ویرایش نوبت", - 4: "لغو نوبت", - 5: "باشگاه مشتریان", - 6: "مشاهده موجودی پیامک", - 7: "وفاداری", - 8: "پیامک تخفیف", - 9: "خرید اشتراک", - 10: "نظرسنجی", - 11: "افزودن / ویرایش حسابداری", - 12: "بررسی حسابداری", + 1: "افزودن نوبت", + 2: "مشاهده نوبت‌ها", + 3: "ویرایش نوبت", + 4: "لغو نوبت", + 5: "باشگاه مشتریان", + 6: "مشاهده موجودی پیامک", + 7: "وفاداری", + 8: "پیامک تخفیف", + 9: "خرید اشتراک", + 10: "نظرسنجی", + 11: "افزودن / ویرایش حسابداری", + 12: "بررسی حسابداری", }; export interface IReceivedDiamond { - amount: number; - got_for: string; - creation_time: string; + amount: number; + got_for: string; + creation_time: string; } export interface IUsedDiamond { - amount: number; - prize: string; - got_for: string; - creation_time: string; + amount: number; + prize: string; + got_for: string; + creation_time: string; } export interface IPrize { - active: boolean; - name: string; - score: number; + active: boolean; + name: string; + score: number; } export interface IScoreOpportunity { - active: boolean; - description: string; - name: string; - score: number; + active: boolean; + description: string; + name: string; + score: number; } export type AppointmentHistoryKeyType = "today" | "tomorrow" | "two_next_day"; export interface IPlayList { - id: string; - title: string; - musics: Array<{ id: string; - addrress: string; - }>; + title: string; + musics: Array<{ + id: string; + addrress: string; + }>; } export interface IAcademyCategory { - [category: string]: { - [videoTitle: string]: string[] | IAcademyCategory; - }; + [category: string]: { + [videoTitle: string]: string[] | IAcademyCategory; + }; } export type ISubmitFeeling = { - feeling: number; + feeling: number; }; export type IGetFarsiQuotes = { - [author: string]: string; + [author: string]: string; }; export type IGetAllExtension = { - allowed_extensions: string[]; + allowed_extensions: string[]; }; export type NewPurchaseType = { - id: number; - picture: string; - title: string; - realPrice?: string; - priceWithDiscount: string; - currency: string; - borderColor1: string; - borderColor2: string; - color1: string; - color2: string; + id: number; + picture: string; + title: string; + realPrice?: string; + priceWithDiscount: string; + currency: string; + borderColor1: string; + borderColor2: string; + color1: string; + color2: string; }; export interface ICreateGiftCardRequest { - amount: string; - text: string; - service: string; - expire_date: string; - card_type: string; + amount: string; + text: string; + service: string; + expire_date: string; + card_type: string; } export interface ICreateGiftCardResponse { - message: string; - public_id: string; + message: string; + public_id: string; } // types.ts export interface ICreateGiftCardRequest { - amount: string; - text: string; - service: string; - expire_date: string; // format: YYYY-MM-DD (Jalali) - card_type: string; + amount: string; + text: string; + service: string; + expire_date: string; // format: YYYY-MM-DD (Jalali) + card_type: string; } export interface ICreateGiftCardResponse { - message: string; - public_id: string; + message: string; + public_id: string; } export interface IUseOrViewGiftCardResponse { - message?: string; - used_date?: string; - error?: string; + message?: string; + used_date?: string; + error?: string; } export interface IGiftCard { - public_id: string; - text: string; - amount: number; - service: string; - expire_date: string; - card_type: string; - expired: boolean; - used: boolean; - created_at: string; - creation_time?: string; - qr_code?: string; - used_date?: string | null; + public_id: string; + text: string; + amount: number; + service: string; + expire_date: string; + card_type: string; + expired: boolean; + used: boolean; + created_at: string; + creation_time?: string; + qr_code?: string; + used_date?: string | null; } export interface IUserGiftCardsResponse { - cards: IGiftCard[]; + cards: IGiftCard[]; } export interface IDeleteGiftCardResponse { - message: string; + message: string; } export interface IGiftCardInfo { - public_id: string; - amount: string; - text: string; - service: string; - expire_date: string; - card_type: string; - qr_code: string; - used: boolean; - used_date?: string; - creation_time?: string; + public_id: string; + amount: string; + text: string; + service: string; + expire_date: string; + card_type: string; + qr_code: string; + used: boolean; + used_date?: string; + creation_time?: string; } export interface ICreateGiftCardResponse { - message: string; - public_id: string; + message: string; + public_id: string; } export interface IGiftCardInfo { - public_id: string; - amount: string; - text: string; - service: string; - expire_date: string; - card_type: string; - qr_code: string; - used: boolean; - used_date?: string; - creation_time?: string; + public_id: string; + amount: string; + text: string; + service: string; + expire_date: string; + card_type: string; + qr_code: string; + used: boolean; + used_date?: string; + creation_time?: string; } export interface ISpecialtiesResponse { - count: number; - specialties: string[]; + count: number; + specialties: string[]; } export interface IEvents { - id: number; - banner: string; - creation_time: string; - content: string; - discount_percentage: string; - price: string; - expire_date: string; - location: string; - price_after_discount: string; - title: string; + id: number; + banner: string; + creation_time: string; + content: string; + discount_percentage: string; + price: string; + expire_date: string; + location: string; + price_after_discount: string; + title: string; } export interface IEventsResponse { - events: IEvents[]; - count: number; + events: IEvents[]; + count: number; } export interface ILoyalCustomer { - customer_name: string; - customer_phone_number: string; - visits: number; - profile_picture?: string | null; + customer_name: string; + customer_phone_number: string; + visits: number; + profile_picture?: string | null; } export interface IReportAtGlanceData { - loyal_customers: ILoyalCustomer[]; - loyal_customers_count: number; - monthly_appointments: number; - total_appointments: number; - weekly_appointments: number; + loyal_customers: ILoyalCustomer[]; + loyal_customers_count: number; + monthly_appointments: number; + total_appointments: number; + weekly_appointments: number; } export interface ISurveyAverage { - average_score: number; - survey_count: number; + average_score: number; + survey_count: number; } export interface IBookmarks { - bookmark_id: number; - created_at: string; - event: IEvents; + bookmark_id: number; + created_at: string; + event: IEvents; } export interface IBookmarksResponse { - bookmarks: IBookmarks[]; - user_id: number; + bookmarks: IBookmarks[]; + user_id: number; } export interface BookmarkResponse { - message: string; + message: string; } diff --git a/src/apps/new-ui/components/AppointmentComponents/ServiceCost.tsx b/src/apps/new-ui/components/AppointmentComponents/ServiceCost.tsx index 0c73c839..035a90a8 100644 --- a/src/apps/new-ui/components/AppointmentComponents/ServiceCost.tsx +++ b/src/apps/new-ui/components/AppointmentComponents/ServiceCost.tsx @@ -1,158 +1,119 @@ -import { useEffect, useState } from "react"; -import { useLocation } from "react-router-dom"; +import { useEffect, useMemo, useState } from "react"; import { numberToPersianWords } from "../numberToWords"; -const persianToLatinMap: Record = { - "۰": "0", - "۱": "1", - "۲": "2", - "۳": "3", - "۴": "4", - "۵": "5", - "۶": "6", - "۷": "7", - "۸": "8", - "۹": "9", +const STORAGE_KEY = "serviceCost"; +const EDIT_KEY = "editAppointmentServiceCost"; + +const PERSIAN_TO_LATIN: Record = { + "۰": "0", + "۱": "1", + "۲": "2", + "۳": "3", + "۴": "4", + "۵": "5", + "۶": "6", + "۷": "7", + "۸": "8", + "۹": "9", }; -const toLatinDigits = (s: string) => - s.replace(/[۰-۹]/g, (d) => persianToLatinMap[d] ?? d); +const toLatinDigits = (s = "") => s.replace(/[۰-۹]/g, (d) => PERSIAN_TO_LATIN[d] ?? d); -const onlyDigits = (s: string) => { - if (!s) return ""; - // convert persian digits first, then remove non-digits - const latin = toLatinDigits(s); - return latin.replace(/\D/g, ""); +const onlyDigits = (s?: string) => { + if (!s) return ""; + return toLatinDigits(s).replace(/\D/g, ""); }; -const formatWithCommas = (digits: string) => { - if (!digits) return ""; - // remove leading zeros unless the whole is zero - const trimmed = digits.replace(/^0+(?=\d)/, ""); - return trimmed.replace(/\B(?=(\d{3})+(?!\d))/g, ","); +const formatWithCommas = (digits = "") => { + if (!digits) return ""; + const trimmed = digits.replace(/^0+(?=\d)/, ""); + return trimmed.replace(/\B(?=(\d{3})+(?!\d))/g, ","); }; -const ServiceCost = () => { - const location = useLocation(); +interface Props { + customClass?: string; + cost?: string | number | null; +} - // state holds RAW digits only (no commas), initialized from localStorage if present - const [selectedCost, setSelectedCost] = useState(() => - onlyDigits(localStorage.getItem("serviceCost") || "") - ); +const ServiceCost: React.FC = ({ customClass, cost = null }) => { + const [selectedCost, setSelectedCost] = useState(() => { + if (typeof window === "undefined") return ""; + if (cost != null) { + const d = onlyDigits(String(cost)); + return d === "0" ? "" : d; + } + return onlyDigits(localStorage.getItem(STORAGE_KEY) || ""); + }); - // write formatted value to localStorage whenever raw digits change - useEffect(() => { - const formatted = selectedCost ? formatWithCommas(selectedCost) : ""; - localStorage.setItem("serviceCost", formatted); - }, [selectedCost]); + useEffect(() => { + if (cost == null) return; + const digits = onlyDigits(String(cost)); + setSelectedCost(digits === "0" ? "" : digits); + }, [cost]); - useEffect(() => { - return () => { - if (!location.pathname.includes("/appointments/advance")) { - localStorage.removeItem("serviceCost"); - } + useEffect(() => { + const formatted = selectedCost ? formatWithCommas(selectedCost) : ""; + try { + if (formatted !== "") localStorage.setItem(STORAGE_KEY, formatted); + } catch {} + }, [selectedCost]); + + useEffect(() => { + const appointmentServiceCost = localStorage.getItem(EDIT_KEY); + if (appointmentServiceCost) { + setSelectedCost(onlyDigits(appointmentServiceCost)); + } + }, []); + + const handleChange = (e: React.ChangeEvent) => { + const digits = onlyDigits(e.target.value); + setSelectedCost(digits === "0" ? "" : digits); }; - }, [location.pathname]); - const handleChange = (e: React.ChangeEvent) => { - const raw = e.target.value; - // remove commas and transform persian digits and strip non-digits - const digits = onlyDigits(raw); - // optional: you could limit length here (e.g., max 12 digits) - setSelectedCost(digits); - }; + const handlePaste = (e: React.ClipboardEvent) => { + const text = e.clipboardData.getData("text"); + const digits = onlyDigits(text); + if (!digits) { + e.preventDefault(); + return; + } + e.preventDefault(); + setSelectedCost(digits === "0" ? "" : digits); + }; - // handle paste: sanitize clipboard text - const handlePaste = (e: React.ClipboardEvent) => { - const text = e.clipboardData.getData("text"); - const digits = onlyDigits(text); - if (!digits) { - // prevent pasting anything that results in no digits - e.preventDefault(); - return; - } - // prevent default paste and insert sanitized digits instead - e.preventDefault(); - setSelectedCost(digits); - }; + const displayValue = useMemo(() => (selectedCost ? formatWithCommas(selectedCost) : ""), [selectedCost]); - const displayValue = selectedCost ? formatWithCommas(selectedCost) : ""; + return ( +
+
+ + هزینه خدمات + - useEffect(() => { - const appointmentServiceCost = localStorage.getItem( - "editAppointmentServiceCost" +
+ + + تومان +
+
+ + {displayValue && ( +
+ {numberToPersianWords(Number(displayValue.replace(/,/g, "")))} تومان +
+ )} +
); - if (appointmentServiceCost) { - setSelectedCost(appointmentServiceCost); - } - }, []); - - return ( - //
- //
- // - // هزینه خدمات - // - - // - - // - // تومان - // - //
- // {displayValue && ( - //
- // {numberToPersianWords(Number(displayValue.replace(/,/g, "")))} تومان - //
- // )} - //
- -
-
- {/* لیبل عنوان */} - - هزینه خدمات - - - {/* باکس ورودی */} -
- - - {/* تومان */} - - تومان - -
-
- - {/* نمایش عدد به حروف */} - {displayValue && ( -
- {numberToPersianWords(Number(displayValue.replace(/,/g, "")))} تومان -
- )} -
- ); }; export default ServiceCost; diff --git a/src/apps/new-ui/components/AppointmentComponents/Services.tsx b/src/apps/new-ui/components/AppointmentComponents/Services.tsx index eaa81316..1d712fa4 100644 --- a/src/apps/new-ui/components/AppointmentComponents/Services.tsx +++ b/src/apps/new-ui/components/AppointmentComponents/Services.tsx @@ -1,3 +1,4 @@ +// Services.tsx import { Swiper, SwiperSlide } from "swiper/react"; import { Navigation, Autoplay } from "swiper/modules"; import { useState, useMemo, useEffect } from "react"; @@ -8,208 +9,227 @@ import VoiceSearchInput from "../VoiceSearchInput"; import { XIcon } from "lucide-react"; const Services = () => { - const { data: userInfo } = useGetUserInfo(); - const [selectedSlideIndex, setSelectedSlideIndex] = useState( - null - ); - const [searchTerm, setSearchTerm] = useState(""); - const [isAddServiceOpen, setIsAddServiceOpen] = useState(false); - const appointmentStatus = localStorage.getItem("appointmentStatus"); - const disableInput = appointmentStatus === "edit" ? true : false; + const { data: userInfo } = useGetUserInfo(); + const [selectedSlideIndex, setSelectedSlideIndex] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + const [isAddServiceOpen, setIsAddServiceOpen] = useState(false); + const appointmentStatus = localStorage.getItem("appointmentStatus"); + const disableInput = appointmentStatus === "edit" ? true : false; - // lazy init (SSR-safe) - const getInitialTitle = () => { - try { - if (typeof window === "undefined") return ""; - return localStorage.getItem("serviceTitle") || ""; - } catch { - return ""; - } - }; - const [selectedSlideTitle, setSelectedSlideTitle] = - useState(getInitialTitle); - - const allServices: Service[] = userInfo?.current_user?.services || []; - // console.log(allServices); - - useEffect(() => { - if (!isAddServiceOpen) return; - - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setIsAddServiceOpen(false); + // lazy init (SSR-safe) + const getInitialTitle = () => { + try { + if (typeof window === "undefined") return ""; + return localStorage.getItem("serviceTitle") || ""; + } catch { + return ""; + } }; - document.addEventListener("keydown", onKey); + const [selectedSlide, setSelectedSlide] = useState(getInitialTitle); - const prevOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; + const allServices: Service[] = userInfo?.current_user?.services || []; - return () => { - document.removeEventListener("keydown", onKey); - document.body.style.overflow = prevOverflow; + useEffect(() => { + if (!isAddServiceOpen) return; + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsAddServiceOpen(false); + }; + document.addEventListener("keydown", onKey); + + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = prevOverflow; + }; + }, [isAddServiceOpen]); + + useEffect(() => { + const appointmentService = localStorage.getItem("editAppointmentService"); + if (appointmentService) { + setSearchTerm(appointmentService); + } + + const foundCost = allServices.find((service) => service.name === appointmentService); + if (foundCost) { + setSelectedSlide(foundCost); + setSelectedSlideIndex(allServices.indexOf(foundCost)); + } + }, [allServices]); + + // helper: emit custom events so other components (same tab) react immediately + const emitServiceCostChange = (newCost: string) => { + try { + const oldVal = localStorage.getItem("serviceCost"); + // already stored, but ensure it's set + localStorage.setItem("serviceCost", newCost ?? ""); + + // 1) custom event for same-tab listeners + try { + const ev = new CustomEvent("serviceCostChanged", { detail: { cost: newCost } }); + window.dispatchEvent(ev); + } catch (err) { + // ignore if CustomEvent not supported (very old browsers) + console.warn("CustomEvent dispatch failed:", err); + } + + // 2) try to dispatch a StorageEvent as well (useful for other windows) + try { + const storageEvent = new StorageEvent("storage", { + key: "serviceCost", + oldValue: oldVal, + newValue: newCost, + storageArea: localStorage, + }); + window.dispatchEvent(storageEvent); + } catch (err) { + // some browsers may not allow constructing StorageEvent - that's fine + } + } catch (err) { + console.warn("emitServiceCostChange error:", err); + } }; - }, [isAddServiceOpen]); - useEffect(() => { - const appointmentService = localStorage.getItem("editAppointmentService"); - if (appointmentService) { - setSearchTerm(appointmentService); - } + // save title & cost to localStorage when selectedSlide changes (and emit event) + useEffect(() => { + try { + if (typeof window === "undefined") return; + // guard: selectedSlide might initially be a string (from getInitialTitle) or null + if (!selectedSlide || typeof selectedSlide !== "object") return; - const foundCost = allServices.find( - (service) => service.name === appointmentService - ); - if (foundCost) { - setSelectedSlideTitle(foundCost.name); - setSelectedSlideIndex(allServices.indexOf(foundCost)); - } - }, []); + const title = selectedSlide.name ?? ""; + const price = selectedSlide.price != null ? String(selectedSlide.price) : ""; - // save title to localStorage when it changes (no handleServiceClick here!) - useEffect(() => { - try { - if (typeof window === "undefined") return; - localStorage.setItem("serviceTitle", selectedSlideTitle); - } catch (err) { - console.warn("localStorage set error:", err); - } - }, [selectedSlideTitle]); + localStorage.setItem("serviceTitle", title); + // use helper so events are emitted + emitServiceCostChange(price); + } catch (err) { + console.warn("localStorage set error:", err); + } + }, [selectedSlide]); - // keep selectedSlideIndex in sync: - // - when allServices loads/changes - // - or when selectedSlideTitle changes (e.g. restored from localStorage) - useEffect(() => { - if (!allServices || allServices.length === 0) { - setSelectedSlideIndex(null); - return; - } + useEffect(() => { + if (!allServices || allServices.length === 0) { + setSelectedSlideIndex(null); + return; + } - const idx = allServices.map((s) => s.name).indexOf(selectedSlideTitle); - if (idx >= 0) { - setSelectedSlideIndex(idx); - } else { - // اگر هیچ سرویسی مطابق عنوان ذخیره‌شده نبود، می‌تونی null بذاری یا اولی رو ست کنی. - setSelectedSlideIndex(null); - } - }, [allServices, selectedSlideTitle]); + const idx = allServices.map((s) => s.name).indexOf(selectedSlide?.name ?? ""); + if (idx >= 0) { + setSelectedSlideIndex(idx); + } else { + setSelectedSlideIndex(null); + } + }, [allServices, selectedSlide?.name]); - // handle click: فقط state رو وقتی واقعاً تغییره آپدیت کن تا از رندرهای بیهوده جلوگیری بشه - const handleServiceClick = (service: Service) => { - const originalIndex = allServices.indexOf(service); - if ( - service.name === selectedSlideTitle && - originalIndex === selectedSlideIndex - ) { - // هیچ تغییری لازم نیست - return; - } + const handleServiceClick = (service: Service) => { + const originalIndex = allServices.indexOf(service); + if (service.name === selectedSlide?.name && originalIndex === selectedSlideIndex) { + return; + } - setSelectedSlideTitle(service.name); - setSelectedSlideIndex(originalIndex >= 0 ? originalIndex : null); - }; + setSelectedSlide(service); + setSelectedSlideIndex(originalIndex >= 0 ? originalIndex : null); + }; - // filteredServices (بدون تغییر) - const filteredServices = useMemo(() => { - return allServices.filter((service) => - service.name.toLowerCase().includes(searchTerm.trim().toLowerCase()) - ); - }, [allServices, searchTerm]); + const filteredServices = useMemo(() => { + return allServices.filter((service) => service.name.toLowerCase().includes(searchTerm.trim().toLowerCase())); + }, [allServices, searchTerm]); - return ( -
- {/* Search Section */} -
-
- -
- -
(disableInput ? "" : setIsAddServiceOpen(true))} - className="w-1/3 flex flex-nowrap whitespace-nowrap gap-1 items-center justify-center border-2 border-[#A780C3] rounded-full cursor-pointer" - > -

+

-

- افزودن خدمات -

-
-
- - {/* Services Swiper */} -
- 0 - ? { - delay: 5000, - disableOnInteraction: false, - pauseOnMouseEnter: false, - } - : false - } - spaceBetween={12} - breakpoints={{ - 0: { slidesPerView: 1.8 }, // خیلی کوچک - 320: { slidesPerView: 2 }, // small phones (e.g. old small devices) - 360: { slidesPerView: 2.5 }, // common small phones (e.g. 360px width) - 375: { slidesPerView: 2.7 }, // iPhone SE / small - 390: { slidesPerView: 3 }, // slightly larger small phones - 412: { slidesPerView: 3.2 }, // many Android 4xx widths - 428: { slidesPerView: 3.5 }, // iPhone 12/13 mini-ish - 480: { slidesPerView: 3.8 }, // small phablets / compact landscape - 520: { slidesPerView: 4.3 }, // larger phones - 640: { slidesPerView: 5 }, // small tablets / large phones - 768: { slidesPerView: 6 }, // tablets - 1024: { slidesPerView: 8 }, // desktop-ish - }} - > - {filteredServices.length > 0 ? ( - filteredServices.map((service, index) => ( - -
handleServiceClick(service)} - > -
{service.emoji}
-

{service.name}

+ return ( +
+ {/* Search Section */} +
+
+
- - )) - ) : ( -
-

- {searchTerm ? "نتیجه‌ای یافت نشد" : "خدماتی وجود ندارد"} -

-
- )} - - {isAddServiceOpen && ( -
setIsAddServiceOpen(false)} - className="fixed z-[200] inset-0 bg-black/20 backdrop-blur-sm flex justify-center items-center pb-4" - > -
e.stopPropagation()} - className="relative bg-white w-[95%] max-w-lg max-h-[85vh] rounded-2xl p-4 overflow-y-auto" - > -
setIsAddServiceOpen(false)} - className="absolute cursor-pointer text-black/80 text-2xl rounded-full p-2 top-2 left-2 " - > - -
- setIsAddServiceOpen(false)} /> +
(disableInput ? "" : setIsAddServiceOpen(true))} + className="w-1/3 flex flex-nowrap whitespace-nowrap gap-1 items-center justify-center border-2 border-[#A780C3] rounded-full cursor-pointer" + > +

+

+

افزودن خدمات

+
-
- )} -
-
- ); + + {/* Services Swiper */} +
+ 0 + ? { + delay: 5000, + disableOnInteraction: false, + pauseOnMouseEnter: false, + } + : false + } + spaceBetween={12} + breakpoints={{ + 0: { slidesPerView: 1.8 }, + 320: { slidesPerView: 2 }, + 360: { slidesPerView: 2.5 }, + 375: { slidesPerView: 2.7 }, + 390: { slidesPerView: 3 }, + 412: { slidesPerView: 3.2 }, + 428: { slidesPerView: 3.5 }, + 480: { slidesPerView: 3.8 }, + 520: { slidesPerView: 4.3 }, + 640: { slidesPerView: 5 }, + 768: { slidesPerView: 6 }, + 1024: { slidesPerView: 8 }, + }} + > + {filteredServices.length > 0 ? ( + filteredServices.map((service, index) => ( + +
handleServiceClick(service)} + > +
{service.emoji}
+

{service.name}

+
+
+ )) + ) : ( +
+

{searchTerm ? "نتیجه‌ای یافت نشد" : "خدماتی وجود ندارد"}

+
+ )} +
+ + {isAddServiceOpen && ( +
setIsAddServiceOpen(false)} + className="fixed z-[200] inset-0 bg-black/20 backdrop-blur-sm flex justify-center items-center pb-4" + > +
e.stopPropagation()} + className="relative bg-white w-[95%] max-w-lg max-h-[85vh] rounded-2xl p-4 overflow-y-auto" + > +
setIsAddServiceOpen(false)} + className="absolute cursor-pointer text-black/80 text-2xl rounded-full p-2 top-2 left-2 " + > + +
+ setIsAddServiceOpen(false)} /> +
+
+ )} +
+
+ ); }; export default Services; diff --git a/src/apps/new-ui/components/AppointmentComponents/ServicesQA.tsx b/src/apps/new-ui/components/AppointmentComponents/ServicesQA.tsx index c29af4f7..bd8a1db0 100644 --- a/src/apps/new-ui/components/AppointmentComponents/ServicesQA.tsx +++ b/src/apps/new-ui/components/AppointmentComponents/ServicesQA.tsx @@ -6,133 +6,159 @@ import { useLocation } from "react-router-dom"; import { Service } from "@/apps/appointment/utils/types"; type Props = { - value?: string; - onChange?: (v: string) => void; + value?: string; + onChange?: (v: string) => void; }; +const PRICE_KEY = "servicePrice"; + const ServicesQA: React.FC = ({ value, onChange }) => { - const { data: userInfo } = useGetUserInfo(); - const [selectedSlideIndex, setSelectedSlideIndex] = useState( - null - ); - const location = useLocation(); - const allServices: Service[] = userInfo?.current_user?.services || []; + const { data: userInfo } = useGetUserInfo(); + const [selectedSlideIndex, setSelectedSlideIndex] = useState(null); + const location = useLocation(); + const allServices: Service[] = userInfo?.current_user?.services || []; - const initialService = value ?? localStorage.getItem("serviceTitle") ?? ""; - const [selectedSlideTitle, setSelectedSlideTitle] = - useState(initialService); + const initialService = value ?? localStorage.getItem("serviceTitle") ?? ""; + const [selectedSlideTitle, setSelectedSlideTitle] = useState(initialService); - const weSetLocalRef = useRef(false); + // price state: persisted to localStorage under PRICE_KEY + const initialPrice = localStorage.getItem(PRICE_KEY) ?? ""; + const [selectedPrice, setSelectedPrice] = useState(initialPrice); - useEffect(() => { - if (onChange) return; - localStorage.setItem("serviceTitle", selectedSlideTitle ?? ""); - weSetLocalRef.current = true; - }, [selectedSlideTitle, onChange]); + const weSetLocalRef = useRef(false); - useEffect(() => { - if (typeof value !== "undefined" && value !== selectedSlideTitle) { - setSelectedSlideTitle(value); - } - }, [value]); + useEffect(() => { + if (onChange) return; + localStorage.setItem("serviceTitle", selectedSlideTitle ?? ""); + weSetLocalRef.current = true; + }, [selectedSlideTitle, onChange]); - useEffect(() => { - return () => { - if ( - weSetLocalRef.current && - !location.pathname.includes("/appointments/quick") - ) { - localStorage.removeItem("serviceTitle"); - } + useEffect(() => { + if (typeof value !== "undefined" && value !== selectedSlideTitle) { + setSelectedSlideTitle(value); + } + }, [value]); + + useEffect(() => { + return () => { + if (weSetLocalRef.current && !location.pathname.includes("/appointments/quick")) { + localStorage.removeItem("serviceTitle"); + // also remove price if we saved it + localStorage.removeItem(PRICE_KEY); + } + }; + }, [location.pathname]); + + // whenever selectedTitle changes, set corresponding price (if any) + useEffect(() => { + if (!selectedSlideTitle) return setSelectedSlideIndex(null); + const idx = allServices.map((s) => s.name).indexOf(selectedSlideTitle); + setSelectedSlideIndex(idx >= 0 ? idx : null); + + // attempt to find the service and set its price into state + localStorage + const found = allServices.find((s) => s.name === selectedSlideTitle); + if (found) { + const p = found.price ?? ""; + const pStr = typeof p === "number" ? String(p) : p; + setSelectedPrice(pStr); + try { + localStorage.setItem(PRICE_KEY, pStr); + weSetLocalRef.current = true; + } catch {} + } + }, [selectedSlideTitle, allServices]); + + // keep localStorage in sync when selectedPrice changes directly + useEffect(() => { + try { + if (selectedPrice !== null && selectedPrice !== undefined) + localStorage.setItem(PRICE_KEY, selectedPrice ?? ""); + } catch {} + }, [selectedPrice]); + + useEffect(() => { + localStorage.setItem("serviceTitle", selectedSlideTitle ?? ""); + }, [selectedSlideTitle]); + + const handleServiceClick = (service: Service) => { + const originalIndex = allServices.indexOf(service); + setSelectedSlideIndex(originalIndex); + setSelectedSlideTitle(service.name); + onChange?.(String(service.name)); + localStorage.setItem("serviceTitle", service.name); + + // save its price to localStorage (no custom events) + const p = service.price ?? ""; + const pStr = typeof p === "number" ? String(p) : p; + setSelectedPrice(pStr); + try { + localStorage.setItem(PRICE_KEY, pStr); + weSetLocalRef.current = true; + } catch {} }; - }, [location.pathname]); - - - useEffect(() => { - if (!selectedSlideTitle) return setSelectedSlideIndex(null); - const idx = allServices.map((s) => s.name).indexOf(selectedSlideTitle); - setSelectedSlideIndex(idx >= 0 ? idx : null); - }, [selectedSlideTitle, allServices]); - - useEffect(() => { - localStorage.setItem("serviceTitle", selectedSlideTitle ?? ""); -}, [selectedSlideTitle]); - - - const handleServiceClick = (service: Service) => { - const originalIndex = allServices.indexOf(service); - setSelectedSlideIndex(originalIndex); - setSelectedSlideTitle(service.name); - onChange?.(String(service.name)); - localStorage.setItem("serviceTitle", service.name); -}; - - - return ( -
-
-
-

خدمات

-
- -
- 0 - ? { - delay: 5000, - disableOnInteraction: false, - pauseOnMouseEnter: false, - } - : false - } - spaceBetween={5} - breakpoints={{ - 0: { slidesPerView: 1.8 }, - 320: { slidesPerView: 2 }, - 360: { slidesPerView: 2.5 }, - 375: { slidesPerView: 2.7 }, - 390: { slidesPerView: 3 }, - 412: { slidesPerView: 3.2 }, - 428: { slidesPerView: 3.5 }, - 480: { slidesPerView: 3.8 }, - 520: { slidesPerView: 4.3 }, - 640: { slidesPerView: 5 }, - 768: { slidesPerView: 5 }, - 1024: { slidesPerView: 5 }, - }} - > - {allServices.length > 0 ? ( - allServices.map((service, index) => ( - -
handleServiceClick(service)} - > -

{service.emoji}

- -

{service.name}

-
-
- )) - ) : ( -
-

- {"خدماتی وجود ندارد"} -

+ return ( +
+
+
+

خدمات

- )} - -
-
- ); + +
+ 0 + ? { + delay: 5000, + disableOnInteraction: false, + pauseOnMouseEnter: false, + } + : false + } + spaceBetween={5} + breakpoints={{ + 0: { slidesPerView: 1.8 }, + 320: { slidesPerView: 2 }, + 360: { slidesPerView: 2.5 }, + 375: { slidesPerView: 2.7 }, + 390: { slidesPerView: 3 }, + 412: { slidesPerView: 3.2 }, + 428: { slidesPerView: 3.5 }, + 480: { slidesPerView: 3.8 }, + 520: { slidesPerView: 4.3 }, + 640: { slidesPerView: 5 }, + 768: { slidesPerView: 5 }, + 1024: { slidesPerView: 5 }, + }} + > + {allServices.length > 0 ? ( + allServices.map((service, index) => ( + +
handleServiceClick(service)} + > +

{service.emoji}

+ +

{service.name}

+
+
+ )) + ) : ( +
+

{"خدماتی وجود ندارد"}

+
+ )} +
+
+
+ ); }; export default ServicesQA; diff --git a/src/apps/new-ui/pages/AddService/AddService.tsx b/src/apps/new-ui/pages/AddService/AddService.tsx index b943377f..4b3aea58 100644 --- a/src/apps/new-ui/pages/AddService/AddService.tsx +++ b/src/apps/new-ui/pages/AddService/AddService.tsx @@ -3,132 +3,135 @@ import editIcon from "../../assets/EditCTA.svg"; import EmojiPicker, { EmojiClickData, EmojiStyle } from "emoji-picker-react"; import { useAddUserService } from "../../services/Service"; import { XIcon } from "lucide-react"; +import ServiceCost from "../../components/AppointmentComponents/ServiceCost"; interface Props { - onclick: () => void; + onclick: () => void; } +const STORAGE_KEY = "serviceCost"; + const AddService: React.FC = ({ onclick }) => { - const addUserServiceMutation = useAddUserService(); + const addUserServiceMutation = useAddUserService(); - const [addServiceTitle, setAddServiceTitle] = useState(""); - const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false); - const [selectedEmoji, setSelectedEmoji] = useState(""); - const btnEnable = addServiceTitle !== ""; + const [addServiceTitle, setAddServiceTitle] = useState(""); + const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false); + const [selectedEmoji, setSelectedEmoji] = useState(""); + const btnEnable = addServiceTitle !== ""; - const handleSave = async () => { - if (!btnEnable) return; + const handleSave = async () => { + if (!btnEnable) return; - const serviceData = { - service: addServiceTitle, - emoji: selectedEmoji || "😎", - price: 0, + // read price from ServiceCost (stored in localStorage formatted with commas) + const raw = (localStorage.getItem(STORAGE_KEY) || "").replace(/,/g, ""); + const price = Number(raw) || 0; + + const serviceData = { + service: addServiceTitle, + emoji: selectedEmoji || "😎", + price, + }; + + try { + await addUserServiceMutation.mutateAsync(serviceData); + setAddServiceTitle(""); + setSelectedEmoji(""); + onclick(); + } catch (error) { + console.error(error); + } }; - try { - await addUserServiceMutation.mutateAsync(serviceData); - setAddServiceTitle(""); - setSelectedEmoji(""); - onclick(); - } catch (error) { - console.error(error); - } - }; + const handleEmojiClick = (emojiObject: EmojiClickData) => { + setSelectedEmoji(emojiObject.emoji); + setIsEmojiPickerOpen(false); + }; - const handleEmojiClick = (emojiObject: EmojiClickData) => { - setSelectedEmoji(emojiObject.emoji); - setIsEmojiPickerOpen(false); - }; + return ( +
+
+

افزودن خدمات

- return ( -
-
-

- افزودن خدمات -

+ {/* =======add/change emoji========= */} + {selectedEmoji ? ( +
+
{selectedEmoji}
- {/* =======add/change emoji========= */} - {selectedEmoji ? ( -
-
- {selectedEmoji} -
+
setIsEmojiPickerOpen(true)} + className="w-[160px] h-10 text-[#000000E5] text-sm font-semibold rounded-full cursor-pointer border-2 border-[#A780C3] flex gap-2 items-center justify-center text-center" + > + editIcon +

تغییر تصویر

+
+
+ ) : ( +
setIsEmojiPickerOpen(true)} + className="my-16 w-[160px] h-10 text-[#000000E5] text-sm font-semibold rounded-full cursor-pointer border-2 border-[#A780C3] flex gap-2 items-center justify-center text-center" + > +

+

+

افزودن ایموجی

+
+ )} -
setIsEmojiPickerOpen(true)} - className="w-[160px] h-10 text-[#000000E5] text-sm font-semibold rounded-full cursor-pointer border-2 border-[#A780C3] flex gap-2 items-center justify-center text-center" - > - editIcon -

تغییر تصویر

-
-
- ) : ( -
setIsEmojiPickerOpen(true)} - className="my-16 w-[160px] h-10 text-[#000000E5] text-sm font-semibold rounded-full cursor-pointer border-2 border-[#A780C3] flex gap-2 items-center justify-center text-center" - > -

+

-

افزودن ایموجی

-
- )} - - {/* ========= */} -
- setAddServiceTitle(e.target.value)} - placeholder="عنوان خدمت" - className="text-[#999999] border-2 border-[#999999] w-full py-3 px-3 rounded-full" - /> -
- - {/* ========buttons========= */} -
- - -
- - {isEmojiPickerOpen && ( -
-
-
setIsEmojiPickerOpen(false)} - className="absolute cursor-pointer bg-[#dedee3] text-[#999999] text-2xl rounded-full px-3.5 py-1 top-2 right-2 " - > - -
-
-

ایموجی خود را انتخاب کنید

-
- + {/* ========= */} +
+ setAddServiceTitle(e.target.value)} + placeholder="عنوان خدمت" + className="text-[#999999] border-2 border-[#999999] w-full py-3 px-4 rounded-full" + />
-
+ + {/* ServiceCost component inserted here */} +
+ +
+ + {/* ========buttons========= */} +
+ + +
+ + {isEmojiPickerOpen && ( +
+
+
setIsEmojiPickerOpen(false)} + className="absolute cursor-pointer bg-[#dedee3] text-[#999999] text-2xl rounded-full px-3.5 py-1 top-2 right-2 " + > + +
+
+

ایموجی خود را انتخاب کنید

+
+ +
+
+
+
+ )}
-
- )} -
-
- ); +
+ ); }; export default AddService; diff --git a/src/apps/new-ui/pages/AddStatus/AddStatus.tsx b/src/apps/new-ui/pages/AddStatus/AddStatus.tsx index 0d5e1ee1..48078caf 100644 --- a/src/apps/new-ui/pages/AddStatus/AddStatus.tsx +++ b/src/apps/new-ui/pages/AddStatus/AddStatus.tsx @@ -1,116 +1,100 @@ import { useState } from "react"; import editIcon from "../../assets/EditCTA.svg"; -import EmojiPicker, { - EmojiClickData, - EmojiStyle, - Emoji, -} from "emoji-picker-react"; +import EmojiPicker, { EmojiClickData, EmojiStyle, Emoji } from "emoji-picker-react"; interface Props { - onclick: () => void; + onclick: () => void; } const AddStatus: React.FC = ({ onclick }) => { - const [addServiceTitle, setAddServiceTitle] = useState(""); - const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false); - const [selectedEmoji, setSelectedEmoji] = useState(""); - const btnEnable = addServiceTitle !== "" && selectedEmoji !== ""; + const [addServiceTitle, setAddServiceTitle] = useState(""); + const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false); + const [selectedEmoji, setSelectedEmoji] = useState(""); + const btnEnable = addServiceTitle !== "" && selectedEmoji !== ""; - return ( -
-
-

- افزودن دسته‌بندی -

+ return ( +
+

افزودن دسته‌بندی

- {/* =======add/change emoji========= */} - {selectedEmoji ? ( -
-
- {selectedEmoji && ( - - )} -
-
setIsEmojiPickerOpen(true)} - className="w-[160px] h-10 text-[#000000E5] text-sm font-semibold rounded-full cursor-pointer border-2 border-[#A780C3] flex gap-2 items-center justify-center text-center" - > - editIcon -

تغییر تصویر

-
-
- ) : ( -
setIsEmojiPickerOpen(true)} - className="my-16 w-[160px] h-10 text-[#000000E5] text-sm font-semibold rounded-full cursor-pointer border-2 border-[#A780C3] flex gap-2 items-center justify-center text-center" - > -

+

-

افزودن ایموجی

-
- )} - - {/* ========= */} -
- setAddServiceTitle(e.target.value)} - placeholder="عنوان دسته‌بندی" - className="text-[#999999] border-2 border-[#999999] w-full py-3 px-3 rounded-full" - /> -
- - {/* ========buttons========= */} -
- - -
- - {isEmojiPickerOpen && ( -
-
-
setIsEmojiPickerOpen(false)} - className="absolute cursor-pointer bg-[#dedee3] text-[#999999] text-2xl rounded-full px-3.5 py-1 top-2 right-2 " - > - X -
-
-

ایموجی خود را انتخاب کنید

-
- { - setSelectedEmoji(emojiObject.unified); - setIsEmojiPickerOpen(false); - }} - /> + {/* =======add/change emoji========= */} + {selectedEmoji ? ( +
+
+ {selectedEmoji && } +
+
setIsEmojiPickerOpen(true)} + className="w-[160px] h-10 text-[#000000E5] text-sm font-semibold rounded-full cursor-pointer border-2 border-[#A780C3] flex gap-2 items-center justify-center text-center" + > + editIcon +

تغییر تصویر

+
-
+ ) : ( +
setIsEmojiPickerOpen(true)} + className="my-16 w-[160px] h-10 text-[#000000E5] text-sm font-semibold rounded-full cursor-pointer border-2 border-[#A780C3] flex gap-2 items-center justify-center text-center" + > +

+

+

افزودن ایموجی

+
+ )} + + {/* ========= */} +
+ setAddServiceTitle(e.target.value)} + placeholder="عنوان دسته‌بندی" + className="text-[#999999] border-2 border-[#999999] w-full py-3 px-4 rounded-full" + />
-
- )} -
-
- ); + + {/* ========buttons========= */} +
+ + +
+ + {isEmojiPickerOpen && ( +
+
+
setIsEmojiPickerOpen(false)} + className="absolute cursor-pointer bg-[#dedee3] text-[#999999] text-2xl rounded-full px-3.5 py-1 top-2 right-2 " + > + X +
+
+

ایموجی خود را انتخاب کنید

+
+ { + setSelectedEmoji(emojiObject.unified); + setIsEmojiPickerOpen(false); + }} + /> +
+
+
+
+ )} +
+ ); }; export default AddStatus; diff --git a/src/apps/new-ui/pages/AdvancedTurn/+components/Information.tsx b/src/apps/new-ui/pages/AdvancedTurn/+components/Information.tsx index 83840fb3..36b255e4 100644 --- a/src/apps/new-ui/pages/AdvancedTurn/+components/Information.tsx +++ b/src/apps/new-ui/pages/AdvancedTurn/+components/Information.tsx @@ -5,46 +5,76 @@ import ServiceCost from "../../../components/AppointmentComponents/ServiceCost"; import Services from "../../../components/AppointmentComponents/Services"; interface InformationProps { - onNext?: () => void; + onNext?: () => void; } const Information = ({ onNext }: InformationProps) => { - const [btnTrigger, setBtnTrigger] = useState(true); + const [btnTrigger, setBtnTrigger] = useState(true); + const [serviceCost, setServiceCost] = useState(() => { + if (typeof window === "undefined") return ""; + return localStorage.getItem("serviceCost") ?? ""; + }); - useEffect(() => { - const checkLocalStorage = () => { - const hasName = localStorage.getItem("selectedName")?.trim() !== ""; - // const hasCost = localStorage.getItem("serviceCost")?.trim() !== ""; - // const hasTitle = localStorage.getItem("serviceTitle")?.trim() !== ""; + useEffect(() => { + const checkLocalStorage = () => { + const hasName = localStorage.getItem("selectedName")?.trim() !== ""; + setBtnTrigger(!hasName); + localStorage.removeItem("appointmentTime"); + }; - setBtnTrigger(!hasName); - localStorage.removeItem('appointmentTime') + checkLocalStorage(); + + const intervalId = setInterval(checkLocalStorage, 500); + + return () => { + clearInterval(intervalId); + }; + }, []); + + useEffect(() => { + if (typeof window === "undefined") return; + + const onCustom = (e: Event) => { + const ev = e as CustomEvent; + const newCost = ev?.detail?.cost ?? localStorage.getItem("serviceCost") ?? ""; + setServiceCost(String(newCost ?? "")); + }; + + const onStorage = (e: StorageEvent) => { + if (e.key === "serviceCost") { + setServiceCost(e.newValue ?? ""); + } + }; + + window.addEventListener("serviceCostChanged", onCustom as EventListener); + window.addEventListener("storage", onStorage as EventListener); + + const pollId = setInterval(() => { + const cur = localStorage.getItem("serviceCost") ?? ""; + setServiceCost((prev) => (prev !== cur ? cur : prev)); + }, 1000); + + return () => { + window.removeEventListener("serviceCostChanged", onCustom as EventListener); + window.removeEventListener("storage", onStorage as EventListener); + clearInterval(pollId); + }; + }, []); + + const handleClick = () => { + if (onNext) { + onNext(); + } }; - checkLocalStorage(); - - const intervalId = setInterval(checkLocalStorage, 500); - - return () => { - clearInterval(intervalId); - }; - }, []); - - const handleClick = () => { - console.log("ارسال اطلاعات..."); - if (onNext) { - onNext(); - } - }; - - return ( -
- - - - -
- ); + return ( +
+ + + + +
+ ); }; -export default Information; \ No newline at end of file +export default Information; diff --git a/src/apps/new-ui/pages/AdvancedTurn/AdvancedTurnSend/index.tsx b/src/apps/new-ui/pages/AdvancedTurn/AdvancedTurnSend/index.tsx index 30361dee..87e7d74c 100644 --- a/src/apps/new-ui/pages/AdvancedTurn/AdvancedTurnSend/index.tsx +++ b/src/apps/new-ui/pages/AdvancedTurn/AdvancedTurnSend/index.tsx @@ -219,7 +219,7 @@ export default function AdvancedTurnSend() { phone_number: selectedPhoneNumber, date: selectedDate.replace(/\//g, "-"), time: selectedTime, - description: selectedCost, + description: "", service: selectedSlideTitle, Done: false, send_reminder_sms: selectedReminderSMS, @@ -231,6 +231,7 @@ export default function AdvancedTurnSend() { send_sms: selectedSendSMS, pattern: selectedRepairPattern, sms_card_link: null, + price: Number(selectedCost.replace(/,/g, "")), send_sms_after_edit: false, pattern_after_edit: selectedPatternAfterEdit, }; @@ -263,7 +264,7 @@ export default function AdvancedTurnSend() { return; } - debugger + debugger; if (reminderRepairSMS && !repairReminderDays) { showErrorAlert("در صورت فعال بودن یادآوری ترمیم، انتخاب دوره ترمیم الزامی است."); return; diff --git a/src/apps/new-ui/pages/CashBack/+components/UseCode.tsx b/src/apps/new-ui/pages/CashBack/+components/UseCode.tsx new file mode 100644 index 00000000..9b5cb852 --- /dev/null +++ b/src/apps/new-ui/pages/CashBack/+components/UseCode.tsx @@ -0,0 +1,72 @@ +import { useUseCashback } from "@/apps/appointment/services/CashBack"; +import { useState } from "react"; + +interface Props { + onclick: () => void; +} + +const UseCode: React.FC = ({ onclick }) => { + const [code, setCode] = useState(""); + const { mutateAsync } = useUseCashback(); + + const handleChange = (e: React.ChangeEvent) => { + const onlyDigits = e.target.value.replace(/\D/g, ""); + setCode(onlyDigits); + }; + + const handlePaste = (e: React.ClipboardEvent) => { + const paste = e.clipboardData.getData("text"); + const onlyDigits = paste.replace(/\D/g, ""); + if (onlyDigits !== paste) { + e.preventDefault(); + const target = e.target as HTMLInputElement; + const newVal = (target.value + onlyDigits).slice(0, 6); + setCode(newVal); + } + }; + + return ( +
+

استفاده کد بازگشت وجه

+ + {/* ========= */} +
+ +
+ + {/* ========buttons========= */} +
+ + +
+
+ ); +}; + +export default UseCode; diff --git a/src/apps/new-ui/pages/CashBack/index.tsx b/src/apps/new-ui/pages/CashBack/index.tsx index bb37820f..f127bfdf 100644 --- a/src/apps/new-ui/pages/CashBack/index.tsx +++ b/src/apps/new-ui/pages/CashBack/index.tsx @@ -16,6 +16,9 @@ import SearchBar from "./+components/SearchBar"; import { showWarningAlert } from "../../components/CustomAlert"; import SubscriptionWrapper from "../../components/SubscriptionWrapper"; import { useGetUserInfo } from "../../services/User"; +import { Button } from "@/components/ui/button"; +import { XIcon } from "lucide-react"; +import UseCode from "./+components/UseCode"; const STAGGER_MS = 150; const INITIAL_DELAY = 120; @@ -41,6 +44,7 @@ const CashbacksPage: React.FC = () => { const deleteMutation = useDeleteCashback(); const [searchMain, setSearchMain] = useState(""); + const [isCanUseCodeOpenModal, setIsCanUseCodeOpenModal] = useState(false); const [debouncedSearch, setDebouncedSearch] = useState(undefined); const [debouncedPhone, setDebouncedPhone] = useState(undefined); @@ -201,7 +205,16 @@ const CashbacksPage: React.FC = () => {
- +
+ + +
{ )}
+ + {isUserAbilitiesList && isCanUseCodeOpenModal && ( +
setIsCanUseCodeOpenModal(false)} + className="fixed z-[200] inset-0 bg-black/20 backdrop-blur-sm flex justify-center items-center" + > +
e.stopPropagation()} + className="relative bg-white w-[95%] max-w-lg max-h-[80vh] rounded-2xl p-4 " + > +
setIsCanUseCodeOpenModal(false)} + className="absolute cursor-pointer text-[#999999] text-2xl rounded-full p-2 top-2 left-2 " + > + +
+ setIsCanUseCodeOpenModal(false)} /> +
+
+ )}
); }; diff --git a/src/apps/new-ui/pages/Notification/index.tsx b/src/apps/new-ui/pages/Notification/index.tsx index e24b1903..33ffa878 100644 --- a/src/apps/new-ui/pages/Notification/index.tsx +++ b/src/apps/new-ui/pages/Notification/index.tsx @@ -1,377 +1,401 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { - Notification, - useGetUnreadNotificationCount, - useGetUserNotifications, - useMarkNotificationSeen, + Notification, + notificationQueryKeys, + useGetUnreadNotificationCount, + useGetUserNotifications, + useMarkNotificationSeen, } from "../../services/Notifications"; import { useNavigate } from "react-router-dom"; import BackBtn from "@/components/BackBtn"; import alertIcon from "@new-ui/assets/alert.svg"; +import { useQueryClient } from "@tanstack/react-query"; function safeParseButtons(raw: string | unknown) { - try { - if (Array.isArray(raw)) return raw; - if (typeof raw === "string" && raw.trim() !== "") { - return JSON.parse(raw); + try { + if (Array.isArray(raw)) return raw; + if (typeof raw === "string" && raw.trim() !== "") { + return JSON.parse(raw); + } + return []; + } catch { + return []; } - return []; - } catch { - return []; - } } export const NotificationsPhoneStyle: React.FC = () => { - const [search, setSearch] = useState(""); - const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [open, setOpen] = useState(false); - const { data: unreadData } = useGetUnreadNotificationCount(); + const { data: unreadData } = useGetUnreadNotificationCount(); - const { data: listData, isLoading } = useGetUserNotifications(open); + const { data: listData, isLoading } = useGetUserNotifications(open); + const queryClient = useQueryClient(); - const markSeen = useMarkNotificationSeen(); - const navigate = useNavigate(); + const markSeen = useMarkNotificationSeen(); + const navigate = useNavigate(); - const [localItems, setLocalItems] = useState([]); - const [removingIds, setRemovingIds] = useState>(new Set()); - const [localSeenIds, setLocalSeenIds] = useState>(new Set()); + const [localItems, setLocalItems] = useState([]); + const [removingIds, setRemovingIds] = useState>(new Set()); + const [localSeenIds, setLocalSeenIds] = useState>(new Set()); - const [translateMap, setTranslateMap] = useState>({}); + const [translateMap, setTranslateMap] = useState>({}); - const draggingIdRef = useRef(null); - const startXRef = useRef>(new Map()); + const draggingIdRef = useRef(null); + const startXRef = useRef>(new Map()); - const timersRef = useRef([]); - useEffect(() => { - return () => { - timersRef.current.forEach((t) => clearTimeout(t)); - timersRef.current = []; - }; - }, []); + const timersRef = useRef([]); + useEffect(() => { + return () => { + timersRef.current.forEach((t) => clearTimeout(t)); + timersRef.current = []; + }; + }, []); - useEffect(() => { - const server = listData?.notifications ?? []; - setLocalItems((prev) => { - if (!prev.length) return server.filter((s) => !localSeenIds.has(s.id)); + useEffect(() => { + const server = listData?.notifications ?? []; + setLocalItems((prev) => { + if (!prev.length) return server.filter((s) => !localSeenIds.has(s.id)); - const kept = prev.filter((p) => removingIds.has(p.id)); - const merged = server.filter((s) => !removingIds.has(s.id) && !localSeenIds.has(s.id)).concat(kept); - return merged; - }); - }, [listData, removingIds, localSeenIds]); + const kept = prev.filter((p) => removingIds.has(p.id)); + const merged = server.filter((s) => !removingIds.has(s.id) && !localSeenIds.has(s.id)).concat(kept); + return merged; + }); + }, [listData, removingIds, localSeenIds]); - const filtered = useMemo(() => { - const s = search.trim().toLowerCase(); - if (!s) return localItems; - return localItems.filter((n) => { - return ( - (n.title || "").toLowerCase().includes(s) || - (n.content || "").toLowerCase().includes(s) - ); - }); - }, [localItems, search]); + const filtered = useMemo(() => { + const s = search.trim().toLowerCase(); + if (!s) return localItems; + return localItems.filter((n) => { + return (n.title || "").toLowerCase().includes(s) || (n.content || "").toLowerCase().includes(s); + }); + }, [localItems, search]); - const ANIM_DURATION = 300; - const DISMISS_THRESHOLD = 80; + const ANIM_DURATION = 300; + const DISMISS_THRESHOLD = 80; - const removeItemLocally = (id: number) => { - setLocalItems((prev) => prev.filter((x) => x.id !== id)); - setTranslateMap((prev) => { - const next = { ...prev }; - delete next[id]; - return next; - }); - setRemovingIds((prev) => { - const next = new Set(prev); - next.delete(id); - return next; - }); - setLocalSeenIds((prev) => { - const next = new Set(prev); - next.delete(id); - return next; - }); - }; - - const startDrag = (e: React.PointerEvent, id: number) => { - const target = e.target as HTMLElement; - if (target.tagName === "BUTTON" || target.closest("button")) return; - - draggingIdRef.current = id; - startXRef.current.set(id, e.clientX); - try { - (e.currentTarget as Element).setPointerCapture(e.pointerId); - } catch {} - }; - - const onPointerMove = (e: React.PointerEvent, id: number) => { - const start = startXRef.current.get(id); - if (start == null) return; - const delta = e.clientX - start; - setTranslateMap((prev) => { - if (prev[id] === delta) return prev; - return { ...prev, [id]: delta }; - }); - }; - - const endDrag = (e: React.PointerEvent, n: Notification) => { - const id = n.id; - const start = startXRef.current.get(id); - if (start == null) return; - const delta = e.clientX - start; - try { - (e.currentTarget as Element).releasePointerCapture(e.pointerId); - } catch {} - startXRef.current.delete(id); - draggingIdRef.current = null; - - if (Math.abs(delta) >= DISMISS_THRESHOLD) { - const sign = delta > 0 ? 1 : -1; - const offX = sign * (window.innerWidth || 800); - setTranslateMap((prev) => ({ ...prev, [id]: offX })); - setRemovingIds((prev) => new Set(prev).add(id)); - setLocalSeenIds((prev) => new Set(prev).add(id)); - - markSeen.mutate(id, { - onError: () => { - setRemovingIds((prev) => { - const next = new Set(prev); - next.delete(id); + const removeItemLocally = (id: number) => { + setLocalItems((prev) => prev.filter((x) => x.id !== id)); + setTranslateMap((prev) => { + const next = { ...prev }; + delete next[id]; return next; - }); - setLocalSeenIds((prev) => { - const next = new Set(prev); - next.delete(id); - return next; - }); - setTranslateMap((prev) => ({ ...prev, [id]: 0 })); - }, - }); - - const t = window.setTimeout(() => { - removeItemLocally(id); - }, ANIM_DURATION + 30); - timersRef.current.push(t); - } else { - setTranslateMap((prev) => ({ ...prev, [id]: 0 })); - } - }; - - const handleAcknowledge = (n: Notification) => { - const id = n.id; - if (removingIds.has(id)) return; - const sign = 1; - const offX = sign * (window.innerWidth || 800); - setTranslateMap((prev) => ({ ...prev, [id]: offX })); - setRemovingIds((prev) => new Set(prev).add(id)); - setLocalSeenIds((prev) => new Set(prev).add(id)); - - markSeen.mutate(id, { - onError: () => { + }); setRemovingIds((prev) => { - const next = new Set(prev); - next.delete(id); - return next; + const next = new Set(prev); + next.delete(id); + return next; }); setLocalSeenIds((prev) => { - const next = new Set(prev); - next.delete(id); - return next; + const next = new Set(prev); + next.delete(id); + return next; }); - setTranslateMap((prev) => ({ ...prev, [id]: 0 })); - }, - }); + }; - const t = window.setTimeout(() => { - removeItemLocally(id); - }, ANIM_DURATION + 30); - timersRef.current.push(t); - }; + const startDrag = (e: React.PointerEvent, id: number) => { + const target = e.target as HTMLElement; + if (target.tagName === "BUTTON" || target.closest("button")) return; - // render - const unreadCount = unreadData?.unread_count ?? 0; + draggingIdRef.current = id; + startXRef.current.set(id, e.clientX); + try { + (e.currentTarget as Element).setPointerCapture(e.pointerId); + } catch {} + }; - return ( - <> - {/* wrap img so we can position a badge */} -
- alert icon setOpen(true)} - className="cursor-pointer" - /> + const onPointerMove = (e: React.PointerEvent, id: number) => { + const start = startXRef.current.get(id); + if (start == null) return; + const delta = e.clientX - start; + setTranslateMap((prev) => { + if (prev[id] === delta) return prev; + return { ...prev, [id]: delta }; + }); + }; - {unreadCount > 0 && ( - - {unreadCount > 99 ? "99+" : unreadCount} - - )} -
+ const endDrag = (e: React.PointerEvent, n: Notification) => { + const id = n.id; + const start = startXRef.current.get(id); + if (start == null) return; + const delta = e.clientX - start; + try { + (e.currentTarget as Element).releasePointerCapture(e.pointerId); + } catch {} + startXRef.current.delete(id); + draggingIdRef.current = null; - {open && ( -
-
setOpen(false)} - className="absolute inset-0 pointer-events-auto" - style={{ - background: "rgba(0,0,0,0.35)", - backdropFilter: "blur(8px)", - WebkitBackdropFilter: "blur(8px)", - }} - /> + if (Math.abs(delta) >= DISMISS_THRESHOLD) { + const sign = delta > 0 ? 1 : -1; + const offX = sign * (window.innerWidth || 800); + setTranslateMap((prev) => ({ ...prev, [id]: offX })); + setRemovingIds((prev) => new Set(prev).add(id)); + setLocalSeenIds((prev) => new Set(prev).add(id)); -
e.stopPropagation()} - > -
- setOpen(false)} /> + markSeen.mutate(id, { + onError: () => { + setRemovingIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + setLocalSeenIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + setTranslateMap((prev) => ({ ...prev, [id]: 0 })); + }, + }); - setSearch(e.target.value)} - placeholder="جستجو..." - maxLength={25} - className="flex-1 px-4 py-2 rounded-full bg-white placeholder:text-gray-400 text-gray-900 outline-none border border-gray-200" - aria-label="جستجو اعلان" - /> -
+ const t = window.setTimeout(() => { + removeItemLocally(id); + }, ANIM_DURATION + 30); + timersRef.current.push(t); + } else { + setTranslateMap((prev) => ({ ...prev, [id]: 0 })); + } + }; -
- خوانده‌ نشده: {unreadCount} -
+ const handleAcknowledge = (n: Notification) => { + const id = n.id; + if (removingIds.has(id)) return; + const sign = 1; + const offX = sign * (window.innerWidth || 800); + setTranslateMap((prev) => ({ ...prev, [id]: offX })); + setRemovingIds((prev) => new Set(prev).add(id)); + setLocalSeenIds((prev) => new Set(prev).add(id)); -
    - {isLoading && ( -
    درحال بارگذاری...
    - )} - {!isLoading && filtered.length === 0 && ( -
    - اعلانی یافت نشد -
    - )} + markSeen.mutate(id, { + onError: () => { + setRemovingIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + setLocalSeenIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + setTranslateMap((prev) => ({ ...prev, [id]: 0 })); + }, + }); - {filtered.map((n) => { - if (n.seen) return null; - const buttons = safeParseButtons(n.buttons) as Array<{ - title: string; - action: string; - }>; - const isRemoving = removingIds.has(n.id); - const translate = translateMap[n.id] ?? 0; - const isDragging = draggingIdRef.current === n.id; + const t = window.setTimeout(() => { + removeItemLocally(id); + }, ANIM_DURATION + 30); + timersRef.current.push(t); + }; - return ( -
  • -
    startDrag(e, n.id)} - onPointerMove={(e) => onPointerMove(e, n.id)} - onPointerUp={(e) => endDrag(e, n)} - onPointerCancel={(e) => endDrag(e as any, n)} - className={`flex justify-between items-start p-3 rounded-xl shadow-sm border border-gray-100`} - style={{ - background: "white", - transform: `translateX(${translate}px)`, - transition: isDragging - ? "none" - : `transform ${ANIM_DURATION}ms ease, opacity ${ANIM_DURATION}ms ease`, - opacity: isRemoving ? 0 : 1, - touchAction: "pan-y", - }} + // تنظیم متغیر --vh برای موبایل (حل مشکل 100vh با address bar در iOS/Android) + useEffect(() => { + const setVh = () => { + if (typeof window === "undefined" || !window.innerHeight) return; + document.documentElement.style.setProperty("--vh", `${window.innerHeight * 0.01}px`); + }; + setVh(); + window.addEventListener("resize", setVh); + window.addEventListener("orientationchange", setVh); + return () => { + window.removeEventListener("resize", setVh); + window.removeEventListener("orientationchange", setVh); + }; + }, []); + + // وقتی مودال بازه، اسکرول بدنه رو قفل کن تا پشت مودال حرکت نکنه + useEffect(() => { + const prev = document.body.style.overflow; + if (open) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = prev; + } + return () => { + document.body.style.overflow = prev; + }; + }, [open]); + + // render + const unreadCount = unreadData?.unread_count ?? 0; + + return ( + <> + {/* wrap img so we can position a badge */} +
    + alert icon setOpen(true)} className="cursor-pointer" /> + + {unreadCount > 0 && ( + -
    -
    -

    - {n.title} -

    - + {unreadCount > 99 ? "99+" : unreadCount} + + )} +
    + + {open && ( +
    +
    setOpen(false)} + className="absolute inset-0 pointer-events-auto" + style={{ + background: "rgba(0,0,0,0.35)", + backdropFilter: "blur(8px)", + WebkitBackdropFilter: "blur(8px)", + }} + /> + + {/* کانتینر مودال: از --vh استفاده می‌کنیم، display:flex column */} +
    e.stopPropagation()} + style={{ + // از var(--vh) استفاده کن؛ عدد 3rem برای حاشیه/فاصله بالایی/پایینی قابل تنظیمه + height: "calc(var(--vh, 1vh) * 100 - 3rem)", + maxHeight: "calc(var(--vh, 1vh) * 100 - 3rem)", + }} + > + {/* header ثابت (دکمه برگشت + سرچ) */} +
    + setOpen(false)} /> + + setSearch(e.target.value)} + placeholder="جستجو..." + maxLength={25} + className="flex-1 px-4 py-2 rounded-full bg-white placeholder:text-gray-400 text-gray-900 outline-none border border-gray-200" + aria-label="جستجو اعلان" + />
    -

    - {n.content} -

    - {buttons.length > 0 && ( -
    - {buttons.map((b, idx) => { - const handleClick = (ev?: React.MouseEvent) => { - ev?.stopPropagation(); - if (b.action === "quit") { - handleAcknowledge(n); - } else if (/^https?:\/\//i.test(b.action)) { - window.open(b.action, "_blank"); - } else { - navigate(b.action); - } - }; +
    خوانده‌ نشده: {unreadCount}
    - return ( - - ); - })} -
    - )} + {/* بخش اسکرولی: flex-1 باعث میشه تمام ارتفاع باقیمانده رو بگیره و اسکرول کنه */} +
    + {isLoading &&
    درحال بارگذاری...
    } + {!isLoading && filtered.length === 0 && ( +
    اعلانی یافت نشد
    + )} - {!buttons.some((b) => b.action === "quit") && ( -
    - -
    - )} -
    +
      + {filtered.map((n) => { + if (n.seen) return null; + const buttons = safeParseButtons(n.buttons) as Array<{ + text: string; + action: string; + }>; -
      - {!n.seen && ( - - )} -
      + const isRemoving = removingIds.has(n.id); + const translate = translateMap[n.id] ?? 0; + const isDragging = draggingIdRef.current === n.id; + + return ( +
    • +
      startDrag(e, n.id)} + onPointerMove={(e) => onPointerMove(e, n.id)} + onPointerUp={(e) => endDrag(e, n)} + onPointerCancel={(e) => endDrag(e as any, n)} + className={`flex justify-between items-start p-3 rounded-xl shadow-sm border border-gray-100`} + style={{ + background: "white", + transform: `translateX(${translate}px)`, + transition: isDragging + ? "none" + : `transform ${ANIM_DURATION}ms ease, opacity ${ANIM_DURATION}ms ease`, + opacity: isRemoving ? 0 : 1, + touchAction: "pan-y", + }} + > +
      +
      +

      {n.title}

      + +
      +

      {n.content}

      + + {buttons.length > 0 && ( +
      + {buttons.map((b, idx) => { + const handleClick = (ev?: React.MouseEvent) => { + ev?.stopPropagation(); + if (b.action === "quit") { + handleAcknowledge(n); + } else if (/^https?:\/\//i.test(b.action)) { + window.open(b.action, "_blank"); + } else { + navigate(b.action); + } + }; + + return ( + + ); + })} +
      + )} + + {!buttons.some((b) => b.action === "quit") && ( +
      + +
      + )} +
      + +
      + {!n.seen && ( + + )} +
      +
      +
    • + ); + })} +
    +
    -
  • - ); - })} -
-
-
- )} - - ); +
+ )} + + ); }; export default NotificationsPhoneStyle; diff --git a/src/apps/new-ui/pages/appointments/+components/CustomerToggle.tsx b/src/apps/new-ui/pages/appointments/+components/CustomerToggle.tsx index 5ac42495..179bb3d7 100644 --- a/src/apps/new-ui/pages/appointments/+components/CustomerToggle.tsx +++ b/src/apps/new-ui/pages/appointments/+components/CustomerToggle.tsx @@ -6,218 +6,200 @@ import setTimeCustomer from "../../../assets/appointment/setTimeCustomer.svg"; import setTimeCustomerSelected from "../../../assets/appointment/setTimeCustomerSelected.svg"; type Props = { - customerTime: boolean; - setCustomerTime: (v: boolean) => void; - className?: string; - size?: "sm" | "md"; + customerTime: boolean; + setCustomerTime: (v: boolean) => void; + className?: string; + size?: "sm" | "md"; }; -const CustomerTimeToggle: React.FC = ({ - customerTime, - setCustomerTime, - className = "", - size = "md", -}) => { - const sm = size === "sm"; +const CustomerTimeToggle: React.FC = ({ customerTime, setCustomerTime, className = "", size = "md" }) => { + const sm = size === "sm"; - const containerRef = useRef(null); - const btnRefs = useRef>([]); + const containerRef = useRef(null); + const btnRefs = useRef>([]); - const isDraggingRef = useRef(false); - const dragStartXRef = useRef(0); - const dragStartValueRef = useRef(false); - const dragOffsetRef = useRef(0); + const isDraggingRef = useRef(false); + const dragStartXRef = useRef(0); + const dragStartValueRef = useRef(false); + const dragOffsetRef = useRef(0); - const [indicator, setIndicator] = useState({ - left: 0, - top: 0, - width: 0, - height: 0, - opacity: 0, - }); - - const [dragOffset, setDragOffset] = useState(0); - const [isDragging, setIsDragging] = useState(false); - - const measure = () => { - const idx = customerTime ? 0 : 1; - const container = containerRef.current; - const btn = btnRefs.current[idx]; - if (!container || !btn) return; - - const cRect = container.getBoundingClientRect(); - const bRect = btn.getBoundingClientRect(); - - setIndicator({ - left: Math.round(bRect.left - cRect.left), - top: Math.round(bRect.top - cRect.top), - width: Math.round(bRect.width), - height: Math.round(bRect.height), - opacity: 1, + const [indicator, setIndicator] = useState({ + left: 0, + top: 0, + width: 0, + height: 0, + opacity: 0, }); - }; - // measure on change - useLayoutEffect(() => { - requestAnimationFrame(measure); - }, [customerTime, size]); + const [dragOffset, setDragOffset] = useState(0); + const [isDragging, setIsDragging] = useState(false); - useEffect(() => { - const container = containerRef.current; - if (!container) return; + const measure = () => { + const idx = customerTime ? 0 : 1; + const container = containerRef.current; + const btn = btnRefs.current[idx]; + if (!container || !btn) return; - const ro = new ResizeObserver(() => requestAnimationFrame(measure)); - ro.observe(container); - btnRefs.current.forEach((b) => b && ro.observe(b)); + const cRect = container.getBoundingClientRect(); + const bRect = btn.getBoundingClientRect(); - const onWin = () => requestAnimationFrame(measure); - window.addEventListener("resize", onWin); - - return () => { - ro.disconnect(); - window.removeEventListener("resize", onWin); - }; - }, []); - - // DRAG START - const handleDragStart = (e: React.MouseEvent | React.TouchEvent) => { - e.preventDefault(); - - const clientX = - "touches" in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX; - - isDraggingRef.current = true; - dragStartXRef.current = clientX; - dragStartValueRef.current = customerTime; - dragOffsetRef.current = 0; - - setIsDragging(true); - setDragOffset(0); - - // DRAG MOVE - const handleMove = (ev: MouseEvent | TouchEvent) => { - if (!isDraggingRef.current) return; - - const moveX = - "touches" in ev ? ev.touches[0].clientX : (ev as MouseEvent).clientX; - - const delta = moveX - dragStartXRef.current; // ✔ بدون برعکس کردن - - const container = containerRef.current; - if (!container) return; - - const maxOffset = container.getBoundingClientRect().width / 2; - - const clamped = Math.max(-maxOffset, Math.min(maxOffset, delta)); - - dragOffsetRef.current = clamped; - setDragOffset(clamped); -}; - - - // DRAG END - const handleEnd = () => { - if (!isDraggingRef.current) return; - - isDraggingRef.current = false; - setIsDragging(false); - - const container = containerRef.current; - if (!container) return; - - const threshold = container.getBoundingClientRect().width * 0.2; - - // check threshold - if (Math.abs(dragOffsetRef.current) > threshold) { - setCustomerTime(!dragStartValueRef.current); - } - - setDragOffset(0); - dragOffsetRef.current = 0; - - document.removeEventListener("mousemove", handleMove); - document.removeEventListener("mouseup", handleEnd); - document.removeEventListener("touchmove", handleMove as any); - document.removeEventListener("touchend", handleEnd); + setIndicator({ + left: Math.round(bRect.left - cRect.left), + top: Math.round(bRect.top - cRect.top), + width: Math.round(bRect.width), + height: Math.round(bRect.height), + opacity: 1, + }); }; - document.addEventListener("mousemove", handleMove); - document.addEventListener("mouseup", handleEnd); - document.addEventListener("touchmove", handleMove as any); - document.addEventListener("touchend", handleEnd); - }; + // measure on change + useLayoutEffect(() => { + requestAnimationFrame(measure); + }, [customerTime, size]); - const btnPadding = sm ? "px-3 py-2 text-xs" : "px-3 py-2 text-sm"; + useEffect(() => { + const container = containerRef.current; + if (!container) return; - const indicatorLeft = indicator.left + (isDragging ? dragOffset : 0); + const ro = new ResizeObserver(() => requestAnimationFrame(measure)); + ro.observe(container); + btnRefs.current.forEach((b) => b && ro.observe(b)); - return ( -
-
- setTime -

تنظیم وقت توسط :

-
+ const onWin = () => requestAnimationFrame(measure); + window.addEventListener("resize", onWin); -
- {/* INDICATOR */} - - {/* DRAG OVERLAY */} -
+ return () => { + ro.disconnect(); + window.removeEventListener("resize", onWin); + }; + }, []); - {/* BUTTONS */} - + // DRAG START + const handleDragStart = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); - -
-
- ); + const clientX = "touches" in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX; + + isDraggingRef.current = true; + dragStartXRef.current = clientX; + dragStartValueRef.current = customerTime; + dragOffsetRef.current = 0; + + setIsDragging(true); + setDragOffset(0); + + // DRAG MOVE + const handleMove = (ev: MouseEvent | TouchEvent) => { + if (!isDraggingRef.current) return; + + const moveX = "touches" in ev ? ev.touches[0].clientX : (ev as MouseEvent).clientX; + + const delta = moveX - dragStartXRef.current; // ✔ بدون برعکس کردن + + const container = containerRef.current; + if (!container) return; + + const maxOffset = container.getBoundingClientRect().width / 2; + + const clamped = Math.max(-maxOffset, Math.min(maxOffset, delta)); + + dragOffsetRef.current = clamped; + setDragOffset(clamped); + }; + + // DRAG END + const handleEnd = () => { + if (!isDraggingRef.current) return; + + isDraggingRef.current = false; + setIsDragging(false); + + const container = containerRef.current; + if (!container) return; + + const threshold = container.getBoundingClientRect().width * 0.2; + + // check threshold + if (Math.abs(dragOffsetRef.current) > threshold) { + setCustomerTime(!dragStartValueRef.current); + } + + setDragOffset(0); + dragOffsetRef.current = 0; + + document.removeEventListener("mousemove", handleMove); + document.removeEventListener("mouseup", handleEnd); + document.removeEventListener("touchmove", handleMove as any); + document.removeEventListener("touchend", handleEnd); + }; + + document.addEventListener("mousemove", handleMove); + document.addEventListener("mouseup", handleEnd); + document.addEventListener("touchmove", handleMove as any); + document.addEventListener("touchend", handleEnd); + }; + + const btnPadding = sm ? "px-3 py-2 text-xs" : "px-3 py-2 text-sm"; + + const indicatorLeft = indicator.left + (isDragging ? dragOffset : 0); + + return ( +
+
+ setTime +

تنظیم وقت توسط :

+
+ +
+ {/* INDICATOR */} + + {/* DRAG OVERLAY */} +
+ + {/* BUTTONS */} + + + +
+
+ ); }; export default CustomerTimeToggle; diff --git a/src/apps/new-ui/pages/appointments/+components/QuickAppointment.tsx b/src/apps/new-ui/pages/appointments/+components/QuickAppointment.tsx index d921276e..1a8da954 100644 --- a/src/apps/new-ui/pages/appointments/+components/QuickAppointment.tsx +++ b/src/apps/new-ui/pages/appointments/+components/QuickAppointment.tsx @@ -339,6 +339,7 @@ const QuickAppointment: React.FC = () => { const selectedSendReminderPattern = localStorage.getItem("appointment_with_service_sms_pattern_Id"); const selectedRepairPattern = localStorage.getItem("reminder_sms_pattern_Id"); + const cost = localStorage.getItem("servicePrice") ?? 0; const payload: IAppointment = { name, @@ -355,6 +356,7 @@ const QuickAppointment: React.FC = () => { pattern: selectedRepairPattern, send_reminder_sms_pattern: selectedSendReminderPattern, send_sms: false, + price: +cost, sms_card_link: null, send_sms_after_edit: false, pattern_after_edit: null, diff --git a/src/apps/new-ui/pages/support/tickets/index.tsx b/src/apps/new-ui/pages/support/tickets/index.tsx index d6a3c356..d16b1838 100644 --- a/src/apps/new-ui/pages/support/tickets/index.tsx +++ b/src/apps/new-ui/pages/support/tickets/index.tsx @@ -107,12 +107,14 @@ const Tickets = () => {
{/* Open sheet instead of navigating */} - - +
diff --git a/src/apps/new-ui/services/Notifications.tsx b/src/apps/new-ui/services/Notifications.tsx index 1108496a..6ae8f3fb 100644 --- a/src/apps/new-ui/services/Notifications.tsx +++ b/src/apps/new-ui/services/Notifications.tsx @@ -2,7 +2,7 @@ import { useQuery, useMutation } from "@tanstack/react-query"; import { appointmentClient } from "@/utils/axios-interceptor"; import { useQueryClient } from "@tanstack/react-query"; -const notificationQueryKeys = { +export const notificationQueryKeys = { NOTIFICATIONS: ["NOTIFICATIONS"] as const, UNREAD_COUNT: ["NOTIFICATIONS", "UNREAD_COUNT"] as const, }; @@ -21,9 +21,11 @@ export type Notification = { buttons: NotificationButton[]; }; +// ... imports مثل قبل + export const useGetUserNotifications = (enabled: boolean = true) => { return useQuery({ - queryKey: [...notificationQueryKeys.NOTIFICATIONS, "LIST"], + queryKey: notificationQueryKeys.NOTIFICATIONS, // <-- مطمئن شو همان کلید استفاده میشه queryFn: async (): Promise<{ notifications: Notification[] } | undefined> => { const { data } = await appointmentClient.get("/get_user_notifications"); return data; @@ -32,6 +34,17 @@ export const useGetUserNotifications = (enabled: boolean = true) => { }); }; +export const useGetUnreadNotificationCount = (enabled = true) => { + return useQuery({ + queryKey: notificationQueryKeys.UNREAD_COUNT, // <-- همانطور مستقیم + queryFn: async (): Promise<{ unread_count: number } | undefined> => { + const { data } = await appointmentClient.get("/get_unread_notification_count"); + return data; + }, + enabled, + }); +}; + export const useMarkNotificationSeen = () => { const queryClient = useQueryClient(); @@ -41,21 +54,15 @@ export const useMarkNotificationSeen = () => { return data; }, onSuccess: () => { - // بعد از مارک شدن، لیست نوتیفیکیشن‌ها و تعداد خوانده نشده را refetch کن - queryClient.invalidateQueries({ queryKey: notificationQueryKeys.NOTIFICATIONS }); // برای لیست - queryClient.invalidateQueries({ queryKey: notificationQueryKeys.UNREAD_COUNT }); // برای شمارش + // استفاده از refetchType: 'all' برای refetch کردن حتی کوئری‌های غیرفعال + queryClient.invalidateQueries({ + queryKey: notificationQueryKeys.NOTIFICATIONS, + refetchType: "all", + }); + queryClient.invalidateQueries({ + queryKey: notificationQueryKeys.UNREAD_COUNT, + refetchType: "all", + }); }, }); }; - - -export const useGetUnreadNotificationCount = (enabled = true) => { - return useQuery({ - queryKey: notificationQueryKeys.UNREAD_COUNT, - queryFn: async (): Promise<{ unread_count: number } | undefined> => { - const { data } = await appointmentClient.get("/get_unread_notification_count"); - return data; - }, - enabled, - }); -}; diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx index 843c29a9..91730be5 100644 --- a/src/components/SplashScreen.tsx +++ b/src/components/SplashScreen.tsx @@ -1,29 +1,23 @@ -import { ReactNode, useEffect, useState } from 'react'; -import { BeatLoader } from 'react-spinners'; - +import { ReactNode, useEffect, useState } from "react"; +import giftLoading from '@public/lotties/../Splash.gif'; const SplashScreen = ({ children }: { children: ReactNode }) => { - // Default value of splash screen is false when we are in development mode - const [isLoading, setIsLoading] = useState(import.meta.env.MODE !== 'development'); - useEffect(() => { - const timer = setTimeout(() => { - setIsLoading(false); - }, 3000); - return () => clearTimeout(timer); - }, []); - if (isLoading) - return ( -
- App Logo - -

Salona - {import.meta.env.VITE_APP_VERSION}

-
- ); - return children; + const [isLoading, setIsLoading] = useState(import.meta.env.MODE !== "development"); + useEffect(() => { + const timer = setTimeout(() => { + setIsLoading(false); + }, 3000); + return () => clearTimeout(timer); + }, []); + if (isLoading) + return ( +
+ App Logo +

+ Salona - {import.meta.env.VITE_APP_VERSION} +

+
+ ); + return children; }; export default SplashScreen;