React
: 建立前端架構React-router
: 建立Single Page ApplicationRedux
: 管理全域狀態Stylus
: 規劃網站切版以及響應式網頁(RWD)設計Express.js
: 建立後端架構 (Source code: https://github.com/JohnnyHsu041/clothie-clothes-shop-backend)MongoDB
: 儲存資料庫資料TypeScript
: 加入型別,預防預期外錯誤firebase
: website hostingHeroku
: server hosting
圖片路經預先儲存至data-src屬性
// src/components/product/ProductItem.tsx
<img
src="/images/placeholder.jpeg"
data-src={
process.env.REACT_APP_BACKEND + props.image
}
alt="product_image"
className="product-image"
/>
viewport瀏覽至產品時,將data-src圖片路徑替換至src:
// src/hooks/useImageLazyLoading.ts
const useImageLazyLoading = () => {
useEffect(() => {
const observer = new IntersectionObserver((entries, owner) => {
for (let entry of entries) {
if (entry.isIntersecting) {
// replace placeholder to product image
const img = entry.target as HTMLImageElement;
img.src = img.dataset.src as string;
// remove the listener
owner.unobserve(img);
}
}
});
const productImages = document.querySelectorAll(".product-image");
productImages.forEach((productImage) => observer.observe(productImage));
}, []);
};
// src/hooks/useFormValidity.ts
/* ... */
const formInfoHandler = (state: formInfo, action: formInfoAction) => {
switch (action.type) {
// check if the inputs are avaliable for submission
case "INPUT_CHANGE":
let formIsValid = true;
for (let inputId in state.inputInfoObject) {
if (!state.inputInfoObject[inputId]) continue;
if (inputId === action.id) {
formIsValid = formIsValid && action.isValid;
} else {
formIsValid =
formIsValid && state.inputInfoObject[inputId]!.isValid;
}
}
return {
...state,
inputInfoObject: {
...state.inputInfoObject,
[action.id]: {
value: action.value,
isValid: action.isValid,
},
},
formIsValid,
};
// save user input when switching to signin/ signup form
case "SET_FORM":
return {
inputInfoObject: action.inputInfoObject,
formIsValid: action.formIsValid,
};
default:
return state;
}
};
/* ... */
const useFormValidity: FormValidity = (initObj, initFormValidity) => {
const [formInfo, dispatch] = useReducer(formInfoHandler, {
inputInfoObject: initObj,
formIsValid: initFormValidity,
});
const changeHandler = useCallback(
(id: string, value: string, isValid: boolean) => {
dispatch({ type: "INPUT_CHANGE", id, value, isValid });
},
[]
);
const setForm = useCallback(
(inputObj: InputInfo, formValidity: boolean) => {
dispatch({
type: "SET_FORM",
inputInfoObject: inputObj,
formIsValid: formValidity,
});
},
[]
);
const { inputInfoObject, formIsValid } = formInfo;
return [inputInfoObject, formIsValid, changeHandler, setForm];
};
使用custom hook確認登入狀態,以執行自動登入/登出:
// src/hooks/useAuthCheck.ts
let logoutTimer: NodeJS.Timeout;
const useAuthCheck = () => {
const token = useSelector((state: RootState) => state.auth.token);
const tokenExpirationDate = useSelector(
(state: RootState) => state.auth.tokenExpirationDate
);
const dispatch = useDispatch();
// checking for auto login
useEffect(() => {
const storedUserData = JSON.parse(localStorage.getItem("userData")!);
if (
storedUserData &&
storedUserData.token &&
new Date(storedUserData.expiration) > new Date()
) {
dispatch(
AuthActions.login({
userId: storedUserData.userId,
token: storedUserData.token,
expiration: new Date(storedUserData.expiration),
})
);
} else {
dispatch(AuthActions.logout());
}
}, [dispatch]);
// set logout timer
useEffect(() => {
if (token && tokenExpirationDate) {
const remainingTime =
new Date(tokenExpirationDate).getTime() - new Date().getTime();
logoutTimer = setTimeout(
dispatch.bind(null, AuthActions.logout()),
remainingTime
);
} else {
clearTimeout(logoutTimer);
}
}, [token, tokenExpirationDate, dispatch]);
};
// src/redux/cart-slice.ts
export const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
/* ... */
changeAmount(state, action) {
const storedData = JSON.parse(
localStorage.getItem("clothie-cart")!
);
const storedProduct = storedData.products.find(
(product: Product) => product.id === action.payload.id
);
const changedAmount =
action.payload.amount - storedProduct.size[action.payload.size];
state.amountOfCartProducts += changedAmount;
storedProduct.size[action.payload.size] = action.payload.amount;
storedProduct.amount += changedAmount;
storedProduct.total = storedProduct.price * storedProduct.amount;
localStorage.setItem(
"clothie-cart",
JSON.stringify({
products: [...storedData.products],
amountOfProducts: state.amountOfCartProducts,
totalAmount:
storedData.totalAmount +
storedProduct.price * changedAmount,
})
);
},
/* ... */
},
});
使用custom hook儲存步驟狀態:
// src/hooks/useMultiSteps.ts
const useMultiSteps: MultiStepsFunc = (initStep, totalSteps) => {
const [currentStep, setCurrentStep] = useState(initStep);
const [isFirstStep, setIsFirstStep] = useState(true);
const [isLastStep, setIsLastStep] = useState(false);
const nextStep = useCallback(() => {
if (currentStep >= totalSteps) return;
setCurrentStep((prev) => prev + 1);
}, [currentStep, totalSteps]);
const prevStep = useCallback(() => {
if (currentStep <= initStep) return;
setCurrentStep((prev) => prev - 1);
}, [currentStep, initStep]);
useEffect(() => {
if (currentStep === initStep) {
setIsFirstStep(true);
setIsLastStep(false);
} else if (currentStep === totalSteps) {
setIsLastStep(true);
setIsFirstStep(false);
} else {
setIsFirstStep(false);
setIsLastStep(false);
}
}, [currentStep, initStep, totalSteps]);
return [currentStep, isFirstStep, isLastStep, nextStep, prevStep];
};
使用與會員登入相同的方法來保存訂單資料的狀態(請見功能:會員註冊/登入):
// src/hooks/useFormValidity.ts
npm install
npm start
Runs the app in the development mode.
Open http://localhost:3000 to view it in the browser.
The page will reload if you make edits.
You will also see any lint errors in the console.