From 83f4a13bc95edbe52994f53879a6f1064240d19a Mon Sep 17 00:00:00 2001 From: Yoichiro Tanaka Date: Wed, 23 Apr 2025 13:39:35 +0900 Subject: [PATCH 1/7] Introduce UserPurchase information. --- firestore.rules | 9 +++ src/actions/actions.ts | 35 ++++++++++- src/actions/catalog.action.ts | 1 + src/actions/workbench.action.ts | 48 ++++----------- src/components/catalog/Catalog.container.ts | 3 +- .../configure/Configure.container.ts | 7 +-- .../documents/Documents.container.ts | 3 +- .../workbench/Workbench.container.ts | 3 +- .../workbench/breadboard/Breadboard.tsx | 3 + .../workbench/breadboard/UserPurchaseHook.ts | 20 +++++++ .../workbench/header/Header.container.ts | 1 + src/components/workbench/header/Header.tsx | 19 +++--- src/services/provider/Firebase.ts | 60 +++++++++++++++++++ src/services/storage/Storage.ts | 5 ++ src/store/reducers.ts | 5 ++ src/store/state.ts | 9 +++ 16 files changed, 180 insertions(+), 51 deletions(-) create mode 100644 src/components/workbench/breadboard/UserPurchaseHook.ts diff --git a/firestore.rules b/firestore.rules index ebbd059f..629ca327 100644 --- a/firestore.rules +++ b/firestore.rules @@ -178,6 +178,15 @@ service cloud.firestore { && (request.auth.uid == userId); } + match /users/v1/purchases/{userId} { + allow create: if isAuthenticated() + && (request.auth.uid == userId); + allow update: if false; + allow delete: if false; + allow read: if isAuthenticated() + && (request.auth.uid == userId); + } + function isAuthenticated() { return request.auth.uid != null; } diff --git a/src/actions/actions.ts b/src/actions/actions.ts index 751a3dc0..0424b7a9 100644 --- a/src/actions/actions.ts +++ b/src/actions/actions.ts @@ -7,6 +7,7 @@ import { IKeySwitchOperation, ISetupPhase, IUserInformation, + IUserPurchase, RootState, SetupPhase, } from '../store/state'; @@ -220,6 +221,7 @@ export const APP_UPDATE_SIGNED_IN = `${APP_ACTIONS}/SignedIn`; export const APP_TESTED_MATRIX_CLEAR = `${APP_ACTIONS}/TestedMatrixClear`; export const APP_TEST_MATRIX_UPDATE = `${APP_ACTIONS}/TestMatrixUpdate`; export const APP_UPDATE_USER_INFORMATION = `${APP_ACTIONS}/UpdateUserInformation`; +export const APP_UPDATE_USER_PURCHASE = `${APP_ACTIONS}/UpdateUserPurchase`; export const AppActions = { updateSetupPhase: (setupPhase: ISetupPhase) => { return { @@ -367,6 +369,12 @@ export const AppActions = { value: userInformation, }; }, + updateUserPurchase: (purchase: IUserPurchase | undefined) => { + return { + type: APP_UPDATE_USER_PURCHASE, + value: purchase, + }; + }, }; type ActionTypes = ReturnType< @@ -391,6 +399,7 @@ export const AppActionsThunk = { const { auth } = getState(); dispatch(AppActions.updateSignedIn(false)); dispatch(AppActions.updateUserInformation(undefined)); + dispatch(AppActions.updateUserPurchase(undefined)); await auth.instance!.signOut(); dispatch(await hidActionsThunk.closeOpenedKeyboard()); dispatch(AppActions.updateSetupPhase(SetupPhase.keyboardNotSelected)); @@ -451,7 +460,7 @@ export const AppActionsThunk = { dispatch(NotificationActions.addError(result.error!, result.cause)); } }, - updateUserInformation: (): ThunkPromiseAction => { + fetchUserInformation: (): ThunkPromiseAction => { return async ( dispatch: ThunkDispatch, getState: () => RootState @@ -475,6 +484,30 @@ export const AppActionsThunk = { } }; }, + fetchUserPurchase: (): ThunkPromiseAction => { + return async ( + dispatch: ThunkDispatch, + getState: () => RootState + ) => { + const { storage, auth, app } = getState(); + if (!app.signedIn) { + dispatch(AppActions.updateUserPurchase(undefined)); + return; + } + const user = auth.instance!.getCurrentAuthenticatedUserIgnoreNull(); + if (user === null) { + dispatch(AppActions.updateUserPurchase(undefined)); + return; + } + const result = await storage.instance!.getUserPurchase(user.uid); + if (isSuccessful(result)) { + dispatch(AppActions.updateUserPurchase(result.value)); + } else { + console.error(result.cause); + dispatch(NotificationActions.addError(result.error, result.cause)); + } + }; + }, }; export const LAYOUT_OPTIONS_ACTIONS = '@LayoutOptions'; diff --git a/src/actions/catalog.action.ts b/src/actions/catalog.action.ts index 17db0049..3e0ea7fa 100644 --- a/src/actions/catalog.action.ts +++ b/src/actions/catalog.action.ts @@ -282,6 +282,7 @@ export const catalogActionsThunk = { const { auth } = getState(); dispatch(AppActions.updateSignedIn(false)); dispatch(AppActions.updateUserInformation(undefined)); + dispatch(AppActions.updateUserPurchase(undefined)); await auth.instance!.signOut(); }, diff --git a/src/actions/workbench.action.ts b/src/actions/workbench.action.ts index 66456a62..c361c763 100644 --- a/src/actions/workbench.action.ts +++ b/src/actions/workbench.action.ts @@ -177,13 +177,11 @@ export const workbenchActionsThunk = { WorkbenchAppActions.updateCurrentProject(currentProjectWithFiles) ); - const updateUserInformationResult = await updateUserInformation( - storage.instance!, - { + const updateUserInformationResult = + await storage.instance!.updateUserInformation({ ...userInformation, currentProjectId, - } - ); + }); if (isError(updateUserInformationResult)) { dispatch( NotificationActions.addError( @@ -382,13 +380,11 @@ export const workbenchActionsThunk = { dispatch(WorkbenchAppActions.updateCurrentProject(newProject)); dispatch(WorkbenchAppActions.updateSelectedFile(undefined)); - const updateUserInformationResult = await updateUserInformation( - storage.instance!, - { + const updateUserInformationResult = + await storage.instance!.updateUserInformation({ ...app.user.information!, currentProjectId: newProject.id, - } - ); + }); if (isError(updateUserInformationResult)) { dispatch( NotificationActions.addError( @@ -419,13 +415,11 @@ export const workbenchActionsThunk = { } dispatch(WorkbenchAppActions.updateCurrentProject(currentProject)); - const updateUserInformationResult = await updateUserInformation( - storage.instance!, - { + const updateUserInformationResult = + await storage.instance!.updateUserInformation({ ...app.user.information!, currentProjectId: currentProject.id, - } - ); + }); if (isError(updateUserInformationResult)) { dispatch( NotificationActions.addError( @@ -512,13 +506,11 @@ export const workbenchActionsThunk = { ); dispatch(WorkbenchAppActions.updateSelectedFile(undefined)); - const updateUserInformationResult = await updateUserInformation( - storage.instance!, - { + const updateUserInformationResult = + await storage.instance!.updateUserInformation({ ...app.user.information!, currentProjectId: newCurrentProjectWithFiles.id, - } - ); + }); if (isError(updateUserInformationResult)) { dispatch( NotificationActions.addError( @@ -636,6 +628,7 @@ export const workbenchActionsThunk = { const { auth } = getState(); dispatch(AppActions.updateSignedIn(false)); dispatch(AppActions.updateUserInformation(undefined)); + dispatch(AppActions.updateUserPurchase(undefined)); dispatch(WorkbenchAppActions.updateCurrentProject(undefined)); dispatch(WorkbenchAppActions.updateProjects([])); dispatch(WorkbenchAppActions.updateSelectedFile(undefined)); @@ -673,18 +666,3 @@ const determineBootloaderType = ( return 'copy'; } }; - -const updateUserInformation = async ( - storage: IStorage, - userInformation: IUserInformation -): Promise => { - const updateUserInformationResult = - await storage.updateUserInformation(userInformation); - if (isError(updateUserInformationResult)) { - return errorResultOf( - updateUserInformationResult.error, - updateUserInformationResult.cause - ); - } - return successResult(); -}; diff --git a/src/components/catalog/Catalog.container.ts b/src/components/catalog/Catalog.container.ts index c06eec81..89d18f77 100644 --- a/src/components/catalog/Catalog.container.ts +++ b/src/components/catalog/Catalog.container.ts @@ -70,7 +70,8 @@ const mapDispatchToProps = (dispatch: any) => { }, updateSignedIn: (signedIn: boolean) => { dispatch(AppActions.updateSignedIn(signedIn)); - dispatch(AppActionsThunk.updateUserInformation()); + dispatch(AppActionsThunk.fetchUserInformation()); + dispatch(AppActionsThunk.fetchUserPurchase()); }, updateSearchCondition: (params: ParsedQs) => { if (params.keyword) { diff --git a/src/components/configure/Configure.container.ts b/src/components/configure/Configure.container.ts index 38e03110..60f55ae9 100644 --- a/src/components/configure/Configure.container.ts +++ b/src/components/configure/Configure.container.ts @@ -55,9 +55,7 @@ const mapDispatchToProps = (dispatch: any) => { * Switch to Keyboard selection page. */ dispatch(HidActions.updateKeyboard(null)); - dispatch( - AppActions.updateSetupPhase(SetupPhase.keyboardNotSelected) - ); + dispatch(AppActions.updateSetupPhase(SetupPhase.keyboardNotSelected)); } } @@ -72,7 +70,8 @@ const mapDispatchToProps = (dispatch: any) => { updateSignedIn: (signedIn: boolean) => { dispatch(AppActions.updateSignedIn(signedIn)); - dispatch(AppActionsThunk.updateUserInformation()); + dispatch(AppActionsThunk.fetchUserInformation()); + dispatch(AppActionsThunk.fetchUserPurchase()); }, initializeMeta: () => { dispatch(MetaActions.initialize()); diff --git a/src/components/documents/Documents.container.ts b/src/components/documents/Documents.container.ts index cf31a071..0be29ddb 100644 --- a/src/components/documents/Documents.container.ts +++ b/src/components/documents/Documents.container.ts @@ -17,7 +17,8 @@ const mapDispatchToProps = (dispatch: any) => { }, updateSignedIn: (signedIn: boolean) => { dispatch(AppActions.updateSignedIn(signedIn)); - dispatch(AppActionsThunk.updateUserInformation()); + dispatch(AppActionsThunk.fetchUserInformation()); + dispatch(AppActionsThunk.fetchUserPurchase()); }, }; }; diff --git a/src/components/workbench/Workbench.container.ts b/src/components/workbench/Workbench.container.ts index 47b891a9..b3efb156 100644 --- a/src/components/workbench/Workbench.container.ts +++ b/src/components/workbench/Workbench.container.ts @@ -30,7 +30,8 @@ const mapDispatchToProps = (dispatch: any) => { }, updateSignedIn: (signedIn: boolean) => { dispatch(AppActions.updateSignedIn(signedIn)); - dispatch(AppActionsThunk.updateUserInformation()); + dispatch(AppActionsThunk.fetchUserInformation()); + dispatch(AppActionsThunk.fetchUserPurchase()); }, initializeWorkbench: () => { dispatch(workbenchActionsThunk.initializeWorkbench()); diff --git a/src/components/workbench/breadboard/Breadboard.tsx b/src/components/workbench/breadboard/Breadboard.tsx index 4f4369a9..dccca543 100644 --- a/src/components/workbench/breadboard/Breadboard.tsx +++ b/src/components/workbench/breadboard/Breadboard.tsx @@ -42,6 +42,7 @@ import AutorenewIcon from '@mui/icons-material/Autorenew'; import DeveloperBoardIcon from '@mui/icons-material/DeveloperBoard'; import FlashFirmwareDialog from '../../common/firmware/FlashFirmwareDialog.container'; import { t } from 'i18next'; +import { useUserPurchaseHook } from './UserPurchaseHook'; type OwnProps = {}; type BreadboardProps = OwnProps & @@ -83,6 +84,8 @@ export default function Breadboard( useBuildTaskHook(props.currentProject?.id, props.storage?.instance); + useUserPurchaseHook(props.storage?.instance); + // Event Handlers const onClickWorkbenchProjectFile = ( diff --git a/src/components/workbench/breadboard/UserPurchaseHook.ts b/src/components/workbench/breadboard/UserPurchaseHook.ts new file mode 100644 index 00000000..b387d258 --- /dev/null +++ b/src/components/workbench/breadboard/UserPurchaseHook.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { IStorage } from '../../../services/storage/Storage'; +import { AppActions } from '../../../actions/actions'; + +export const useUserPurchaseHook = (storage: IStorage | null | undefined) => { + const dispatch = useDispatch(); + + useEffect(() => { + if (storage === null || storage === undefined) { + return; + } + const unsubscribe = storage.onSnapshotUserPurchase((purchase) => { + dispatch(AppActions.updateUserPurchase(purchase)); + }); + return () => { + unsubscribe(); + }; + }, [dispatch, storage]); +}; diff --git a/src/components/workbench/header/Header.container.ts b/src/components/workbench/header/Header.container.ts index 94138b8b..56156422 100644 --- a/src/components/workbench/header/Header.container.ts +++ b/src/components/workbench/header/Header.container.ts @@ -19,6 +19,7 @@ const mapStateToProps = (state: RootState) => { phase: state.catalog.app.phase, currentProject: state.workbench.app.currentProject, projects: state.workbench.app.projects, + userPurchase: state.app.user.purchase, }; }; export type HeaderStateType = ReturnType; diff --git a/src/components/workbench/header/Header.tsx b/src/components/workbench/header/Header.tsx index 33e44d2b..ec5db381 100644 --- a/src/components/workbench/header/Header.tsx +++ b/src/components/workbench/header/Header.tsx @@ -171,14 +171,17 @@ export default function Header(props: HeaderProps | Readonly) { > {t('Projects')} - + {props.userPurchase !== undefined && + props.userPurchase.remainingBuildCount > 0 ? ( + + ) : null} )}
diff --git a/src/services/provider/Firebase.ts b/src/services/provider/Firebase.ts index f9ad0030..82f37ea6 100644 --- a/src/services/provider/Firebase.ts +++ b/src/services/provider/Firebase.ts @@ -46,6 +46,7 @@ import { IAuth, IAuthenticationResult } from '../auth/Auth'; import { IFirmwareCodePlace, IKeyboardFeatures, + IUserPurchase, IUserInformation, } from '../../store/state'; import { IDeviceInformation } from '../hid/Hid'; @@ -2484,4 +2485,63 @@ export class FirebaseProvider implements IStorage, IAuth { }); return unsubscribe; } + + async getUserPurchase(uid: string): Promise> { + try { + const now = new Date(); + const documentSnapshot = await this.db + .collection('users') + .doc('v1') + .collection('purchases') + .doc(uid) + .get(); + if (documentSnapshot.exists) { + return successResultOf({ + uid: documentSnapshot.id, + ...documentSnapshot.data(), + } as IUserPurchase); + } else { + await this.db + .collection('users') + .doc('v1') + .collection('purchases') + .doc(uid) + .set({ + remainingBuildCount: 3, + createdAt: now, + updatedAt: now, + }); + return successResultOf({ + uid, + remainingBuildCount: 3, + createdAt: now, + updatedAt: now, + } as IUserPurchase); + } + } catch (error) { + console.error(error); + return errorResultOf(`Creating user purchase failed: ${error}`, error); + } + } + + onSnapshotUserPurchase( + callback: (purchase: IUserPurchase) => void + ): () => void { + const uid = this.getCurrentAuthenticatedUserIgnoreNull()!.uid; + const unsubscribe = this.db + .collection('users') + .doc('v1') + .collection('purchases') + .doc(uid) + .onSnapshot((doc) => { + if (doc.exists) { + const purchase = { + uid: doc.id, + ...doc.data(), + } as IUserPurchase; + callback(purchase); + } + }); + return unsubscribe; + } } diff --git a/src/services/storage/Storage.ts b/src/services/storage/Storage.ts index 505285dd..2019826c 100644 --- a/src/services/storage/Storage.ts +++ b/src/services/storage/Storage.ts @@ -2,6 +2,7 @@ import { LayoutOption } from '../../components/configure/keymap/Keymap'; import { IFirmwareCodePlace, IKeyboardFeatures, + IUserPurchase, IUserInformation, } from '../../store/state'; import { IDeviceInformation } from '../hid/Hid'; @@ -544,6 +545,10 @@ export interface IStorage { updateUserInformation( userInformation: IUserInformation ): Promise; + getUserPurchase(uid: string): Promise>; + onSnapshotUserPurchase( + callback: (purchase: IUserPurchase) => void + ): () => void; fetchMyWorkbenchProjects(): Promise>; fetchWorkbenchProjectWithFiles( diff --git a/src/store/reducers.ts b/src/store/reducers.ts index 41e15c72..c312de8e 100644 --- a/src/store/reducers.ts +++ b/src/store/reducers.ts @@ -51,6 +51,7 @@ import { KEYMAP_CLEAR_SELECTED_KEY_POSITION, KEYMAP_UPDATE_SELECTED_KEY_POSITION, APP_UPDATE_USER_INFORMATION, + APP_UPDATE_USER_PURCHASE, } from '../actions/actions'; import { HID_ACTIONS, @@ -739,6 +740,10 @@ const appReducer = (action: Action, draft: WritableDraft) => { draft.app.user.information = action.value; break; } + case APP_UPDATE_USER_PURCHASE: { + draft.app.user.purchase = action.value; + break; + } } }; diff --git a/src/store/state.ts b/src/store/state.ts index c75f785c..26ec34df 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -262,6 +262,13 @@ export type IUserInformation = { updatedAt: Date; }; +export type IUserPurchase = { + uid: string; + remainingBuildCount: number; + createdAt: Date; + updatedAt: Date; +}; + export type RootState = { entities: { device: { @@ -343,6 +350,7 @@ export type RootState = { }; user: { information: IUserInformation | undefined; + purchase: IUserPurchase | undefined; }; }; configure: { @@ -620,6 +628,7 @@ export const INIT_STATE: RootState = { }, user: { information: undefined, + purchase: undefined, }, }, configure: { From e710e40553588b0c786c222d4a2255e07151a418 Mon Sep 17 00:00:00 2001 From: Yoichiro Tanaka Date: Thu, 24 Apr 2025 20:03:27 +0900 Subject: [PATCH 2/7] Support payment by PayPal. --- package.json | 1 + src/App.tsx | 102 +++++++++------ src/assets/locales/en.json | 11 +- src/assets/locales/ja.json | 11 +- .../RemainingBuildPurchaseDialog.container.ts | 31 +++++ .../dialogs/RemainingBuildPurchaseDialog.tsx | 123 ++++++++++++++++++ .../workbench/header/Header.container.ts | 5 +- src/components/workbench/header/Header.tsx | 53 ++++++-- src/services/provider/Firebase.ts | 38 ++++++ src/services/storage/Storage.ts | 86 ++++++++++++ yarn.lock | 46 +++++++ 11 files changed, 450 insertions(+), 57 deletions(-) create mode 100644 src/components/workbench/dialogs/RemainingBuildPurchaseDialog.container.ts create mode 100644 src/components/workbench/dialogs/RemainingBuildPurchaseDialog.tsx diff --git a/package.json b/package.json index ec127ea0..355b96d2 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@mui/icons-material": "^5.15.19", "@mui/material": "^5.15.19", "@mui/styles": "^5.15.19", + "@paypal/react-paypal-js": "^8.8.3", "@pdf-lib/fontkit": "^1.1.1", "ajv": "^7.0.3", "axios": "^0.21.1", diff --git a/src/App.tsx b/src/App.tsx index 6ba43e07..857b8a9a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import enJson from './assets/locales/en.json'; import jaJson from './assets/locales/ja.json'; import LanguageDetector from 'i18next-browser-languagedetector'; import Workbench from './components/workbench/Workbench.container'; +import { PayPalScriptProvider } from '@paypal/react-paypal-js'; i18n .use(LanguageDetector) @@ -34,6 +35,13 @@ i18n interpolation: { escapeValue: false }, }); +// PayPal client ID for production. +const PAYPAL_CLIENT_ID = + 'AQZXAw8Mr_sl4JpTZjCD_tR-xPBi3M3HUPDEySq6gy2C3Uk-wcLfatuXIXxw5GF_7Ijz_fW1w5cwtm-J'; +// PayPal client ID for sandbox. +// const PAYPAL_CLIENT_ID = +// 'AaQjWXEdTtWn-_qPRaeIRDLpcEAQtYZlKxdzZQ5aREMU1kh7gIl3E6YEMHZBHETx_9xZyKrY6JGK_R8I'; + class App extends React.Component { constructor( props: StyledComponentProps | Readonly> @@ -58,48 +66,58 @@ class App extends React.Component { variantInfo: this.props.classes!.info, }} > - - - } /> - } /> - } /> - } /> - } - /> - } - /> - } /> - } - /> - } /> - } - /> - } - /> - } - /> - } - /> - } /> - } /> - } /> - } /> - - + + + + } /> + } /> + } /> + } /> + } + /> + } + /> + } + /> + } + /> + } /> + } + /> + } + /> + } + /> + } + /> + } /> + } /> + } /> + } /> + + + ); } diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 26ef8d31..d281c426 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -335,5 +335,14 @@ "Account": "Account", "Project": "Project", "Build Tasks": "Build Tasks", - "Are you sure you want to delete file?": "Are you sure you want to delete \"{{name}}\" file?" + "Are you sure you want to delete file?": "Are you sure you want to delete \"{{name}}\" file?", + "Firmware Workbench": "Firmware Workbench", + "Get your own firmware by writing it from source code.": "Get your own firmware by writing it from source code.", + "Purchase Remaining Builds": "Purchase Builds", + "By purchasing the Remap 10 Builds Package, you can use the firmware workbench feature to build your custom firmware up to 10 times.": "By purchasing the Remap 10 Builds Package, you can use the firmware workbench feature to build your custom firmware up to 10 times.", + "Make the most of this feature to customize your keyboard and make it more comfortable to use.": "Make the most of this feature to customize your keyboard and make it more comfortable to use.", + "Remap 10 Builds Package": "Remap 10 Builds Package", + "Item amount:": "Item amount:", + "Tax amount:": "Tax amount:", + "Total amount:": "Total amount:" } diff --git a/src/assets/locales/ja.json b/src/assets/locales/ja.json index eafa36d0..03463951 100644 --- a/src/assets/locales/ja.json +++ b/src/assets/locales/ja.json @@ -358,5 +358,14 @@ "Account": "アカウント", "Project": "プロジェクト", "Build Tasks": "ビルドタスク", - "Are you sure you want to delete file?": "本当に \"{{name}}\" ファイルを削除しますか?" + "Are you sure you want to delete file?": "本当に \"{{name}}\" ファイルを削除しますか?", + "Firmware Workbench": "ファームウェアワークベンチ", + "Get your own firmware by writing it from source code.": "プログラムを書いて、自分だけのファームウェアを手に入れよう。", + "Purchase Remaining Builds": "ビルドを購入", + "By purchasing the Remap 10 Builds Package, you can use the firmware workbench feature to build your custom firmware up to 10 times.": "Remap 10回ビルドパッケージを購入すると、ファームウェアワークベンチ機能を使用して、カスタムファームウェアを10回ビルドできます。", + "Make the most of this feature to customize your keyboard and make it more comfortable to use.": "この機能を最大限に活用して、キーボードをカスタマイズし、より快適に使用できるようにしましょう。", + "Remap 10 Builds Package": "Remap 10回ビルドパッケージ", + "Item amount:": "商品価格:", + "Tax amount:": "税額:", + "Total amount:": "合計:" } diff --git a/src/components/workbench/dialogs/RemainingBuildPurchaseDialog.container.ts b/src/components/workbench/dialogs/RemainingBuildPurchaseDialog.container.ts new file mode 100644 index 00000000..78c08091 --- /dev/null +++ b/src/components/workbench/dialogs/RemainingBuildPurchaseDialog.container.ts @@ -0,0 +1,31 @@ +import { connect } from 'react-redux'; +import { RootState } from '../../../store/state'; +import { RemainingBuildPurchaseDialog } from './RemainingBuildPurchaseDialog'; +import { NotificationActions } from '../../../actions/actions'; + +// eslint-disable-next-line no-unused-vars +const mapStateToProps = (state: RootState) => { + return { + storage: state.storage.instance, + }; +}; +export type RemainingBuildPurchaseDialogStateType = ReturnType< + typeof mapStateToProps +>; + +// eslint-disable-next-line no-unused-vars +const mapDispatchToProps = (dispatch: any) => { + return { + showErrorMessage: (message: string) => { + dispatch(NotificationActions.addError(message)); + }, + }; +}; +export type RemainingBuildPurchaseDialogActionsType = ReturnType< + typeof mapDispatchToProps +>; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(RemainingBuildPurchaseDialog); diff --git a/src/components/workbench/dialogs/RemainingBuildPurchaseDialog.tsx b/src/components/workbench/dialogs/RemainingBuildPurchaseDialog.tsx new file mode 100644 index 00000000..8ebb7706 --- /dev/null +++ b/src/components/workbench/dialogs/RemainingBuildPurchaseDialog.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from '@mui/material'; +import Grid from '@mui/material/Grid'; +import { t } from 'i18next'; +import { useTranslation } from 'react-i18next'; +import { PayPalButtons } from '@paypal/react-paypal-js'; +import { + RemainingBuildPurchaseDialogActionsType, + RemainingBuildPurchaseDialogStateType, +} from './RemainingBuildPurchaseDialog.container'; +import { isError } from '../../../types'; + +type OwnProps = { + open: boolean; + onClose: () => void; + onPurchase: () => void; +}; +type RemainingBuildPurchaseDialogProps = OwnProps & + Partial & + Partial; + +export function RemainingBuildPurchaseDialog( + props: RemainingBuildPurchaseDialogProps +) { + const { i18n } = useTranslation(); + const language = i18n.language; + + const createOrder = async () => { + const orderCreateResult = await props.storage!.orderCreate(language); + if (isError(orderCreateResult)) { + console.error('Error creating order:', orderCreateResult); + props.showErrorMessage!( + 'Error creating order: ' + orderCreateResult.error + ); + throw new Error('Error creating order'); + } + return orderCreateResult.value; + }; + + const captureOrder = async (orderId: string) => { + const captureOrderResult = await props.storage!.captureOrder(orderId); + if (isError(captureOrderResult)) { + console.error('Error capturing order:', captureOrderResult); + props.showErrorMessage!( + 'Error capturing order: ' + captureOrderResult.error + ); + throw new Error('Error capturing order'); + } + props.onPurchase(); + }; + + return ( + + {t('Purchase Remaining Builds')} + + {t('Remap 10 Builds Package')} + + + + {t('Item amount:')} + + + $1.50 + + + {t('Tax amount:')} + + + $0.15 + + + {t('Total amount:')} + + + $1.65 + + + + + + {t( + 'By purchasing the Remap 10 Builds Package, you can use the firmware workbench feature to build your custom firmware up to 10 times.' + )} + + + {t( + 'Make the most of this feature to customize your keyboard and make it more comfortable to use.' + )} + + + { + const orderId = data.orderID; + if (orderId === undefined) { + throw new Error('Order ID is undefined'); + } + await captureOrder(orderId); + }} + /> + + + + + + ); +} diff --git a/src/components/workbench/header/Header.container.ts b/src/components/workbench/header/Header.container.ts index 56156422..d9a62879 100644 --- a/src/components/workbench/header/Header.container.ts +++ b/src/components/workbench/header/Header.container.ts @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import { RootState } from '../../../store/state'; import Header from './Header'; -import { AppActionsThunk } from '../../../actions/actions'; +import { AppActionsThunk, NotificationActions } from '../../../actions/actions'; import { IBuildableFirmwareFileType, IWorkbenchProject, @@ -58,6 +58,9 @@ const mapDispatchToProps = (dispatch: any) => { createFirmwareBuildingTask: (project: IWorkbenchProject) => { dispatch(workbenchActionsThunk.createFirmwareBuildingTask(project)); }, + showMessage: (message: string) => { + dispatch(NotificationActions.addSuccess(message)); + }, }; }; export type HeaderActionsType = ReturnType; diff --git a/src/components/workbench/header/Header.tsx b/src/components/workbench/header/Header.tsx index ec5db381..b28fc7e8 100644 --- a/src/components/workbench/header/Header.tsx +++ b/src/components/workbench/header/Header.tsx @@ -22,6 +22,7 @@ import WorkbenchProjectsDialog from '../dialogs/WorkbenchProjectsDialog'; import ConfirmDialog from '../../common/confirm/ConfirmDialog'; import WorkbenchProjectSettingsDialog from '../dialogs/WorkbenchProjectSettingsDialog'; import { t } from 'i18next'; +import RemainingBuildPurchaseDialog from '../dialogs/RemainingBuildPurchaseDialog.container'; type OwnProps = {}; type HeaderProps = OwnProps & @@ -37,6 +38,10 @@ export default function Header(props: HeaderProps | Readonly) { >(undefined); const [openProjectSettingsDialog, setOpenProjectSettingsDialog] = useState(false); + const [ + openRemainingBuildPurchaseDialog, + setOpenRemainingBuildPurchaseDialog, + ] = useState(false); useEffect(() => { if (props.currentProject === undefined) { @@ -134,6 +139,15 @@ export default function Header(props: HeaderProps | Readonly) { props.createFirmwareBuildingTask!(props.currentProject); }; + const onClickPurchase = () => { + setOpenRemainingBuildPurchaseDialog(true); + }; + + const onPurchaseRemainingBuilds = () => { + setOpenRemainingBuildPurchaseDialog(false); + props.showMessage!(t('Purchase completed successfully')); + }; + return (
@@ -142,7 +156,7 @@ export default function Header(props: HeaderProps | Readonly) { - Firmware Workbench + {t('Firmware Workbench')}
@@ -171,17 +185,25 @@ export default function Header(props: HeaderProps | Readonly) { > {t('Projects')} - {props.userPurchase !== undefined && - props.userPurchase.remainingBuildCount > 0 ? ( - - ) : null} + )}
@@ -223,6 +245,13 @@ export default function Header(props: HeaderProps | Readonly) { workbenchProject={props.currentProject} onApply={onApplyProjectSettings} /> + { + setOpenRemainingBuildPurchaseDialog(false); + }} + onPurchase={onPurchaseRemainingBuilds} + > ); } diff --git a/src/services/provider/Firebase.ts b/src/services/provider/Firebase.ts index 82f37ea6..4acf81bf 100644 --- a/src/services/provider/Firebase.ts +++ b/src/services/provider/Firebase.ts @@ -41,6 +41,8 @@ import { IKeyboardStatistics, IWorkbenchProject, IWorkbenchProjectFile, + IRemainingBuildPurchaseCreateOrderResult, + IRemainingBuildPurchaseCaptureOrderResult, } from '../storage/Storage'; import { IAuth, IAuthenticationResult } from '../auth/Auth'; import { @@ -2544,4 +2546,40 @@ export class FirebaseProvider implements IStorage, IAuth { }); return unsubscribe; } + + async orderCreate(language: string): Promise> { + try { + const orderCreate = this.functions.httpsCallable('orderCreate'); + const orderCreateResult = await orderCreate({ language }); + const data = orderCreateResult.data; + if (data.success) { + return successResultOf(data.orderId); + } else { + console.error(data.errorMessage); + return errorResultOf(data.errorMessage); + } + } catch (error) { + console.error(error); + return errorResultOf(`Creating order failed: ${error}`, error); + } + } + + async captureOrder(orderId: string): Promise { + try { + const captureOrder = this.functions.httpsCallable('captureOrder'); + const captureOrderResult = await captureOrder({ + orderId, + }); + const data = captureOrderResult.data; + if (data.success) { + return successResult(); + } else { + console.error(data.errorMessage); + return errorResultOf(data.errorMessage); + } + } catch (error) { + console.error(error); + return errorResultOf(`Capturing order failed: ${error}`, error); + } + } } diff --git a/src/services/storage/Storage.ts b/src/services/storage/Storage.ts index 2019826c..e2ee5c38 100644 --- a/src/services/storage/Storage.ts +++ b/src/services/storage/Storage.ts @@ -9,6 +9,7 @@ import { IDeviceInformation } from '../hid/Hid'; import { KeyboardLabelLang } from '../labellang/KeyLabelLangs'; import { IBootloaderType } from '../firmware/Types'; import { IEmptyResult, IResult } from '../../types'; +import { I } from 'react-router/dist/production/route-data-DuV3tXo2'; export type IKeyboardDefinitionStatus = | 'draft' @@ -316,6 +317,89 @@ export type IWorkbenchProjectFile = { updatedAt: Date; }; +export type IPaypalLink = { + href: string; + rel: string; + method: string; +}; + +export type IPaypalAmount = { + currency_code: string; + value: string; +}; + +export type IRemainingBuildPurchaseCreateOrderResult = { + id: string; + status: string; + links: IPaypalLink[]; +}; + +export type IRemainingBuildPurchaseCaptureOrderResult = { + id: string; + status: string; + payment_source: { + paypal: { + email_address: string; + account_id: string; + account_status: string; + name: { + given_name: string; + surname: string; + }; + address: { + country_code: string; + }; + }; + }; + purchase_units: { + reference_id: string; + shipping: { + name: { + full_name: string; + }; + address: { + address_line_1: string; + admin_area_2: string; + admin_area_1: string; + postal_code: string; + country_code: string; + }; + }; + payments: { + captures: { + id: string; + status: string; + amount: IPaypalAmount; + final_capture: boolean; + seller_protection: { + status: string; + dispute_categories: string[]; + }; + seller_receivable_breakdown: { + gross_amount: IPaypalAmount; + paypal_fee: IPaypalAmount; + net_amount: IPaypalAmount; + }; + links: IPaypalLink[]; + create_time: string; + update_time: string; + }[]; + }; + }[]; + payer: { + name: { + given_name: string; + surname: string; + }; + email_address: string; + payer_id: string; + address: { + country_code: string; + }; + }; + links: IPaypalLink[]; +}; + /* eslint-disable no-unused-vars */ export interface IStorage { fetchKeyboardDefinitionDocumentByDeviceInfo( @@ -588,5 +672,7 @@ export interface IStorage { projectId: string, callback: (tasks: IFirmwareBuildingTask[]) => void ): () => void; + orderCreate(language: string): Promise>; + captureOrder(orderId: string): Promise; } /* eslint-enable no-unused-vars */ diff --git a/yarn.lock b/yarn.lock index 2f84c4ab..52c8998c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3119,6 +3119,37 @@ __metadata: languageName: node linkType: hard +"@paypal/paypal-js@npm:^8.1.2": + version: 8.2.0 + resolution: "@paypal/paypal-js@npm:8.2.0" + dependencies: + promise-polyfill: "npm:^8.3.0" + checksum: 10c0/84cd3ca6db0f8a2f1686a8a78b53fa26429b5660175e49750fc2d68c4324b1a52b367b155a5bd8cd0eaf8aae66832308c491ff5047197bccd510a07e0b72b3c7 + languageName: node + linkType: hard + +"@paypal/react-paypal-js@npm:^8.8.3": + version: 8.8.3 + resolution: "@paypal/react-paypal-js@npm:8.8.3" + dependencies: + "@paypal/paypal-js": "npm:^8.1.2" + "@paypal/sdk-constants": "npm:^1.0.122" + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-dom: ^16.8.0 || ^17 || ^18 || ^19 + checksum: 10c0/3084ac62c2e7c368702a54896b65f23cd1c020cdaad75f26e4c4f19c4b1b9b9956a40d18aa2525727fd4247abaf79963431752c5637a7a4a6f5fb3e47b678636 + languageName: node + linkType: hard + +"@paypal/sdk-constants@npm:^1.0.122": + version: 1.0.153 + resolution: "@paypal/sdk-constants@npm:1.0.153" + dependencies: + hi-base32: "npm:^0.5.0" + checksum: 10c0/4f09c480fa93f9861c7bcb9307dbdc8521d459b08e3af240763a6bae02df04b6c4bbc37b3591ebdd435e0793ab16cbcc6d28cc9b3f799b75e70f9f45beb7f3fc + languageName: node + linkType: hard + "@pdf-lib/fontkit@npm:^1.1.1": version: 1.1.1 resolution: "@pdf-lib/fontkit@npm:1.1.1" @@ -5905,6 +5936,7 @@ __metadata: "@mui/icons-material": "npm:^5.15.19" "@mui/material": "npm:^5.15.19" "@mui/styles": "npm:^5.15.19" + "@paypal/react-paypal-js": "npm:^8.8.3" "@pdf-lib/fontkit": "npm:^1.1.1" "@storybook/addon-actions": "npm:^6.1.14" "@storybook/addon-essentials": "npm:^6.1.15" @@ -10909,6 +10941,13 @@ __metadata: languageName: node linkType: hard +"hi-base32@npm:^0.5.0": + version: 0.5.1 + resolution: "hi-base32@npm:0.5.1" + checksum: 10c0/45adb99249b04108fd689cb344f4b625b49d0518327117c34c403dbaf4723aaefb2a1bde6d7a0288ff0f835970d0f844a3ae103534dc237c99b8552a0e689177 + languageName: node + linkType: hard + "hmac-drbg@npm:^1.0.1": version: 1.0.1 resolution: "hmac-drbg@npm:1.0.1" @@ -14885,6 +14924,13 @@ __metadata: languageName: node linkType: hard +"promise-polyfill@npm:^8.3.0": + version: 8.3.0 + resolution: "promise-polyfill@npm:8.3.0" + checksum: 10c0/97141f07a31a6be135ec9ed53700a3423a771cabec0ba5e08fcb2bf8cfda855479ff70e444fceb938f560be42b450cd032c14f4a940ed2ad1e5b4dcb78368dce + languageName: node + linkType: hard + "promise-retry@npm:^2.0.1": version: 2.0.1 resolution: "promise-retry@npm:2.0.1" From 65acd9aeed6ed60c0c3f195f9b0684945ce914c9 Mon Sep 17 00:00:00 2001 From: Yoichiro Tanaka Date: Fri, 2 May 2025 21:01:14 +0900 Subject: [PATCH 3/7] Put the link to navigate to the term page. --- .../dialogs/RemainingBuildPurchaseDialog.tsx | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/components/workbench/dialogs/RemainingBuildPurchaseDialog.tsx b/src/components/workbench/dialogs/RemainingBuildPurchaseDialog.tsx index 8ebb7706..77f724b3 100644 --- a/src/components/workbench/dialogs/RemainingBuildPurchaseDialog.tsx +++ b/src/components/workbench/dialogs/RemainingBuildPurchaseDialog.tsx @@ -6,6 +6,8 @@ import { DialogActions, DialogContent, DialogTitle, + Link, + Stack, Typography, } from '@mui/material'; import Grid from '@mui/material/Grid'; @@ -62,7 +64,7 @@ export function RemainingBuildPurchaseDialog( {t('Purchase Remaining Builds')} {t('Remap 10 Builds Package')} - + {t('Item amount:')} @@ -84,17 +86,26 @@ export function RemainingBuildPurchaseDialog( - - - {t( - 'By purchasing the Remap 10 Builds Package, you can use the firmware workbench feature to build your custom firmware up to 10 times.' + + + + {t( + 'By purchasing the Remap 10 Builds Package, you can use the firmware workbench feature to build your custom firmware up to 10 times.' + )} + + + {t( + 'Make the most of this feature to customize your keyboard and make it more comfortable to use.' + )} + + {language === 'ja' && ( + + + 特定商取引法に基づく表記 + + )} - - - {t( - 'Make the most of this feature to customize your keyboard and make it more comfortable to use.' - )} - + Date: Wed, 7 May 2025 08:24:17 +0900 Subject: [PATCH 4/7] Implement purchase history page. --- firestore.rules | 6 + src/App.tsx | 8 +- src/actions/workbench.action.ts | 26 ++ src/assets/locales/en.json | 7 +- src/assets/locales/ja.json | 7 +- src/components/common/auth/ProfileIcon.tsx | 263 +++++++++--------- ...ngBuildPurchaseHistoryDialog.container.tsx | 32 +++ .../RemainingBuildPurchaseHistoryDialog.tsx | 102 +++++++ .../workbench/header/Header.container.ts | 3 + src/components/workbench/header/Header.tsx | 30 ++ src/services/provider/Firebase.ts | 36 +++ src/services/storage/Storage.ts | 14 + src/store/reducers.ts | 5 + src/store/state.ts | 3 + 14 files changed, 409 insertions(+), 133 deletions(-) create mode 100644 src/components/workbench/dialogs/RemainingBuildPurchaseHistoryDialog.container.tsx create mode 100644 src/components/workbench/dialogs/RemainingBuildPurchaseHistoryDialog.tsx diff --git a/firestore.rules b/firestore.rules index 629ca327..50c7f7a9 100644 --- a/firestore.rules +++ b/firestore.rules @@ -178,6 +178,12 @@ service cloud.firestore { && (request.auth.uid == userId); } + match /users/v1/purchases/{userId}/histories/{historyId} { + allow write: if false; + allow read: if isAuthenticated() + && (request.auth.uid == userId); + } + match /users/v1/purchases/{userId} { allow create: if isAuthenticated() && (request.auth.uid == userId); diff --git a/src/App.tsx b/src/App.tsx index 857b8a9a..ad449a1e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,11 +36,11 @@ i18n }); // PayPal client ID for production. -const PAYPAL_CLIENT_ID = - 'AQZXAw8Mr_sl4JpTZjCD_tR-xPBi3M3HUPDEySq6gy2C3Uk-wcLfatuXIXxw5GF_7Ijz_fW1w5cwtm-J'; -// PayPal client ID for sandbox. // const PAYPAL_CLIENT_ID = -// 'AaQjWXEdTtWn-_qPRaeIRDLpcEAQtYZlKxdzZQ5aREMU1kh7gIl3E6YEMHZBHETx_9xZyKrY6JGK_R8I'; +// 'AQZXAw8Mr_sl4JpTZjCD_tR-xPBi3M3HUPDEySq6gy2C3Uk-wcLfatuXIXxw5GF_7Ijz_fW1w5cwtm-J'; +// PayPal client ID for sandbox. +const PAYPAL_CLIENT_ID = + 'AaQjWXEdTtWn-_qPRaeIRDLpcEAQtYZlKxdzZQ5aREMU1kh7gIl3E6YEMHZBHETx_9xZyKrY6JGK_R8I'; class App extends React.Component { constructor( diff --git a/src/actions/workbench.action.ts b/src/actions/workbench.action.ts index c361c763..5019e4ba 100644 --- a/src/actions/workbench.action.ts +++ b/src/actions/workbench.action.ts @@ -12,6 +12,7 @@ import { IBuildableFirmwareFileType, IFirmwareBuildingTask, IStorage, + IUserPurchaseHistory, IWorkbenchProject, IWorkbenchProjectFile, } from '../services/storage/Storage'; @@ -28,6 +29,7 @@ export const WORKBENCH_APP_UPDATE_CURRENT_PROJECT = `${WORKBENCH_APP_ACTIONS}/Up export const WORKBENCH_APP_UPDATE_SELECTED_FILE = `${WORKBENCH_APP_ACTIONS}/UpdateSelectedFile`; export const WORKBENCH_APP_APPEND_FILE_TO_CURRENT_PROJECT = `${WORKBENCH_APP_ACTIONS}/AppendFileToCurrentProject`; export const WORKBENCH_APP_UPDATE_BUILDING_TASKS = `${WORKBENCH_APP_ACTIONS}/UpdateBuildableTasks`; +export const WORKBENCH_APP_UPDATE_USER_PURCHASE_HISTORIES = `${WORKBENCH_APP_ACTIONS}/UpdateUserPurchaseHistories`; export const WorkbenchAppActions = { updatePhase: (phase: IWorkbenchPhase) => { return { @@ -69,6 +71,14 @@ export const WorkbenchAppActions = { value: tasks, }; }, + updateUserPurchaseHistories: ( + userPurchaseHistories: IUserPurchaseHistory[] | undefined + ) => { + return { + type: WORKBENCH_APP_UPDATE_USER_PURCHASE_HISTORIES, + value: userPurchaseHistories, + }; + }, }; type ActionTypes = ReturnType< @@ -635,6 +645,22 @@ export const workbenchActionsThunk = { dispatch(WorkbenchAppActions.updatePhase(WorkbenchPhase.processing)); await auth.instance!.signOut(); }, + fetchUserPurchaseHistories: (): ThunkPromiseAction => { + return async ( + dispatch: ThunkDispatch, + getState: () => RootState + ) => { + const { storage, auth } = getState(); + const user = auth.instance!.getCurrentAuthenticatedUserIgnoreNull(); + const result = + await storage.instance!.fetchRemainingBuildPurchaseHistories(user.uid); + if (isError(result)) { + dispatch(NotificationActions.addError(result.error, result.cause)); + return; + } + dispatch(WorkbenchAppActions.updateUserPurchaseHistories(result.value)); + }; + }, }; const createDefaultProjectName = (projects: IWorkbenchProject[]): string => { diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index d281c426..b00e2855 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -344,5 +344,10 @@ "Remap 10 Builds Package": "Remap 10 Builds Package", "Item amount:": "Item amount:", "Tax amount:": "Tax amount:", - "Total amount:": "Total amount:" + "Total amount:": "Total amount:", + "Purchase History": "Purchase History", + "Date": "Date", + "Amount": "Amount", + "Error": "Error", + "If you have any questions about your purchase, please contact the Remap team.": "If you have any questions about your purchase, please contact the Remap team." } diff --git a/src/assets/locales/ja.json b/src/assets/locales/ja.json index 03463951..d998b521 100644 --- a/src/assets/locales/ja.json +++ b/src/assets/locales/ja.json @@ -367,5 +367,10 @@ "Remap 10 Builds Package": "Remap 10回ビルドパッケージ", "Item amount:": "商品価格:", "Tax amount:": "税額:", - "Total amount:": "合計:" + "Total amount:": "合計:", + "Purchase History": "購入履歴", + "Date": "日時", + "Amount": "金額", + "Error": "エラー", + "If you have any questions about your purchase, please contact the Remap team.": "購入に関して質問がある場合は、Remapチームにお問い合わせください。" } diff --git a/src/components/common/auth/ProfileIcon.tsx b/src/components/common/auth/ProfileIcon.tsx index 409a4db0..f313a88d 100644 --- a/src/components/common/auth/ProfileIcon.tsx +++ b/src/components/common/auth/ProfileIcon.tsx @@ -3,7 +3,14 @@ import { ProfileIconActionsType, ProfileIconStateType, } from './ProfileIcon.container'; -import { Avatar, IconButton, Menu, MenuItem } from '@mui/material'; +import { + Avatar, + IconButton, + ListItemIcon, + ListItemText, + Menu, + MenuItem, +} from '@mui/material'; import { Person, PersonOutline } from '@mui/icons-material'; import AuthProviderDialog from './AuthProviderDialog.container'; import { @@ -12,74 +19,68 @@ import { } from '../../../services/auth/Auth'; import './ProfileIcon.scss'; import { t } from 'i18next'; +import LogoutIcon from '@mui/icons-material/Logout'; +import LoginIcon from '@mui/icons-material/Login'; -type ProfileIconState = { - authMenuAnchorEl: any; - openAuthProviderDialog: boolean; -}; type OwnProps = { logout: () => void; + children?: React.ReactNode; }; type ProfileIconProps = OwnProps & Partial & - Partial; + Partial & { + childrenWithOnClose?: (onClose: () => void) => React.ReactNode; + }; -export default class ProfileIcon extends React.Component< - ProfileIconProps, - ProfileIconState -> { - constructor(props: ProfileIconProps | Readonly) { - super(props); - this.state = { - authMenuAnchorEl: null, - openAuthProviderDialog: false, - }; - } +export default function ProfileIcon( + props: ProfileIconProps | Readonly +) { + const [authMenuAnchorEl, setAuthMenuAnchorEl] = + React.useState(null); + const [openAuthProviderDialog, setOpenAuthProviderDialog] = + React.useState(false); - handleAuthMenuIconClick = (event: React.MouseEvent) => { - this.setState({ - authMenuAnchorEl: event.currentTarget, - }); + const handleAuthMenuIconClick = ( + event: React.MouseEvent + ) => { + setAuthMenuAnchorEl(event.currentTarget); }; - handleAuthMenuClose = () => { - this.setState({ - authMenuAnchorEl: null, - }); + const handleAuthMenuClose = () => { + setAuthMenuAnchorEl(null); }; - async handleLogoutMenuClick() { - await this.props.logout(); - this.setState({ - authMenuAnchorEl: null, - }); - } + const handleLogoutMenuClick = async () => { + await props.logout!(); + setAuthMenuAnchorEl(null); + }; - async handleLinkGoogleAccountMenuClick() { - this.setState({ authMenuAnchorEl: null }); - this.props.linkToGoogleAccount!(); - } + const handleLinkGoogleAccountMenuClick = async () => { + setAuthMenuAnchorEl(null); + props.linkToGoogleAccount!(); + }; - async handleLinkGitHubAccountMenuClick() { - this.setState({ authMenuAnchorEl: null }); - this.props.linkToGitHubAccount!(); - } + const handleLinkGitHubAccountMenuClick = async () => { + setAuthMenuAnchorEl(null); + props.linkToGitHubAccount!(); + }; - private handleCloseAuthProviderDialog() { - this.setState({ openAuthProviderDialog: false }); - } + const handleCloseAuthProviderDialog = async () => { + setOpenAuthProviderDialog(false); + }; - async handleLoginMenuClick() { - this.setState({ authMenuAnchorEl: null, openAuthProviderDialog: true }); - } + const handleLoginMenuClick = async () => { + setAuthMenuAnchorEl(null); + setOpenAuthProviderDialog(true); + }; - renderLinkGoogleAccountMenu() { - const user = this.props.auth!.getCurrentAuthenticatedUserIgnoreNull(); + const renderLinkGoogleAccountMenu = () => { + const user = props.auth!.getCurrentAuthenticatedUserIgnoreNull(); if (user && !getGoogleProviderData(user).exists) { return ( this.handleLinkGoogleAccountMenuClick()} + onClick={() => handleLinkGoogleAccountMenuClick()} > Link Google Account @@ -87,15 +88,15 @@ export default class ProfileIcon extends React.Component< } else { return null; } - } + }; - renderLinkGitHubAccountMenu() { - const user = this.props.auth!.getCurrentAuthenticatedUserIgnoreNull(); + const renderLinkGitHubAccountMenu = () => { + const user = props.auth!.getCurrentAuthenticatedUserIgnoreNull(); if (user && !getGitHubProviderData(user).exists) { return ( this.handleLinkGitHubAccountMenuClick()} + onClick={() => handleLinkGitHubAccountMenuClick()} > Link GitHub Account @@ -103,85 +104,93 @@ export default class ProfileIcon extends React.Component< } else { return null; } - } + }; - render() { - const { authMenuAnchorEl } = this.state; - if (this.props.signedIn) { - const user = this.props.auth!.getCurrentAuthenticatedUserIgnoreNull(); - const profileImageUrl = user.photoURL || ''; - const profileDisplayName = user.displayName || ''; - let avatar: React.ReactNode; - if (profileImageUrl) { - avatar = ( - - ); - } else { - avatar = ( - - - - ); - } - return ( - - - {avatar} - - - {this.renderLinkGoogleAccountMenu()} - {this.renderLinkGitHubAccountMenu()} - this.handleLogoutMenuClick()} - > - {t('Logout')} - - - + // Render + if (props.signedIn) { + const user = props.auth!.getCurrentAuthenticatedUserIgnoreNull(); + const profileImageUrl = user.photoURL || ''; + const profileDisplayName = user.displayName || ''; + let avatar: React.ReactNode; + if (profileImageUrl) { + avatar = ( + ); } else { - return ( - - - - - - - - this.handleLoginMenuClick()} - > - {t('Login')} - - - - + avatar = ( + + + ); } + return ( + + + {avatar} + + + {renderLinkGoogleAccountMenu()} + {renderLinkGitHubAccountMenu()} + {props.childrenWithOnClose && + props.childrenWithOnClose(handleAuthMenuClose)} + handleLogoutMenuClick()} + > + + + + {t('Logout')} + + + + ); + } else { + return ( + + + + + + + + handleLoginMenuClick()} + > + + + + {t('Login')} + + {props.childrenWithOnClose && + props.childrenWithOnClose(handleAuthMenuClose)} + + + + ); } } diff --git a/src/components/workbench/dialogs/RemainingBuildPurchaseHistoryDialog.container.tsx b/src/components/workbench/dialogs/RemainingBuildPurchaseHistoryDialog.container.tsx new file mode 100644 index 00000000..8a0f1e7a --- /dev/null +++ b/src/components/workbench/dialogs/RemainingBuildPurchaseHistoryDialog.container.tsx @@ -0,0 +1,32 @@ +import { connect } from 'react-redux'; +import { RootState } from '../../../store/state'; +import RemainingBuildPurchaseHistoryDialog from './RemainingBuildPurchaseHistoryDialog'; +import { clear } from 'console'; +import { WorkbenchAppActions } from '../../../actions/workbench.action'; + +// eslint-disable-next-line no-unused-vars +const mapStateToProps = (state: RootState) => { + return { + histories: state.workbench.app.userPurchaseHistories, + }; +}; +export type RemainingBuildPurchaseHistoryDialogStateType = ReturnType< + typeof mapStateToProps +>; + +// eslint-disable-next-line no-unused-vars +const mapDispatchToProps = (dispatch: any) => { + return { + clearPurchaseHistories: () => { + dispatch(WorkbenchAppActions.updateUserPurchaseHistories(undefined)); + }, + }; +}; +export type RemainingBuildPurchaseHistoryDialogActionsType = ReturnType< + typeof mapDispatchToProps +>; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(RemainingBuildPurchaseHistoryDialog); diff --git a/src/components/workbench/dialogs/RemainingBuildPurchaseHistoryDialog.tsx b/src/components/workbench/dialogs/RemainingBuildPurchaseHistoryDialog.tsx new file mode 100644 index 00000000..bce5514f --- /dev/null +++ b/src/components/workbench/dialogs/RemainingBuildPurchaseHistoryDialog.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { + RemainingBuildPurchaseHistoryDialogActionsType, + RemainingBuildPurchaseHistoryDialogStateType, +} from './RemainingBuildPurchaseHistoryDialog.container'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + List, + ListItem, + ListItemText, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +import { t } from 'i18next'; +import { format } from 'date-fns'; + +type OwnProps = {}; +type RemainingBuildPurchaseHistoryDialogProps = OwnProps & + Partial & + Partial; + +export default function RemainingBuildPurchaseHistoryDialog( + props: + | RemainingBuildPurchaseHistoryDialogProps + | Readonly +) { + return ( + + {t('Purchase History')} + + {props.histories !== undefined && props.histories.length > 0 ? ( + <> + + + + + ID + {t('Date')} + {t('Amount')} + {t('Status')} + {t('Error')} + + + + {props.histories.map((history, index) => { + let amount = ''; + if (history.captureOrderResponseJson !== undefined) { + const captureOrderResponse = JSON.parse( + history.captureOrderResponseJson + ); + amount = `$${ + captureOrderResponse.purchase_units[0].payments + .captures[0].amount.value + }`; + } + return ( + + {history.id} + + {format(history.updatedAt, 'yyyy/MM/dd HH:mm:ss')} + + {amount} + {history.status} + {history.errorMessage} + + ); + })} + +
+
+ + *{' '} + {t( + 'If you have any questions about your purchase, please contact the Remap team.' + )} + + + ) : ( + {t('No purchase history.')} + )} +
+ + + +
+ ); +} diff --git a/src/components/workbench/header/Header.container.ts b/src/components/workbench/header/Header.container.ts index d9a62879..8997bde6 100644 --- a/src/components/workbench/header/Header.container.ts +++ b/src/components/workbench/header/Header.container.ts @@ -61,6 +61,9 @@ const mapDispatchToProps = (dispatch: any) => { showMessage: (message: string) => { dispatch(NotificationActions.addSuccess(message)); }, + fetchUserPurchaseHistories: () => { + dispatch(workbenchActionsThunk.fetchUserPurchaseHistories()); + }, }; }; export type HeaderActionsType = ReturnType; diff --git a/src/components/workbench/header/Header.tsx b/src/components/workbench/header/Header.tsx index b28fc7e8..5f7a2564 100644 --- a/src/components/workbench/header/Header.tsx +++ b/src/components/workbench/header/Header.tsx @@ -10,6 +10,9 @@ import { Button, IconButton, InputAdornment, + ListItemIcon, + ListItemText, + MenuItem, TextField, Typography, } from '@mui/material'; @@ -23,6 +26,8 @@ import ConfirmDialog from '../../common/confirm/ConfirmDialog'; import WorkbenchProjectSettingsDialog from '../dialogs/WorkbenchProjectSettingsDialog'; import { t } from 'i18next'; import RemainingBuildPurchaseDialog from '../dialogs/RemainingBuildPurchaseDialog.container'; +import PaymentIcon from '@mui/icons-material/Payment'; +import RemainingBuildPurchaseHistoryDialog from '../dialogs/RemainingBuildPurchaseHistoryDialog.container'; type OwnProps = {}; type HeaderProps = OwnProps & @@ -148,6 +153,10 @@ export default function Header(props: HeaderProps | Readonly) { props.showMessage!(t('Purchase completed successfully')); }; + const onClickPurchaseHistory = () => { + props.fetchUserPurchaseHistories!(); + }; + return (
@@ -211,6 +220,26 @@ export default function Header(props: HeaderProps | Readonly) { logout={() => { props.logout!(); }} + childrenWithOnClose={(onClose) => { + if (props.signedIn) { + return ( + { + onClose(); + onClickPurchaseHistory(); + }} + > + + + + {t('Purchase History')} + + ); + } else { + return null; + } + }} />
@@ -252,6 +281,7 @@ export default function Header(props: HeaderProps | Readonly) { }} onPurchase={onPurchaseRemainingBuilds} > + ); } diff --git a/src/services/provider/Firebase.ts b/src/services/provider/Firebase.ts index 4acf81bf..11606b5e 100644 --- a/src/services/provider/Firebase.ts +++ b/src/services/provider/Firebase.ts @@ -43,6 +43,7 @@ import { IWorkbenchProjectFile, IRemainingBuildPurchaseCreateOrderResult, IRemainingBuildPurchaseCaptureOrderResult, + IUserPurchaseHistory, } from '../storage/Storage'; import { IAuth, IAuthenticationResult } from '../auth/Auth'; import { @@ -2582,4 +2583,39 @@ export class FirebaseProvider implements IStorage, IAuth { return errorResultOf(`Capturing order failed: ${error}`, error); } } + + async fetchRemainingBuildPurchaseHistories( + uid: string + ): Promise> { + try { + const querySnapshot = await this.db + .collection('users') + .doc('v1') + .collection('purchases') + .doc(uid) + .collection('histories') + .orderBy('createdAt', 'desc') + .get(); + return successResultOf( + querySnapshot.docs.map((doc) => { + return { + id: doc.id, + orderId: doc.data()!.orderId, + status: doc.data()!.status, + createOrderResponseJson: doc.data()!.createOrderResponseJson, + captureOrderResponseJson: doc.data()!.captureOrderResponseJson, + errorMessage: doc.data()!.errorMessage, + createdAt: doc.data()!.createdAt.toDate(), + updatedAt: doc.data()!.updatedAt.toDate(), + } as IUserPurchaseHistory; + }) + ); + } catch (error) { + console.error(error); + return errorResultOf( + `Fetching remaining build purchase histories failed: ${error}`, + error + ); + } + } } diff --git a/src/services/storage/Storage.ts b/src/services/storage/Storage.ts index e2ee5c38..8cfb2a20 100644 --- a/src/services/storage/Storage.ts +++ b/src/services/storage/Storage.ts @@ -400,6 +400,17 @@ export type IRemainingBuildPurchaseCaptureOrderResult = { links: IPaypalLink[]; }; +export type IUserPurchaseHistory = { + id: string; + orderId: string; + status: string; + createOrderResponseJson: string; + captureOrderResponseJson: string; + errorMessage: string; + createdAt: Date; + updatedAt: Date; +}; + /* eslint-disable no-unused-vars */ export interface IStorage { fetchKeyboardDefinitionDocumentByDeviceInfo( @@ -674,5 +685,8 @@ export interface IStorage { ): () => void; orderCreate(language: string): Promise>; captureOrder(orderId: string): Promise; + fetchRemainingBuildPurchaseHistories( + uid: string + ): Promise>; } /* eslint-enable no-unused-vars */ diff --git a/src/store/reducers.ts b/src/store/reducers.ts index c312de8e..3eb4a36e 100644 --- a/src/store/reducers.ts +++ b/src/store/reducers.ts @@ -224,6 +224,7 @@ import { WORKBENCH_APP_UPDATE_PHASE, WORKBENCH_APP_UPDATE_PROJECTS, WORKBENCH_APP_UPDATE_SELECTED_FILE, + WORKBENCH_APP_UPDATE_USER_PURCHASE_HISTORIES, } from '../actions/workbench.action'; export type Action = { type: string; value: any }; @@ -1311,6 +1312,10 @@ const workbenchAppReducer = ( draft.workbench.app.buildingTasks = action.value; break; } + case WORKBENCH_APP_UPDATE_USER_PURCHASE_HISTORIES: { + draft.workbench.app.userPurchaseHistories = action.value; + break; + } } }; diff --git a/src/store/state.ts b/src/store/state.ts index 26ec34df..a5d6fa9a 100644 --- a/src/store/state.ts +++ b/src/store/state.ts @@ -19,6 +19,7 @@ import { IOrganizationMember, IStorage, IStore, + IUserPurchaseHistory, IWorkbenchProject, SavedKeymapData, } from '../services/storage/Storage'; @@ -523,6 +524,7 @@ export type RootState = { | { fileId: string; fileType: IBuildableFirmwareFileType } | undefined; buildingTasks: IFirmwareBuildingTask[]; + userPurchaseHistories: IUserPurchaseHistory[] | undefined; }; }; }; @@ -800,6 +802,7 @@ export const INIT_STATE: RootState = { currentProject: undefined, selectedFile: undefined, buildingTasks: [], + userPurchaseHistories: undefined, }, }, }; From c10947629c449d9bb49cfe071b5654c560627ac6 Mon Sep 17 00:00:00 2001 From: Yoichiro Tanaka Date: Wed, 7 May 2025 10:26:31 +0900 Subject: [PATCH 5/7] Remove unnecessary import statements. --- .../workbench/dialogs/RemainingBuildPurchaseHistoryDialog.tsx | 3 --- src/services/provider/Firebase.ts | 2 -- src/services/storage/Storage.ts | 1 - 3 files changed, 6 deletions(-) diff --git a/src/components/workbench/dialogs/RemainingBuildPurchaseHistoryDialog.tsx b/src/components/workbench/dialogs/RemainingBuildPurchaseHistoryDialog.tsx index bce5514f..e756343a 100644 --- a/src/components/workbench/dialogs/RemainingBuildPurchaseHistoryDialog.tsx +++ b/src/components/workbench/dialogs/RemainingBuildPurchaseHistoryDialog.tsx @@ -9,9 +9,6 @@ import { DialogActions, DialogContent, DialogTitle, - List, - ListItem, - ListItemText, Table, TableBody, TableCell, diff --git a/src/services/provider/Firebase.ts b/src/services/provider/Firebase.ts index 11606b5e..135ff1e7 100644 --- a/src/services/provider/Firebase.ts +++ b/src/services/provider/Firebase.ts @@ -41,8 +41,6 @@ import { IKeyboardStatistics, IWorkbenchProject, IWorkbenchProjectFile, - IRemainingBuildPurchaseCreateOrderResult, - IRemainingBuildPurchaseCaptureOrderResult, IUserPurchaseHistory, } from '../storage/Storage'; import { IAuth, IAuthenticationResult } from '../auth/Auth'; diff --git a/src/services/storage/Storage.ts b/src/services/storage/Storage.ts index 8cfb2a20..5c3f4e82 100644 --- a/src/services/storage/Storage.ts +++ b/src/services/storage/Storage.ts @@ -9,7 +9,6 @@ import { IDeviceInformation } from '../hid/Hid'; import { KeyboardLabelLang } from '../labellang/KeyLabelLangs'; import { IBootloaderType } from '../firmware/Types'; import { IEmptyResult, IResult } from '../../types'; -import { I } from 'react-router/dist/production/route-data-DuV3tXo2'; export type IKeyboardDefinitionStatus = | 'draft' From 2e22103f01a4ff116b3cebdfbd4eb560df9621ce Mon Sep 17 00:00:00 2001 From: Yoichiro Tanaka Date: Wed, 7 May 2025 13:45:30 +0900 Subject: [PATCH 6/7] Refactor logics to support PayPal environment. --- .github/workflows/production.yaml | 1 + .github/workflows/pullrequest.yaml | 1 + src/App.tsx | 7 +------ src/services/provider/Firebase.ts | 8 +++++++- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/production.yaml b/.github/workflows/production.yaml index 40db9f30..be68df74 100644 --- a/.github/workflows/production.yaml +++ b/.github/workflows/production.yaml @@ -55,6 +55,7 @@ jobs: REACT_APP_FIREBASE_APP_ID: ${{ secrets.REACT_APP_FIREBASE_APP_ID }} REACT_APP_FIREBASE_MEASUREMENT_ID: ${{ secrets.REACT_APP_FIREBASE_MEASUREMENT_ID }} REACT_APP_ERROR_REPORTING_KEY: ${{ secrets.REACT_APP_ERROR_REPORTING_KEY }} + REACT_APP_PAYPAL_CLIENT_ID: ${{ secrets.REACT_APP_PAYPAL_CLIENT_ID }} run: yarn build && yarn test - name: Deploy to Firebase uses: w9jds/firebase-action@v13.5.2 diff --git a/.github/workflows/pullrequest.yaml b/.github/workflows/pullrequest.yaml index 6ddffc4f..25d7842d 100644 --- a/.github/workflows/pullrequest.yaml +++ b/.github/workflows/pullrequest.yaml @@ -43,4 +43,5 @@ jobs: REACT_APP_FIREBASE_APP_ID: ${{ secrets.REACT_APP_FIREBASE_APP_ID }} REACT_APP_FIREBASE_MEASUREMENT_ID: ${{ secrets.REACT_APP_FIREBASE_MEASUREMENT_ID }} REACT_APP_ERROR_REPORTING_KEY: ${{ secrets.REACT_APP_ERROR_REPORTING_KEY }} + REACT_APP_PAYPAL_CLIENT_ID: ${{ secrets.REACT_APP_PAYPAL_CLIENT_ID }} run: yarn build && yarn test diff --git a/src/App.tsx b/src/App.tsx index ad449a1e..c9d5e787 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,12 +35,7 @@ i18n interpolation: { escapeValue: false }, }); -// PayPal client ID for production. -// const PAYPAL_CLIENT_ID = -// 'AQZXAw8Mr_sl4JpTZjCD_tR-xPBi3M3HUPDEySq6gy2C3Uk-wcLfatuXIXxw5GF_7Ijz_fW1w5cwtm-J'; -// PayPal client ID for sandbox. -const PAYPAL_CLIENT_ID = - 'AaQjWXEdTtWn-_qPRaeIRDLpcEAQtYZlKxdzZQ5aREMU1kh7gIl3E6YEMHZBHETx_9xZyKrY6JGK_R8I'; +const PAYPAL_CLIENT_ID = import.meta.env.REACT_APP_PAYPAL_CLIENT_ID; class App extends React.Component { constructor( diff --git a/src/services/provider/Firebase.ts b/src/services/provider/Firebase.ts index 135ff1e7..711b3359 100644 --- a/src/services/provider/Firebase.ts +++ b/src/services/provider/Firebase.ts @@ -74,6 +74,8 @@ export type IFirebaseConfiguration = { const FUNCTIONS_REGION = 'asia-northeast1'; +const PAYPAL_ENVIRONMENT = import.meta.env.REACT_APP_PAYPAL_ENVIRONMENT; + export class FirebaseProvider implements IStorage, IAuth { private db: firebase.firestore.Firestore; private auth: firebase.auth.Auth; @@ -2549,7 +2551,10 @@ export class FirebaseProvider implements IStorage, IAuth { async orderCreate(language: string): Promise> { try { const orderCreate = this.functions.httpsCallable('orderCreate'); - const orderCreateResult = await orderCreate({ language }); + const orderCreateResult = await orderCreate({ + language, + environment: PAYPAL_ENVIRONMENT, + }); const data = orderCreateResult.data; if (data.success) { return successResultOf(data.orderId); @@ -2568,6 +2573,7 @@ export class FirebaseProvider implements IStorage, IAuth { const captureOrder = this.functions.httpsCallable('captureOrder'); const captureOrderResult = await captureOrder({ orderId, + environment: PAYPAL_ENVIRONMENT, }); const data = captureOrderResult.data; if (data.success) { From 728e5d4f1f3f401f6ef41e144d338f41866bc85d Mon Sep 17 00:00:00 2001 From: Yoichiro Tanaka Date: Wed, 7 May 2025 13:58:44 +0900 Subject: [PATCH 7/7] Put the `Beta` label. --- src/components/workbench/header/Header.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/workbench/header/Header.tsx b/src/components/workbench/header/Header.tsx index 5f7a2564..7b99ed6c 100644 --- a/src/components/workbench/header/Header.tsx +++ b/src/components/workbench/header/Header.tsx @@ -167,6 +167,9 @@ export default function Header(props: HeaderProps | Readonly) { {t('Firmware Workbench')} + + {t('Beta version')} +
{props.signedIn && (