feat: license payment

This commit is contained in:
Tristan Yang
2022-09-22 11:41:28 +08:00
parent 953095e5ce
commit 6e0dfc7e3a
12 changed files with 301 additions and 119 deletions
+24
View File
@@ -3,6 +3,30 @@
const BASE_URL = process.env.REACT_APP_RELEASE
? `${location.origin}/api`
: `https://dev.voce.chat/api`;
export const LicensePriceList = [
{
title: "VoceChat Pro",
limit: 100,
pid: "price_1LkICIGGoUDRyc3jqrdPEnkY",
desc: "User Limit: 100"
},
{
title: "VoceChat Supreme",
limit: 99999,
pid: "price_1LkKqdGGoUDRyc3jnpZvXhzx",
desc: "User Limit: No Limit"
}
// {
// title: "VoceChat Enterprise",
// limit: 500,
// // pid: "price_1LkJNsGGoUDRyc3jkVNf4VPE",
// pid: "price_1LkQGpGGoUDRyc3jGTh3GYHw",
// desc: "User Limit: 500"
// }
];
export const PAYMENT_URL_PREFIX = process.env.REACT_APP_RELEASE
? `https://vera.nicegoodthings.com`
: `http://localhost:4000`;
export const CACHE_VERSION = `0.3.1`;
export const GuestRoutes = ["/", "/chat", "/chat/channel/:channel_id"];
export const ContentTypes = {
+19 -3
View File
@@ -1,5 +1,5 @@
import { createApi } from "@reduxjs/toolkit/query/react";
import BASE_URL from "../config";
import BASE_URL, { PAYMENT_URL_PREFIX } from "../config";
import { updateInfo } from "../slices/server";
import baseQuery from "./base.query";
import { RootState } from "../store";
@@ -14,7 +14,9 @@ import {
SMTPConfig,
AgoraConfig,
GithubAuthConfig,
LicenseResponse
LicenseResponse,
RenewLicense,
RenewLicenseResponse
} from "../../types/server";
const defaultExpireDuration = 7 * 24 * 60 * 60;
@@ -195,6 +197,18 @@ export const serverApi = createApi({
})
}),
getLicensePaymentUrl: builder.mutation<RenewLicenseResponse, RenewLicense>({
query: (data) => ({
url: `${PAYMENT_URL_PREFIX}/vocechat/payment/create`,
method: "POST",
body: data
})
}),
getGeneratedLicense: builder.query<{ license: string }, string>({
query: (session_id) => ({
url: `${PAYMENT_URL_PREFIX}/vocechat/licenses/${session_id}`
})
}),
checkLicense: builder.mutation<LicenseResponse, string>({
query: (license) => ({
url: "/license/check",
@@ -241,5 +255,7 @@ export const {
useCreateAdminMutation,
useUpsertLicenseMutation,
useCheckLicenseMutation,
useGetLicenseQuery
useGetLicenseQuery,
useGetLicensePaymentUrlMutation,
useLazyGetGeneratedLicenseQuery
} = serverApi;
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+5 -1
View File
@@ -4,8 +4,10 @@ import {
useGetLicenseQuery,
useUpsertLicenseMutation
} from "../../app/services/server";
import { useAppSelector } from "../../app/store";
const useLicense = () => {
const userCount = useAppSelector((store) => store.users.ids.length);
const { data: license } = useGetLicenseQuery();
const [check, { isLoading: isChecking, isSuccess: checked }] = useCheckLicenseMutation();
const [upsert, { isSuccess: upserted, isLoading: upserting }] = useUpsertLicenseMutation();
@@ -21,8 +23,10 @@ const useLicense = () => {
return false;
}
};
console.log("uuu", userCount, license);
const lUserLimit = license?.user_limit ?? 0;
return {
reachLimit: userCount >= lUserLimit,
license,
checked,
checking: isChecking,
+71
View File
@@ -0,0 +1,71 @@
import { useEffect } from "react";
import styled from "styled-components";
import { useLazyGetGeneratedLicenseQuery } from "../../app/services/server";
import useLicense from "../../common/hook/useLicense";
import Button from "../../common/component/styled/Button";
import checkIcon from "../../assets/icons/check.png";
import { useNavigate } from "react-router-dom";
const Styled = styled.section`
display: flex;
flex-direction: column;
align-items: center;
padding: 24px;
width: 512px;
background: #f3f4f6;
border-radius: 20px;
.check {
width: 120px;
height: 120px;
}
.head {
font-weight: bold;
font-size: 32px;
padding-top: 20px;
}
.desc {
font-size: 18px;
color: #999;
padding: 0 0 30px 0;
}
`;
type Props = {
sid: string;
};
const PaymentSuccess = ({ sid }: Props) => {
const navigateTo = useNavigate();
const { upsertLicense, upserting, upserted } = useLicense();
const [getGeneratedLicense, { data, isError, isLoading, isSuccess }] =
useLazyGetGeneratedLicenseQuery();
useEffect(() => {
if (sid) {
getGeneratedLicense(sid);
}
}, [sid]);
useEffect(() => {
if (isSuccess && data) {
const l = data.license;
upsertLicense(l);
}
}, [data, isSuccess]);
const handleBack = () => {
navigateTo("/");
};
return (
<Styled>
<img className="check" src={checkIcon} alt="check icon" />
<h1 className="head">Payment Success!</h1>
<p className="desc">
{upserting ? "Renewing the License, do not close the window!" : ""}
{upserted ? "Renew the License Successfully!" : ""}
{isError ? "Invalided Stripe Session ID" : ""}
</p>
<Button disabled={isLoading || upserting} className="back" onClick={handleBack}>
Back Home
</Button>
</Styled>
);
};
export default PaymentSuccess;
+18
View File
@@ -0,0 +1,18 @@
import { useParams } from "react-router-dom";
import PaymentSuccess from "./PaymentSuccess";
import StyledWrapper from "./styled";
// type Props = {
// type: "payment_success";
// };
// 该页面服务于一些第三方服务的回调,比如stripe付款成功的回调
export default function CallbackPage() {
const { type = "", payload = "" } = useParams();
if (type == "payment_success") {
return (
<StyledWrapper>
<PaymentSuccess sid={payload} />
</StyledWrapper>
);
}
return <StyledWrapper>callback page</StyledWrapper>;
}
+13
View File
@@ -0,0 +1,13 @@
import styled from "styled-components";
const StyledWrapper = styled.div`
display: flex;
width: 100vw;
height: 100vh;
justify-content: center;
align-items: center;
word-break: break-word;
line-height: 1.5;
`;
export default StyledWrapper;
+2
View File
@@ -14,6 +14,7 @@ const RegPage = lazy(() => import("./reg/Register"));
const LoginPage = lazy(() => import("./login"));
const OAuthPage = lazy(() => import("./oauth"));
const UsersPage = lazy(() => import("./users"));
const CallbackPage = lazy(() => import("./callback"));
const FavoritesPage = lazy(() => import("./favs"));
const OnboardingPage = lazy(() => import("./onboarding"));
const InvitePage = lazy(() => import("./invite"));
@@ -51,6 +52,7 @@ const PageRoutes = () => {
<Suspense fallback={<Loading fullscreen={true} />}>
<Routes>
<Route path="/guest_login" element={<GuestLogining />} />
<Route path="/cb/:type/:payload" element={<CallbackPage />} />
<Route path="/oauth/:token" element={<OAuthPage />} />
<Route
path="/login"
+27 -82
View File
@@ -1,34 +1,14 @@
import { ChangeEvent, useState, useEffect } from "react";
import { useState } from "react";
import dayjs from "dayjs";
import styled from "styled-components";
import Tippy from "@tippyjs/react";
import toast from "react-hot-toast";
import { hideAll } from "tippy.js";
// import Tippy from "@tippyjs/react";
// import toast from "react-hot-toast";
// import { hideAll } from "tippy.js";
import Textarea from "../../common/component/styled/Textarea";
import Button from "../../common/component/styled/Button";
import useLicense from "../../common/hook/useLicense";
import LicensePriceListModal from "./LicensePriceListModal";
const StyledConfirm = styled.div`
padding: 12px;
border-radius: 10px;
border: 1px solid orange;
background-color: #fff;
display: flex;
flex-direction: column;
gap: 10px;
width: 250px;
.tip {
color: orange;
font-size: 12px;
line-height: 1.5;
}
.btns {
display: flex;
width: 100%;
justify-content: flex-end;
gap: 14px;
}
`;
const StyledInfo = styled.div`
padding: 12px;
border-radius: 5px;
@@ -58,55 +38,37 @@ const StyledInfo = styled.div`
}
`;
const Styled = styled.div`
max-width: 500px;
max-width: 760px;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 15px;
> .input {
> .license {
width: 100%;
display: flex;
flex-direction: column;
/* flex-direction: column; */
align-items: flex-start;
gap: 8px;
gap: 15px;
}
`;
export default function License() {
const { license: licenseInfo, checking, upserting, upsertLicense } = useLicense();
const [license, setLicense] = useState("");
const handleUpsert = async () => {
if (!license) {
toast.error("License Empty");
hideAll();
return;
}
const success = await upsertLicense(license);
if (!success) {
toast.error("Invalid License");
hideAll();
} else {
toast.success("License Updated!");
}
const { license: licenseInfo } = useLicense();
const [modalVisible, setModalVisible] = useState(false);
const handleRenewLicense = () => {
toggleModalVisible();
};
const handleLicenseInput = (evt: ChangeEvent<HTMLTextAreaElement>) => {
setLicense(evt.target.value);
const toggleModalVisible = () => {
setModalVisible((prev) => !prev);
};
useEffect(() => {
if (licenseInfo?.base58) {
setLicense(licenseInfo.base58);
}
}, [licenseInfo]);
const disableBtn = checking || upserting || !license || license === licenseInfo?.base58;
// const disableBtn = !reachLimit;
return (
<>
<Styled>
<div className="input">
<Tippy
placement="right-start"
visible={!!licenseInfo}
content={
<div className="license">
<Textarea disabled rows={14} id="license" value={licenseInfo?.base58} />
<StyledInfo>
<div className="item">
<span className="label">Signed</span>
@@ -115,7 +77,6 @@ export default function License() {
<div className="item">
<span className="label">Domains</span>
<ul className="info">
{" "}
{licenseInfo?.domains.map((d) => {
return <li key={d}>{d}</li>;
})}
@@ -138,31 +99,15 @@ export default function License() {
</span>
</div>
</StyledInfo>
}
>
<Textarea rows={15} id="license" value={license} onChange={handleLicenseInput} />
</Tippy>
</div>
<Tippy
interactive
placement="bottom-start"
trigger="click"
content={
<StyledConfirm>
<div className="tip">Are you sure to update License?</div>
<div className="btns">
<Button onClick={() => hideAll()} className="cancel small">
Cancel
</Button>
<Button disabled={disableBtn} className="small danger" onClick={handleUpsert}>
Yes
</Button>
</div>
</StyledConfirm>
}
>
<Button disabled={disableBtn}>Update License</Button>
</Tippy>
<Button onClick={handleRenewLicense}>Renew License</Button>
</Styled>
{modalVisible && (
<LicensePriceListModal
closeModal={toggleModalVisible}
// domain={licenseInfo?.domains ? licenseInfo.domains.join("|") : ""}
/>
)}
</>
);
}
@@ -0,0 +1,76 @@
import { FC, useState } from "react";
import toast from "react-hot-toast";
import Modal from "../../common/component/Modal";
import StyledModal from "../../common/component/styled/Modal";
import Button from "../../common/component/styled/Button";
import StyledRadio from "../../common/component/styled/Radio";
import { useGetLicensePaymentUrlMutation } from "../../app/services/server";
import { LicensePriceList } from "../../app/config";
// import { LicenseMetadata, RenewLicense } from "../../types/server";
interface Props {
closeModal: () => void;
// domain: string;
}
const LicensePriceListModal: FC<Props> = ({ closeModal }) => {
const [getUrl, { isLoading, isSuccess }] = useGetLicensePaymentUrlMutation();
const [selectPrice, setSelectPrice] = useState(
`${LicensePriceList[0].pid}|${LicensePriceList[0].limit}`
);
const handleRenew = async () => {
const [priceId, user_limit] = selectPrice.split("|");
const resp = await getUrl({
priceId,
metadata: {
user_limit: Number(user_limit),
expire: "2035-01-01",
// 本地,则*通配符
domain: location.hostname.startsWith("localhost") ? "*" : location.hostname
},
cancel_url: location.href,
success_url: `${location.origin}/#/cb/payment_success`
});
if ("error" in resp) {
toast.error("Payment link initialized failed!");
return;
}
console.log("aaaa", resp.data);
// todo
location.href = resp.data.session_url;
};
const handlePriceSelect = (price: string) => {
setSelectPrice(price);
};
return (
<Modal id="modal-modal">
<StyledModal
title="Renew License"
description="Please select the price"
buttons={
<>
<Button onClick={closeModal} className="ghost">
Cancel
</Button>
<Button disabled={isLoading || isSuccess} onClick={handleRenew} className="danger">
{isLoading ? "Initialize Payment Url" : isSuccess ? "Redirecting" : "Renew"}
</Button>
</>
}
>
<StyledRadio
options={LicensePriceList.map(({ title, desc }) => `${title} [${desc}]`)}
values={LicensePriceList.map(({ pid, limit }) => `${pid}|${limit}`)}
value={selectPrice}
onChange={(v) => {
console.log("wtff", v);
handlePriceSelect(v);
}}
/>
</StyledModal>
</Modal>
);
};
export default LicensePriceListModal;
-1
View File
@@ -10,7 +10,6 @@ import SaveTip from "../../common/component/SaveTip";
import StyledRadio from "../../common/component/styled/Radio";
import { useAppSelector } from "../../app/store";
import { LoginConfig, WhoCanSignUp } from "../../types/server";
import Toggle from "../../common/component/styled/Toggle";
import useConfig from "../../common/hook/useConfig";
const StyledWrapper = styled.div`
+14
View File
@@ -67,6 +67,20 @@ export interface LicenseResponse {
base58: string;
user_limit: number;
}
export interface LicenseMetadata {
expire: string;
user_limit: number;
domain: string | string[];
}
export interface RenewLicense {
priceId: string;
metadata: LicenseMetadata;
cancel_url: string;
success_url: string;
}
export interface RenewLicenseResponse {
session_url: string;
}
export interface CreateAdminDTO extends Pick<User, "email" | "name" | "gender"> {
password: string;
}