refactor calculation code
- implement fee - centralize the code
This commit is contained in:
@@ -159,7 +159,7 @@
|
|||||||
"browseMenu": "تصفح القائمة",
|
"browseMenu": "تصفح القائمة",
|
||||||
"paymentSummary": "ملخص الدفعة",
|
"paymentSummary": "ملخص الدفعة",
|
||||||
"subtotal": "المجموع الفرعي",
|
"subtotal": "المجموع الفرعي",
|
||||||
"tax": "الضريبة (10%)",
|
"tax": "الضريبة",
|
||||||
"remove": "حذف",
|
"remove": "حذف",
|
||||||
"proceedToCheckout": "المتابعة إلى الدفع",
|
"proceedToCheckout": "المتابعة إلى الدفع",
|
||||||
"totalAmount": "المجموع الكلي",
|
"totalAmount": "المجموع الكلي",
|
||||||
@@ -234,10 +234,12 @@
|
|||||||
"useLoyaltyPoints": "استخدام نقاط الولاء",
|
"useLoyaltyPoints": "استخدام نقاط الولاء",
|
||||||
"noLoyaltyItemsInCart": "لا توجد عناصر ولاء في سلة المشتريات",
|
"noLoyaltyItemsInCart": "لا توجد عناصر ولاء في سلة المشتريات",
|
||||||
"pleaseAddLoyaltyItems": "يرجى إضافة عناصر ولاء إلى سلة المشتريات لاستخدام نقاط الولاء",
|
"pleaseAddLoyaltyItems": "يرجى إضافة عناصر ولاء إلى سلة المشتريات لاستخدام نقاط الولاء",
|
||||||
"loyaltyDiscountApplied": "تم تطبيق خصم الولاء: {{itemName}} (خصم {{amount}})"
|
"loyaltyDiscountApplied": "تم تطبيق خصم الولاء: {{itemName}} (خصم {{amount}})",
|
||||||
|
"deliveryFee": "رسوم التوصيل"
|
||||||
},
|
},
|
||||||
"checkout": {
|
"checkout": {
|
||||||
"title": "الدفع",
|
"title": "الدفع",
|
||||||
|
"cash": "كاش",
|
||||||
"creditDebitCard": "بطاقة ائتمان/ائتمان",
|
"creditDebitCard": "بطاقة ائتمان/ائتمان",
|
||||||
"differentCard": "بطاقة اخرى",
|
"differentCard": "بطاقة اخرى",
|
||||||
"fascanoWallet": "محفظة فاسكانو",
|
"fascanoWallet": "محفظة فاسكانو",
|
||||||
|
|||||||
@@ -171,7 +171,7 @@
|
|||||||
"browseMenu": "Browse Menu",
|
"browseMenu": "Browse Menu",
|
||||||
"paymentSummary": "Payment Summary",
|
"paymentSummary": "Payment Summary",
|
||||||
"subtotal": "Subtotal",
|
"subtotal": "Subtotal",
|
||||||
"tax": "Tax (10%)",
|
"tax": "Tax",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"proceedToCheckout": "Proceed to Checkout",
|
"proceedToCheckout": "Proceed to Checkout",
|
||||||
"basketTotal": "Basket Total",
|
"basketTotal": "Basket Total",
|
||||||
@@ -244,10 +244,12 @@
|
|||||||
"useLoyaltyPoints": "Use Loyalty Points",
|
"useLoyaltyPoints": "Use Loyalty Points",
|
||||||
"noLoyaltyItemsInCart": "No loyalty items found in your cart",
|
"noLoyaltyItemsInCart": "No loyalty items found in your cart",
|
||||||
"pleaseAddLoyaltyItems": "Please add loyalty items to your cart to use loyalty points",
|
"pleaseAddLoyaltyItems": "Please add loyalty items to your cart to use loyalty points",
|
||||||
"loyaltyDiscountApplied": "Loyalty discount applied: {{itemName}} ({{amount}} off)"
|
"loyaltyDiscountApplied": "Loyalty discount applied: {{itemName}} ({{amount}} off)",
|
||||||
|
"deliveryFee": "Delivery Fee"
|
||||||
},
|
},
|
||||||
"checkout": {
|
"checkout": {
|
||||||
"title": "Checkout",
|
"title": "Checkout",
|
||||||
|
"cash": "Cash",
|
||||||
"creditDebitCard": "Credit/Debit Card",
|
"creditDebitCard": "Credit/Debit Card",
|
||||||
"differentCard": "Different Card",
|
"differentCard": "Different Card",
|
||||||
"fascanoWallet": "Fascano Wallet",
|
"fascanoWallet": "Fascano Wallet",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface ArabicPriceProps {
|
|||||||
strong?: boolean;
|
strong?: boolean;
|
||||||
type?: "secondary" | "success" | "warning" | "danger";
|
type?: "secondary" | "success" | "warning" | "danger";
|
||||||
className?: string;
|
className?: string;
|
||||||
|
hideCurrency?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ArabicPrice: React.FC<ArabicPriceProps> = ({
|
const ArabicPrice: React.FC<ArabicPriceProps> = ({
|
||||||
@@ -17,6 +18,7 @@ const ArabicPrice: React.FC<ArabicPriceProps> = ({
|
|||||||
strong = false,
|
strong = false,
|
||||||
type,
|
type,
|
||||||
className,
|
className,
|
||||||
|
hideCurrency = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isRTL } = useAppSelector((state) => state.locale);
|
const { isRTL } = useAppSelector((state) => state.locale);
|
||||||
@@ -32,12 +34,12 @@ const ArabicPrice: React.FC<ArabicPriceProps> = ({
|
|||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "baseline",
|
alignItems: "baseline",
|
||||||
gap: "0.1em",
|
gap: "0.2em",
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
...style,
|
...style,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isRTL ? (
|
{isRTL && !hideCurrency ? (
|
||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@@ -47,7 +49,6 @@ const ArabicPrice: React.FC<ArabicPriceProps> = ({
|
|||||||
>
|
>
|
||||||
{formattedPrice}
|
{formattedPrice}
|
||||||
</span>
|
</span>
|
||||||
<span style={{margin: "0 0.1em"}} />
|
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.9em",
|
fontSize: "0.9em",
|
||||||
@@ -60,7 +61,7 @@ const ArabicPrice: React.FC<ArabicPriceProps> = ({
|
|||||||
{t("common.omanCurrency")}
|
{t("common.omanCurrency")}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : !hideCurrency ? (
|
||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@@ -82,6 +83,8 @@ const ArabicPrice: React.FC<ArabicPriceProps> = ({
|
|||||||
{t("common.omanCurrency")}
|
{t("common.omanCurrency")}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
<>{formattedPrice}</>
|
||||||
)}
|
)}
|
||||||
</ProText>
|
</ProText>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import ArabicPrice from "components/ArabicPrice";
|
|||||||
import {
|
import {
|
||||||
selectCart,
|
selectCart,
|
||||||
selectCartTotal,
|
selectCartTotal,
|
||||||
selectCartTotalWithLoyaltyDiscount,
|
selectGrandTotal,
|
||||||
selectHighestPricedLoyaltyItem,
|
selectHighestPricedLoyaltyItem,
|
||||||
selectLoyaltyValidation,
|
selectLoyaltyValidation,
|
||||||
|
selectTaxAmount,
|
||||||
updateUseLoyaltyPoints,
|
updateUseLoyaltyPoints,
|
||||||
} from "features/order/orderSlice";
|
} from "features/order/orderSlice";
|
||||||
|
import { OrderType } from "pages/checkout/hooks/types";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useGetRestaurantDetailsQuery } from "redux/api/others";
|
import { useGetRestaurantDetailsQuery } from "redux/api/others";
|
||||||
import { useAppDispatch, useAppSelector } from "redux/hooks";
|
import { useAppDispatch, useAppSelector } from "redux/hooks";
|
||||||
@@ -18,19 +20,15 @@ import styles from "./OrderSummary.module.css";
|
|||||||
export default function OrderSummary() {
|
export default function OrderSummary() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { useLoyaltyPoints } = useAppSelector(selectCart);
|
const { useLoyaltyPoints } = useAppSelector(selectCart);
|
||||||
|
const { data: restaurant } = useGetRestaurantDetailsQuery("595");
|
||||||
|
const { orderType } = useAppSelector(selectCart);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const subtotal = useAppSelector(selectCartTotal);
|
const subtotal = useAppSelector(selectCartTotal);
|
||||||
const subtotalWithLoyaltyDiscount = useAppSelector(
|
|
||||||
selectCartTotalWithLoyaltyDiscount,
|
|
||||||
);
|
|
||||||
const loyaltyValidation = useAppSelector(selectLoyaltyValidation);
|
const loyaltyValidation = useAppSelector(selectLoyaltyValidation);
|
||||||
const highestLoyaltyItem = useAppSelector(selectHighestPricedLoyaltyItem);
|
const highestLoyaltyItem = useAppSelector(selectHighestPricedLoyaltyItem);
|
||||||
|
const taxAmount = useAppSelector(selectTaxAmount);
|
||||||
|
const grandTotal = useAppSelector(selectGrandTotal);
|
||||||
|
|
||||||
const tax = subtotalWithLoyaltyDiscount * 0.1; // 10% tax on discounted amount
|
|
||||||
const total = subtotalWithLoyaltyDiscount + tax;
|
|
||||||
const loyaltyDiscountAmount = subtotal - subtotalWithLoyaltyDiscount;
|
|
||||||
|
|
||||||
const { data: restaurant } = useGetRestaurantDetailsQuery("595");
|
|
||||||
const isHasLoyaltyGift =
|
const isHasLoyaltyGift =
|
||||||
(restaurant?.loyalty_stamps ?? 0) -
|
(restaurant?.loyalty_stamps ?? 0) -
|
||||||
(restaurant?.customer_loyalty_points ?? 0) <=
|
(restaurant?.customer_loyalty_points ?? 0) <=
|
||||||
@@ -46,20 +44,24 @@ export default function OrderSummary() {
|
|||||||
<ProText type="secondary">{t("cart.basketTotal")}</ProText>
|
<ProText type="secondary">{t("cart.basketTotal")}</ProText>
|
||||||
<ArabicPrice price={subtotal} />
|
<ArabicPrice price={subtotal} />
|
||||||
</div>
|
</div>
|
||||||
|
{orderType != OrderType.DineIn && <div className={styles.summaryRow}>
|
||||||
|
<ProText type="secondary">{t("cart.deliveryFee")}</ProText>
|
||||||
|
<ArabicPrice price={Number(restaurant?.delivery_fees || 0)} />
|
||||||
|
</div>}
|
||||||
<div className={styles.summaryRow}>
|
<div className={styles.summaryRow}>
|
||||||
<ProText type="secondary">{t("cart.discount")}</ProText>
|
<ProText type="secondary">{t("cart.discount")}</ProText>
|
||||||
<ArabicPrice price={loyaltyDiscountAmount} />
|
<ArabicPrice price={highestLoyaltyItem?.price || 0} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.summaryRow}>
|
<div className={styles.summaryRow}>
|
||||||
<ProText type="secondary">{t("cart.riderTip")}</ProText>
|
<ProText type="secondary">{t("cart.tax")}</ProText>
|
||||||
<ArabicPrice price={tax} />
|
<ArabicPrice price={taxAmount || 0} />
|
||||||
</div>
|
</div>
|
||||||
<Divider className={styles.summaryDivider} />
|
<Divider className={styles.summaryDivider} />
|
||||||
<div className={`${styles.summaryRow} ${styles.totalRow}`}>
|
<div className={`${styles.summaryRow} ${styles.totalRow}`}>
|
||||||
<ProText strong type="secondary">
|
<ProText strong type="secondary">
|
||||||
{t("cart.totalAmount")}
|
{t("cart.totalAmount")}
|
||||||
</ProText>
|
</ProText>
|
||||||
<ArabicPrice price={total} strong />
|
<ArabicPrice price={grandTotal} />
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
@@ -88,7 +90,7 @@ export default function OrderSummary() {
|
|||||||
<div style={{ marginTop: 8, color: "green", fontSize: "12px" }}>
|
<div style={{ marginTop: 8, color: "green", fontSize: "12px" }}>
|
||||||
{t("cart.loyaltyDiscountApplied", {
|
{t("cart.loyaltyDiscountApplied", {
|
||||||
itemName: highestLoyaltyItem.name,
|
itemName: highestLoyaltyItem.name,
|
||||||
amount: Math.round(loyaltyDiscountAmount).toFixed(2),
|
amount: Math.round(highestLoyaltyItem.price || 0).toFixed(2),
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { Group } from "antd/es/radio";
|
|||||||
import ArabicPrice from "components/ArabicPrice";
|
import ArabicPrice from "components/ArabicPrice";
|
||||||
import DifferentCardIcon from "components/Icons/paymentMethods/DifferentCardIcon";
|
import DifferentCardIcon from "components/Icons/paymentMethods/DifferentCardIcon";
|
||||||
import ProText from "components/ProText";
|
import ProText from "components/ProText";
|
||||||
import { selectCart, updatePaymentMethod } from "features/order/orderSlice";
|
import {
|
||||||
|
selectCart,
|
||||||
|
selectGrandTotal,
|
||||||
|
updatePaymentMethod,
|
||||||
|
} from "features/order/orderSlice";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAppDispatch, useAppSelector } from "redux/hooks";
|
import { useAppDispatch, useAppSelector } from "redux/hooks";
|
||||||
import { colors, ProGray1 } from "../../ThemeConstants";
|
import { colors, ProGray1 } from "../../ThemeConstants";
|
||||||
@@ -14,6 +18,7 @@ const PaymentMethods = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { paymentMethod, orderType } = useAppSelector(selectCart);
|
const { paymentMethod, orderType } = useAppSelector(selectCart);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const grandTotal = useAppSelector(selectGrandTotal);
|
||||||
|
|
||||||
const options: {
|
const options: {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -21,26 +26,35 @@ const PaymentMethods = () => {
|
|||||||
price?: string;
|
price?: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
hideCurrency?: boolean;
|
||||||
}[] = [
|
}[] = [
|
||||||
...(orderType !== "gift"
|
...(orderType !== "gift"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: t("checkout.creditDebitCard"),
|
label: t("checkout.cash"),
|
||||||
value: "creditDebitCard",
|
value: "cash",
|
||||||
price: t("checkout.expiresIn") + ":12/26",
|
price: grandTotal.toString(),
|
||||||
|
style: {
|
||||||
|
color: colors.primary,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
{
|
||||||
|
label: t("checkout.creditDebitCard"),
|
||||||
|
value: "creditDebitCard",
|
||||||
|
price: t("checkout.expiresIn") + ":12/26",
|
||||||
|
hideCurrency: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t("checkout.differentCard"),
|
label: t("checkout.differentCard"),
|
||||||
value: "differentCard",
|
value: "differentCard",
|
||||||
icon: (
|
icon: (
|
||||||
<div className={styles.differentCardIcon}>
|
<div className={styles.differentCardIcon}>
|
||||||
{" "}
|
|
||||||
<DifferentCardIcon />
|
<DifferentCardIcon />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
hideCurrency: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("checkout.fascanoWallet"),
|
label: t("checkout.fascanoWallet"),
|
||||||
@@ -108,6 +122,7 @@ const PaymentMethods = () => {
|
|||||||
{!option.icon ? (
|
{!option.icon ? (
|
||||||
<ArabicPrice
|
<ArabicPrice
|
||||||
price={option.price || 0}
|
price={option.price || 0}
|
||||||
|
hideCurrency={option.hideCurrency}
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
color: ProGray1,
|
color: ProGray1,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||||
|
import { OrderType } from "pages/checkout/hooks/types";
|
||||||
import { RootState } from "redux/store";
|
import { RootState } from "redux/store";
|
||||||
import { CartItem } from "utils/types/appTypes";
|
import { CartItem, RestaurantDetails, Tax } from "utils/types/appTypes";
|
||||||
|
|
||||||
interface LocationData {
|
interface LocationData {
|
||||||
lat: number;
|
lat: number;
|
||||||
@@ -35,8 +36,9 @@ export interface GiftDetailsType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CartState {
|
interface CartState {
|
||||||
|
restaurant: Partial<RestaurantDetails>;
|
||||||
items: CartItem[];
|
items: CartItem[];
|
||||||
tmp: any;
|
tmp: unknown;
|
||||||
specialRequest: string;
|
specialRequest: string;
|
||||||
location: LocationData | null;
|
location: LocationData | null;
|
||||||
roomDetails: RoomDetailsType | null;
|
roomDetails: RoomDetailsType | null;
|
||||||
@@ -76,6 +78,7 @@ export const CART_STORAGE_KEYS = {
|
|||||||
ORDER_TYPE: "fascano_order_type",
|
ORDER_TYPE: "fascano_order_type",
|
||||||
USE_LOYALTY_POINTS: "fascano_use_loyalty_points",
|
USE_LOYALTY_POINTS: "fascano_use_loyalty_points",
|
||||||
LOYALTY_VALIDATION_ERROR: "fascano_loyalty_validation_error",
|
LOYALTY_VALIDATION_ERROR: "fascano_loyalty_validation_error",
|
||||||
|
RESTAURANT: "fascano_restaurant",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Utility functions for localStorage
|
// Utility functions for localStorage
|
||||||
@@ -133,6 +136,7 @@ const initialState: CartState = {
|
|||||||
CART_STORAGE_KEYS.LOYALTY_VALIDATION_ERROR,
|
CART_STORAGE_KEYS.LOYALTY_VALIDATION_ERROR,
|
||||||
null,
|
null,
|
||||||
),
|
),
|
||||||
|
restaurant: getFromLocalStorage(CART_STORAGE_KEYS.RESTAURANT, { taxes: [] }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const orderSlice = createSlice({
|
const orderSlice = createSlice({
|
||||||
@@ -142,6 +146,15 @@ const orderSlice = createSlice({
|
|||||||
reset() {
|
reset() {
|
||||||
return initialState;
|
return initialState;
|
||||||
},
|
},
|
||||||
|
updateRestaurant(state, action: PayloadAction<Partial<RestaurantDetails>>) {
|
||||||
|
state.restaurant = action.payload;
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem(
|
||||||
|
CART_STORAGE_KEYS.RESTAURANT,
|
||||||
|
JSON.stringify(state.restaurant),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
addItem(
|
addItem(
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{
|
action: PayloadAction<{
|
||||||
@@ -520,8 +533,21 @@ export const {
|
|||||||
validateLoyaltyPoints,
|
validateLoyaltyPoints,
|
||||||
clearLoyaltyValidationError,
|
clearLoyaltyValidationError,
|
||||||
reset,
|
reset,
|
||||||
|
updateRestaurant,
|
||||||
} = orderSlice.actions;
|
} = orderSlice.actions;
|
||||||
|
|
||||||
|
// Tax calculation helper functions
|
||||||
|
const calculateTaxAmount = (amount: number, tax: Tax): number => {
|
||||||
|
const percentage = parseFloat(tax.percentage);
|
||||||
|
return (percentage * amount) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateTotalTax = (subtotal: number, taxes: Tax[]): number => {
|
||||||
|
return taxes
|
||||||
|
.filter((tax) => tax.is_active === 1)
|
||||||
|
.reduce((total, tax) => total + calculateTaxAmount(subtotal, tax), 0);
|
||||||
|
};
|
||||||
|
|
||||||
// Selectors
|
// Selectors
|
||||||
export const selectCart = (state: RootState) => state.order;
|
export const selectCart = (state: RootState) => state.order;
|
||||||
export const selectCartItems = (state: RootState) => state.order.items;
|
export const selectCartItems = (state: RootState) => state.order.items;
|
||||||
@@ -549,18 +575,6 @@ export const selectHighestPricedLoyaltyItem = (state: RootState) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const selectCartTotalWithLoyaltyDiscount = (state: RootState) => {
|
|
||||||
const total = selectCartTotal(state);
|
|
||||||
const useLoyaltyPoints = state.order.useLoyaltyPoints;
|
|
||||||
const highestLoyaltyItem = selectHighestPricedLoyaltyItem(state);
|
|
||||||
|
|
||||||
if (useLoyaltyPoints && highestLoyaltyItem) {
|
|
||||||
return total - highestLoyaltyItem.price;
|
|
||||||
}
|
|
||||||
|
|
||||||
return total;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const selectLoyaltyValidation = (state: RootState) => {
|
export const selectLoyaltyValidation = (state: RootState) => {
|
||||||
const useLoyaltyPoints = state.order.useLoyaltyPoints;
|
const useLoyaltyPoints = state.order.useLoyaltyPoints;
|
||||||
const loyaltyItems = selectLoyaltyItems(state);
|
const loyaltyItems = selectLoyaltyItems(state);
|
||||||
@@ -576,4 +590,25 @@ export const selectLoyaltyValidation = (state: RootState) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tax selectors
|
||||||
|
export const selectTaxes = (state: RootState) => state.order.restaurant.taxes;
|
||||||
|
|
||||||
|
export const selectTaxAmount = (state: RootState) => {
|
||||||
|
const subtotal = selectCartTotal(state);
|
||||||
|
const taxes = selectTaxes(state);
|
||||||
|
return calculateTotalTax(subtotal, taxes || []);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectGrandTotal = (state: RootState) => {
|
||||||
|
const loyaltyDiscount = selectHighestPricedLoyaltyItem(state)?.price || 0;
|
||||||
|
const taxAmount = selectTaxAmount(state);
|
||||||
|
const subtotal = selectCartTotal(state);
|
||||||
|
const deliveryFee =
|
||||||
|
state.order.orderType != OrderType.DineIn
|
||||||
|
? Number(state.order.restaurant?.delivery_fees) || 0
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return subtotal + taxAmount - loyaltyDiscount + deliveryFee;
|
||||||
|
};
|
||||||
|
|
||||||
export default orderSlice.reducer;
|
export default orderSlice.reducer;
|
||||||
|
|||||||
18
src/hooks/useRestaurant.ts
Normal file
18
src/hooks/useRestaurant.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { updateRestaurant } from "features/order/orderSlice";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAppDispatch } from "redux/hooks";
|
||||||
|
import { RestaurantDetails } from "utils/types/appTypes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to automatically load restaurant into Redux store
|
||||||
|
* when restaurant data is available
|
||||||
|
*/
|
||||||
|
export const useRestaurant = (restaurant: RestaurantDetails | undefined) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (restaurant) {
|
||||||
|
dispatch(updateRestaurant(restaurant));
|
||||||
|
}
|
||||||
|
}, [restaurant, dispatch]);
|
||||||
|
};
|
||||||
@@ -1,228 +1,248 @@
|
|||||||
import { Variant } from "pages/orders/types"
|
import { Variant } from "pages/orders/types";
|
||||||
import { Extra3 } from "utils/types/appTypes"
|
import { Extra3 } from "utils/types/appTypes";
|
||||||
|
|
||||||
export interface OrderDetails {
|
export interface OrderDetails {
|
||||||
orderItems: OrderItem[]
|
orderItems: OrderItem[];
|
||||||
order: Order
|
order: Order;
|
||||||
status: Status[]
|
status: Status[];
|
||||||
laststatus: Status2[]
|
laststatus: Status2[];
|
||||||
restaurant: string
|
restaurant: string;
|
||||||
restaurantAR: string
|
restaurantAR: string;
|
||||||
restaurantID: number
|
restaurantID: number;
|
||||||
global_currency: string
|
global_currency: string;
|
||||||
local_currency: string
|
local_currency: string;
|
||||||
address: string
|
address: string;
|
||||||
phone: string
|
phone: string;
|
||||||
restaurant_iimage: string
|
restaurant_iimage: string;
|
||||||
itemsImagePrefixOld: string
|
itemsImagePrefixOld: string;
|
||||||
itemsImagePrefix: string
|
itemsImagePrefix: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderItem {
|
export interface OrderItem {
|
||||||
id: number
|
id: number;
|
||||||
is_loyalty_used: number
|
is_loyalty_used: number;
|
||||||
no_of_stamps_give: number
|
no_of_stamps_give: number;
|
||||||
isHasLoyalty: boolean
|
isHasLoyalty: boolean;
|
||||||
is_vat_disabled: number
|
is_vat_disabled: number;
|
||||||
name: string
|
name: string;
|
||||||
price: number
|
price: number;
|
||||||
qty: number
|
qty: number;
|
||||||
variant_price: string
|
variant_price: string;
|
||||||
image: string
|
image: string;
|
||||||
imageName: string
|
imageName: string;
|
||||||
variantName: string
|
variantName: string;
|
||||||
variantLocalName?: string
|
variantLocalName?: string;
|
||||||
extras: any[]
|
extras: any[];
|
||||||
itemline: string
|
itemline: string;
|
||||||
itemlineAR: string
|
itemlineAR: string;
|
||||||
itemlineAREN: string
|
itemlineAREN: string;
|
||||||
extrasgroups: any[]
|
extrasgroups: any[];
|
||||||
itemComment: string
|
itemComment: string;
|
||||||
variant?: Variant
|
variant?: Variant;
|
||||||
itemExtras: any[]
|
itemExtras: any[];
|
||||||
AvaiilableVariantExtras: Extra3[]
|
AvaiilableVariantExtras: Extra3[];
|
||||||
isPrinted: number
|
isPrinted: number;
|
||||||
category_id: number
|
category_id: number;
|
||||||
pos_order_id: string
|
pos_order_id: string;
|
||||||
updated_at: string
|
updated_at: string;
|
||||||
created_at: string
|
created_at: string;
|
||||||
old_qty: number
|
old_qty: number;
|
||||||
new_qty: number
|
new_qty: number;
|
||||||
last_printed_qty: number
|
last_printed_qty: number;
|
||||||
deleted_qty: number
|
deleted_qty: number;
|
||||||
discount_value: any
|
discount_value: any;
|
||||||
discount_type_id: any
|
discount_type_id: any;
|
||||||
original_price: number
|
original_price: number;
|
||||||
pricing_method: string
|
pricing_method: string;
|
||||||
is_already_paid: number
|
is_already_paid: number;
|
||||||
hash_item: string
|
hash_item: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Order {
|
export interface Order {
|
||||||
id: number
|
id: number;
|
||||||
pos_order_id: string
|
pos_order_id: string;
|
||||||
created_at: string
|
created_at: string;
|
||||||
updated_at: string
|
updated_at: string;
|
||||||
table: string
|
table: string;
|
||||||
phone: string
|
phone: string;
|
||||||
user_name: string
|
user_name: string;
|
||||||
restaurant_name: string
|
restaurant_name: string;
|
||||||
lat: string
|
lat: string;
|
||||||
lng: string
|
lng: string;
|
||||||
restaurant_icon: string
|
restaurant_icon: string;
|
||||||
location: any
|
location: any;
|
||||||
status: string
|
status: string;
|
||||||
status_id: number
|
status_id: number;
|
||||||
delivery_method: number
|
delivery_method: number;
|
||||||
orderItems: OrderItem2[]
|
orderItems: OrderItem2[];
|
||||||
discount: string
|
discount: string;
|
||||||
vat: number
|
vat: number;
|
||||||
total_price: number
|
total_price: number;
|
||||||
comment: string
|
comment: string;
|
||||||
pickup_comments: any
|
pickup_comments: any;
|
||||||
car_plate: string
|
car_plate: string;
|
||||||
pickup_time: any
|
pickup_time: any;
|
||||||
pickup_date: any
|
pickup_date: any;
|
||||||
delivery_pickup_interval: any
|
delivery_pickup_interval: any;
|
||||||
office_no: any
|
office_no: any;
|
||||||
room_no: any
|
room_no: any;
|
||||||
time_to_prepare: any
|
time_to_prepare: any;
|
||||||
last_status_id: number
|
last_status_id: number;
|
||||||
currency: string
|
currency: string;
|
||||||
gift_id: any
|
gift_id: any;
|
||||||
is_loyalty_used: number
|
is_loyalty_used: number;
|
||||||
payment_status: string
|
payment_status: string;
|
||||||
created_by: string
|
created_by: string;
|
||||||
split_order_group_id: any
|
split_order_group_id: any;
|
||||||
split_sequence: any
|
split_sequence: any;
|
||||||
is_split_order: number
|
is_split_order: number;
|
||||||
split_at: any
|
split_at: any;
|
||||||
split_by_user_id: any
|
split_by_user_id: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderItem2 {
|
export interface OrderItem2 {
|
||||||
id: number
|
id: number;
|
||||||
is_loyalty_used: number
|
is_loyalty_used: number;
|
||||||
no_of_stamps_give: number
|
no_of_stamps_give: number;
|
||||||
isHasLoyalty: boolean
|
isHasLoyalty: boolean;
|
||||||
is_vat_disabled: number
|
is_vat_disabled: number;
|
||||||
name: string
|
name: string;
|
||||||
price: number
|
price: number;
|
||||||
qty: number
|
qty: number;
|
||||||
variant_price: string
|
variant_price: string;
|
||||||
image: string
|
image: string;
|
||||||
imageName: string
|
imageName: string;
|
||||||
variantName: string
|
variantName: string;
|
||||||
variantLocalName?: string
|
variantLocalName?: string;
|
||||||
extras: any[]
|
extras: any[];
|
||||||
itemline: string
|
itemline: string;
|
||||||
itemlineAR: string
|
itemlineAR: string;
|
||||||
itemlineAREN: string
|
itemlineAREN: string;
|
||||||
extrasgroups: any[]
|
extrasgroups: any[];
|
||||||
itemComment: string
|
itemComment: string;
|
||||||
variant?: Variant2
|
variant?: Variant2;
|
||||||
itemExtras: any[]
|
itemExtras: any[];
|
||||||
AvaiilableVariantExtras: AvaiilableVariantExtra2[]
|
AvaiilableVariantExtras: AvaiilableVariantExtra2[];
|
||||||
isPrinted: number
|
isPrinted: number;
|
||||||
category_id: number
|
category_id: number;
|
||||||
pos_order_id: string
|
pos_order_id: string;
|
||||||
updated_at: string
|
updated_at: string;
|
||||||
created_at: string
|
created_at: string;
|
||||||
old_qty: number
|
old_qty: number;
|
||||||
new_qty: number
|
new_qty: number;
|
||||||
last_printed_qty: number
|
last_printed_qty: number;
|
||||||
deleted_qty: number
|
deleted_qty: number;
|
||||||
discount_value: any
|
discount_value: any;
|
||||||
discount_type_id: any
|
discount_type_id: any;
|
||||||
original_price: number
|
original_price: number;
|
||||||
pricing_method: string
|
pricing_method: string;
|
||||||
is_already_paid: number
|
is_already_paid: number;
|
||||||
hash_item: string
|
hash_item: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Status {
|
export interface Status {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
alias: string
|
alias: string;
|
||||||
pivot: Pivot5
|
pivot: Pivot5;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Pivot5 {
|
export interface Pivot5 {
|
||||||
order_id: number
|
order_id: number;
|
||||||
status_id: number
|
status_id: number;
|
||||||
user_id: number
|
user_id: number;
|
||||||
created_at: string
|
created_at: string;
|
||||||
comment: string
|
comment: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Status2 {
|
export interface Status2 {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
alias: string
|
alias: string;
|
||||||
pivot: Pivot6
|
pivot: Pivot6;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Pivot6 {
|
export interface Pivot6 {
|
||||||
order_id: number
|
order_id: number;
|
||||||
status_id: number
|
status_id: number;
|
||||||
user_id: number
|
user_id: number;
|
||||||
created_at: string
|
created_at: string;
|
||||||
comment: string
|
comment: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Variant2 {
|
export interface Variant2 {
|
||||||
id: number
|
id: number;
|
||||||
price: number
|
price: number;
|
||||||
options: string
|
options: string;
|
||||||
local_name: any
|
local_name: any;
|
||||||
optionsArray: OptionsArray2[]
|
optionsArray: OptionsArray2[];
|
||||||
OptionsList: string
|
OptionsList: string;
|
||||||
extras: Extra2[]
|
extras: Extra2[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OptionsArray2 {
|
export interface OptionsArray2 {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Extra2 {
|
export interface Extra2 {
|
||||||
id: number
|
id: number;
|
||||||
item_id: number
|
item_id: number;
|
||||||
price: number
|
price: number;
|
||||||
name: string
|
name: string;
|
||||||
nameAR: string
|
nameAR: string;
|
||||||
created_at: string
|
created_at: string;
|
||||||
updated_at: string
|
updated_at: string;
|
||||||
deleted_at: any
|
deleted_at: any;
|
||||||
extra_for_all_variants: number
|
extra_for_all_variants: number;
|
||||||
is_custome: number
|
is_custome: number;
|
||||||
is_available: number
|
is_available: number;
|
||||||
modifier_id: any
|
modifier_id: any;
|
||||||
pivot: Pivot3
|
pivot: Pivot3;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Pivot3 {
|
export interface Pivot3 {
|
||||||
variant_id: number
|
variant_id: number;
|
||||||
extra_id: number
|
extra_id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AvaiilableVariantExtra2 {
|
export interface AvaiilableVariantExtra2 {
|
||||||
id: number
|
id: number;
|
||||||
item_id: number
|
item_id: number;
|
||||||
price: number
|
price: number;
|
||||||
name: string
|
name: string;
|
||||||
nameAR: string
|
nameAR: string;
|
||||||
created_at: string
|
created_at: string;
|
||||||
updated_at: string
|
updated_at: string;
|
||||||
deleted_at: any
|
deleted_at: any;
|
||||||
extra_for_all_variants: number
|
extra_for_all_variants: number;
|
||||||
is_custome: number
|
is_custome: number;
|
||||||
is_available: number
|
is_available: number;
|
||||||
modifier_id: any
|
modifier_id: any;
|
||||||
pivot: Pivot4
|
pivot: Pivot4;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Pivot4 {
|
export interface Pivot4 {
|
||||||
variant_id: number
|
variant_id: number;
|
||||||
extra_id: number
|
extra_id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum DeliveryMethod {
|
||||||
|
DineIn = 3,
|
||||||
|
Delivery = 1,
|
||||||
|
Pickup = 2,
|
||||||
|
Gift = 10,
|
||||||
|
ScheduledOrder = 9,
|
||||||
|
ToRoom = 5,
|
||||||
|
ToOffice = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum OrderType {
|
||||||
|
DineIn = "dine-in",
|
||||||
|
Delivery = "delivery",
|
||||||
|
Pickup = "pickup",
|
||||||
|
Gift = "gift",
|
||||||
|
ScheduledOrder = "scheduled_order",
|
||||||
|
ToRoom = "room",
|
||||||
|
ToOffice = "office",
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { message } from "antd";
|
|||||||
import {
|
import {
|
||||||
clearCart,
|
clearCart,
|
||||||
selectCart,
|
selectCart,
|
||||||
selectCartTotalWithLoyaltyDiscount,
|
selectGrandTotal,
|
||||||
selectHighestPricedLoyaltyItem,
|
selectHighestPricedLoyaltyItem
|
||||||
} from "features/order/orderSlice";
|
} from "features/order/orderSlice";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -37,7 +37,7 @@ export default function useOrder() {
|
|||||||
const highestLoyaltyItem = useAppSelector(selectHighestPricedLoyaltyItem);
|
const highestLoyaltyItem = useAppSelector(selectHighestPricedLoyaltyItem);
|
||||||
const { useLoyaltyPoints } = useAppSelector(selectCart);
|
const { useLoyaltyPoints } = useAppSelector(selectCart);
|
||||||
|
|
||||||
const orderPrice = useAppSelector(selectCartTotalWithLoyaltyDiscount);
|
const orderPrice = useAppSelector(selectGrandTotal);
|
||||||
|
|
||||||
const [createOrder] = useCreateOrderMutation();
|
const [createOrder] = useCreateOrderMutation();
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import ProText from "components/ProText";
|
|||||||
import ProTitle from "components/ProTitle";
|
import ProTitle from "components/ProTitle";
|
||||||
import { useScrollHandler } from "contexts/ScrollHandlerContext";
|
import { useScrollHandler } from "contexts/ScrollHandlerContext";
|
||||||
import useBreakPoint from "hooks/useBreakPoint";
|
import useBreakPoint from "hooks/useBreakPoint";
|
||||||
|
import { useRestaurant } from "hooks/useRestaurant";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
@@ -44,6 +45,9 @@ function MenuPage() {
|
|||||||
const { isMobile, isTablet, isDesktop } = useBreakPoint();
|
const { isMobile, isTablet, isDesktop } = useBreakPoint();
|
||||||
const isLoading = isLoadingRestaurant || isLoadingMenu;
|
const isLoading = isLoadingRestaurant || isLoadingMenu;
|
||||||
|
|
||||||
|
// Automatically load restaurant taxes when restaurant data is available
|
||||||
|
useRestaurant(restaurant);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LocalStorageHandler restaurantID={restaurant?.restautantId || ""} />
|
<LocalStorageHandler restaurantID={restaurant?.restautantId || ""} />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import RestaurantServices from "./RestaurantServices";
|
|||||||
// Import the Client Component for localStorage handling
|
// Import the Client Component for localStorage handling
|
||||||
import Ads1 from "components/Ads/Ads1";
|
import Ads1 from "components/Ads/Ads1";
|
||||||
import { Loader } from "components/Loader/Loader";
|
import { Loader } from "components/Loader/Loader";
|
||||||
|
import { useRestaurant } from "hooks/useRestaurant";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useGetRestaurantDetailsQuery } from "redux/api/others";
|
import { useGetRestaurantDetailsQuery } from "redux/api/others";
|
||||||
import LocalStorageHandler from "../menu/components/LocalStorageHandler";
|
import LocalStorageHandler from "../menu/components/LocalStorageHandler";
|
||||||
@@ -19,12 +20,12 @@ import LocalStorageHandler from "../menu/components/LocalStorageHandler";
|
|||||||
export default function RestaurantPage() {
|
export default function RestaurantPage() {
|
||||||
const param = useParams();
|
const param = useParams();
|
||||||
const { isRTL } = useAppSelector((state) => state.locale);
|
const { isRTL } = useAppSelector((state) => state.locale);
|
||||||
const { data: restaurant, isLoading } = useGetRestaurantDetailsQuery(
|
const { data: restaurant, isLoading } = useGetRestaurantDetailsQuery("595", {
|
||||||
"595",
|
|
||||||
{
|
|
||||||
skip: !param.id,
|
skip: !param.id,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
// Automatically load restaurant taxes when restaurant data is available
|
||||||
|
useRestaurant(restaurant);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Loader />;
|
return <Loader />;
|
||||||
|
|||||||
@@ -2,15 +2,20 @@ import ActionsButtons from "components/ActionsButtons/ActionsButtons";
|
|||||||
import { selectCart, setTmp } from "features/order/orderSlice";
|
import { selectCart, setTmp } from "features/order/orderSlice";
|
||||||
import { useAppDispatch, useAppSelector } from "redux/hooks";
|
import { useAppDispatch, useAppSelector } from "redux/hooks";
|
||||||
|
|
||||||
|
interface SplitBillTmp {
|
||||||
|
totalPeople?: number;
|
||||||
|
payFor?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PayForActions() {
|
export default function PayForActions() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { tmp } = useAppSelector(selectCart);
|
const { tmp } = useAppSelector(selectCart);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionsButtons
|
<ActionsButtons
|
||||||
quantity={tmp?.payFor || 1}
|
quantity={(tmp as SplitBillTmp)?.payFor || 1}
|
||||||
setQuantity={(value) => dispatch(setTmp({ ...tmp, payFor: value }))}
|
setQuantity={(value) => dispatch(setTmp({ ...(tmp as SplitBillTmp), payFor: value }))}
|
||||||
max={tmp?.totalPeople || 10}
|
max={(tmp as SplitBillTmp)?.totalPeople || 10}
|
||||||
min={1}
|
min={1}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
import { Card, Divider, Space } from "antd";
|
import { Card, Divider, Space } from "antd";
|
||||||
import ArabicPrice from "components/ArabicPrice";
|
import ArabicPrice from "components/ArabicPrice";
|
||||||
import ProText from "components/ProText";
|
import ProText from "components/ProText";
|
||||||
import { selectCart, selectCartTotal } from "features/order/orderSlice";
|
import {
|
||||||
|
selectCart,
|
||||||
|
selectGrandTotal
|
||||||
|
} from "features/order/orderSlice";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAppSelector } from "redux/hooks";
|
import { useAppSelector } from "redux/hooks";
|
||||||
import { ProGray1 } from "ThemeConstants";
|
import { ProGray1 } from "ThemeConstants";
|
||||||
import styles from "../SplitBillPage.module.css";
|
import styles from "../SplitBillPage.module.css";
|
||||||
|
|
||||||
|
interface SplitBillTmp {
|
||||||
|
totalPeople?: number;
|
||||||
|
payFor?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PaymentSummary() {
|
export default function PaymentSummary() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { tmp } = useAppSelector(selectCart);
|
const { tmp } = useAppSelector(selectCart);
|
||||||
const getTotal = useAppSelector(selectCartTotal);
|
const total = useAppSelector(selectGrandTotal);
|
||||||
|
|
||||||
const subtotal = getTotal;
|
const costPerPerson = total / ((tmp as SplitBillTmp)?.totalPeople || 1);
|
||||||
const tax = subtotal * 0.1; // 10% tax
|
const remainingAmount = total - ((tmp as SplitBillTmp)?.payFor || 1) * costPerPerson;
|
||||||
const total = subtotal + tax;
|
|
||||||
|
|
||||||
const costPerPerson = total / (tmp?.totalPeople || 1);
|
|
||||||
const remainingAmount = total - (tmp?.payFor || 1) * costPerPerson;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`${styles.orderSummary}`}>
|
<Card className={`${styles.orderSummary}`}>
|
||||||
|
|||||||
Reference in New Issue
Block a user