mirror of
https://github.com/aykhans/slash-e.git
synced 2025-04-20 14:01:24 +00:00
feat: implement subscription setting
This commit is contained in:
parent
46fa546a7d
commit
24fe368974
@ -2,52 +2,32 @@ import { useColorScheme } from "@mui/joy";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import DemoBanner from "./components/DemoBanner";
|
import DemoBanner from "./components/DemoBanner";
|
||||||
import { workspaceServiceClient } from "./grpcweb";
|
|
||||||
import { workspaceService } from "./services";
|
|
||||||
import useUserStore from "./stores/v1/user";
|
import useUserStore from "./stores/v1/user";
|
||||||
import { WorkspaceSetting } from "./types/proto/api/v2/workspace_service";
|
import useWorkspaceStore from "./stores/v1/workspace";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { mode: colorScheme } = useColorScheme();
|
const { mode: colorScheme } = useColorScheme();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const [workspaceSetting, setWorkspaceSetting] = useState<WorkspaceSetting>(WorkspaceSetting.fromPartial({}));
|
const workspaceStore = useWorkspaceStore();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initialState = async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await workspaceService.initialState();
|
await Promise.all([workspaceStore.fetchWorkspaceProfile(), workspaceStore.fetchWorkspaceSetting(), userStore.fetchCurrentUser()]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// do nothing
|
// do nth
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const { setting } = await workspaceServiceClient.getWorkspaceSetting({});
|
|
||||||
if (setting) {
|
|
||||||
setWorkspaceSetting(setting);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await userStore.fetchCurrentUser();
|
|
||||||
} catch (error) {
|
|
||||||
// do nothing.
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
})();
|
||||||
|
|
||||||
initialState();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const styleEl = document.createElement("style");
|
const styleEl = document.createElement("style");
|
||||||
styleEl.innerHTML = workspaceSetting.customStyle;
|
styleEl.innerHTML = workspaceStore.setting.customStyle;
|
||||||
styleEl.setAttribute("type", "text/css");
|
styleEl.setAttribute("type", "text/css");
|
||||||
document.body.insertAdjacentElement("beforeend", styleEl);
|
document.body.insertAdjacentElement("beforeend", styleEl);
|
||||||
}, [workspaceSetting.customStyle]);
|
}, [workspaceStore.setting.customStyle]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
import { workspaceService } from "../services";
|
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
|
||||||
const DemoBanner: React.FC = () => {
|
const DemoBanner: React.FC = () => {
|
||||||
const {
|
const workspaceStore = useWorkspaceStore();
|
||||||
workspaceProfile: { mode },
|
const shouldShow = workspaceStore.profile.mode === "demo";
|
||||||
} = workspaceService.getState();
|
|
||||||
const shouldShow = mode === "demo";
|
|
||||||
|
|
||||||
if (!shouldShow) return null;
|
if (!shouldShow) return null;
|
||||||
|
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { Avatar } from "@mui/joy";
|
import { Avatar } from "@mui/joy";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
|
import { PlanType } from "@/types/proto/api/v2/subscription_service";
|
||||||
import * as api from "../helpers/api";
|
import * as api from "../helpers/api";
|
||||||
import useUserStore from "../stores/v1/user";
|
import useUserStore from "../stores/v1/user";
|
||||||
import AboutDialog from "./AboutDialog";
|
import AboutDialog from "./AboutDialog";
|
||||||
@ -8,8 +10,10 @@ import Icon from "./Icon";
|
|||||||
import Dropdown from "./common/Dropdown";
|
import Dropdown from "./common/Dropdown";
|
||||||
|
|
||||||
const Header: React.FC = () => {
|
const Header: React.FC = () => {
|
||||||
|
const workspaceStore = useWorkspaceStore();
|
||||||
const currentUser = useUserStore().getCurrentUser();
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
|
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
|
||||||
|
const profile = workspaceStore.profile;
|
||||||
const isAdmin = currentUser.role === "ADMIN";
|
const isAdmin = currentUser.role === "ADMIN";
|
||||||
|
|
||||||
const handleSignOutButtonClick = async () => {
|
const handleSignOutButtonClick = async () => {
|
||||||
@ -23,9 +27,14 @@ const Header: React.FC = () => {
|
|||||||
<div className="w-full max-w-6xl mx-auto px-3 md:px-12 py-5 flex flex-row justify-between items-center">
|
<div className="w-full max-w-6xl mx-auto px-3 md:px-12 py-5 flex flex-row justify-between items-center">
|
||||||
<div className="flex flex-row justify-start items-center shrink mr-2">
|
<div className="flex flex-row justify-start items-center shrink mr-2">
|
||||||
<Link to="/" className="text-lg cursor-pointer flex flex-row justify-start items-center dark:text-gray-400">
|
<Link to="/" className="text-lg cursor-pointer flex flex-row justify-start items-center dark:text-gray-400">
|
||||||
<img src="/logo.png" className="w-8 h-auto mr-2 -mt-0.5 dark:opacity-80" alt="" />
|
<img id="logo-img" src="/logo.png" className="w-8 h-auto mr-2 -mt-0.5 dark:opacity-80" alt="" />
|
||||||
Slash
|
Slash
|
||||||
</Link>
|
</Link>
|
||||||
|
{profile.plan === PlanType.PRO && (
|
||||||
|
<span className="ml-1 text-xs px-1.5 leading-5 border rounded-full bg-blue-600 border-blue-700 text-white shadow dark:opacity-70">
|
||||||
|
PRO
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
33
frontend/web/src/components/SubscriptionFAQ.tsx
Normal file
33
frontend/web/src/components/SubscriptionFAQ.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import Accordion from "@mui/joy/Accordion";
|
||||||
|
import AccordionDetails from "@mui/joy/AccordionDetails";
|
||||||
|
import AccordionGroup from "@mui/joy/AccordionGroup";
|
||||||
|
import AccordionSummary from "@mui/joy/AccordionSummary";
|
||||||
|
|
||||||
|
const SubscriptionFAQ = () => {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col justify-center items-center">
|
||||||
|
<h2 className="text-2xl font-semibold mb-8 dark:text-gray-400">Frequently Asked Questions</h2>
|
||||||
|
<AccordionGroup className="w-full max-w-2xl">
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary>Can I use the Free plan in my team?</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
Of course you can. In the free plan, you can invite up to 5 members to your team. If you need more, you can upgrade to the Pro
|
||||||
|
plan.
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary>How many devices can the license key be used on?</AccordionSummary>
|
||||||
|
<AccordionDetails>{`It's unlimited for now, but please don't abuse it.`}</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary>{`Can I get a refund if Slash doesn't meet my needs?`}</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
Yes, absolutely! You can send a email to me at `stevenlgtm@gmail.com`. I will refund you as soon as possible.
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
</AccordionGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubscriptionFAQ;
|
@ -62,6 +62,20 @@ const PreferenceSection: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<div className="w-full flex flex-col justify-start items-start gap-y-2">
|
<div className="w-full flex flex-col justify-start items-start gap-y-2">
|
||||||
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Preference</p>
|
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Preference</p>
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<div className="flex flex-row justify-start items-center gap-x-1">
|
||||||
|
<span className="dark:text-gray-400">Color Theme</span>
|
||||||
|
</div>
|
||||||
|
<Select defaultValue={colorTheme} onChange={(_, value) => handleSelectColorTheme(value as UserSetting_ColorTheme)}>
|
||||||
|
{colorThemeOptions.map((option) => {
|
||||||
|
return (
|
||||||
|
<Option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</Option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
{releaseGuard() && (
|
{releaseGuard() && (
|
||||||
<div className="w-full flex flex-row justify-between items-center">
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
<div className="flex flex-row justify-start items-center gap-x-1">
|
<div className="flex flex-row justify-start items-center gap-x-1">
|
||||||
@ -79,21 +93,6 @@ const PreferenceSection: React.FC = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="w-full flex flex-row justify-between items-center">
|
|
||||||
<div className="flex flex-row justify-start items-center gap-x-1">
|
|
||||||
<span className="dark:text-gray-400">Color Theme</span>
|
|
||||||
<BetaBadge />
|
|
||||||
</div>
|
|
||||||
<Select defaultValue={colorTheme} onChange={(_, value) => handleSelectColorTheme(value as UserSetting_ColorTheme)}>
|
|
||||||
{colorThemeOptions.map((option) => {
|
|
||||||
return (
|
|
||||||
<Option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</Option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -1,25 +1,17 @@
|
|||||||
import { Button, Checkbox, Textarea } from "@mui/joy";
|
import { Button, Checkbox, Textarea } from "@mui/joy";
|
||||||
import { isEqual } from "lodash-es";
|
import { isEqual } from "lodash-es";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { workspaceServiceClient } from "@/grpcweb";
|
import { workspaceServiceClient } from "@/grpcweb";
|
||||||
import { releaseGuard } from "@/helpers/utils";
|
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
import { WorkspaceSetting } from "@/types/proto/api/v2/workspace_service";
|
import { WorkspaceSetting } from "@/types/proto/api/v2/workspace_service";
|
||||||
|
|
||||||
const WorkspaceSection: React.FC = () => {
|
const WorkspaceSection: React.FC = () => {
|
||||||
const [workspaceSetting, setWorkspaceSetting] = useState<WorkspaceSetting>(WorkspaceSetting.fromPartial({}));
|
const workspaceStore = useWorkspaceStore();
|
||||||
const originalWorkspaceSetting = useRef<WorkspaceSetting>(WorkspaceSetting.fromPartial({}));
|
const [workspaceSetting, setWorkspaceSetting] = useState<WorkspaceSetting>(workspaceStore.setting);
|
||||||
|
const originalWorkspaceSetting = useRef<WorkspaceSetting>(workspaceStore.setting);
|
||||||
const allowSave = !isEqual(originalWorkspaceSetting.current, workspaceSetting);
|
const allowSave = !isEqual(originalWorkspaceSetting.current, workspaceSetting);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
workspaceServiceClient.getWorkspaceSetting({}).then(({ setting }) => {
|
|
||||||
if (setting) {
|
|
||||||
setWorkspaceSetting(setting);
|
|
||||||
originalWorkspaceSetting.current = setting;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleEnableSignUpChange = async (value: boolean) => {
|
const handleEnableSignUpChange = async (value: boolean) => {
|
||||||
setWorkspaceSetting({
|
setWorkspaceSetting({
|
||||||
...workspaceSetting,
|
...workspaceSetting,
|
||||||
@ -65,7 +57,6 @@ const WorkspaceSection: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col justify-start items-start space-y-4">
|
<div className="w-full flex flex-col justify-start items-start space-y-4">
|
||||||
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Workspace settings</p>
|
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Workspace settings</p>
|
||||||
{releaseGuard() && (
|
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
<div className="w-full flex flex-col justify-start items-start">
|
||||||
<p className="mt-2 dark:text-gray-400">Custom style</p>
|
<p className="mt-2 dark:text-gray-400">Custom style</p>
|
||||||
<Textarea
|
<Textarea
|
||||||
@ -76,7 +67,6 @@ const WorkspaceSection: React.FC = () => {
|
|||||||
onChange={(event) => handleCustomStyleChange(event.target.value)}
|
onChange={(event) => handleCustomStyleChange(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="w-full flex flex-col justify-start items-start">
|
<div className="w-full flex flex-col justify-start items-start">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Enable user signup"
|
label="Enable user signup"
|
||||||
|
@ -1,32 +0,0 @@
|
|||||||
import { Alert } from "@mui/joy";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import MemberSection from "../components/setting/MemberSection";
|
|
||||||
import WorkspaceSection from "../components/setting/WorkspaceSection";
|
|
||||||
import useUserStore from "../stores/v1/user";
|
|
||||||
|
|
||||||
const Setting: React.FC = () => {
|
|
||||||
const currentUser = useUserStore().getCurrentUser();
|
|
||||||
const isAdmin = currentUser.role === "ADMIN";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAdmin) {
|
|
||||||
window.location.href = "/";
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!isAdmin) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 py-6 flex flex-col justify-start items-start gap-y-12">
|
|
||||||
<Alert variant="soft" color="warning">
|
|
||||||
You can see the settings items below because you are an Admin.
|
|
||||||
</Alert>
|
|
||||||
<MemberSection />
|
|
||||||
<WorkspaceSection />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Setting;
|
|
@ -3,7 +3,7 @@ import React, { FormEvent, useEffect, useState } from "react";
|
|||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { workspaceService } from "@/services";
|
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
import * as api from "../helpers/api";
|
import * as api from "../helpers/api";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import useUserStore from "../stores/v1/user";
|
import useUserStore from "../stores/v1/user";
|
||||||
@ -12,9 +12,7 @@ const SignIn: React.FC = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const {
|
const workspaceStore = useWorkspaceStore();
|
||||||
workspaceProfile: { enableSignup, mode },
|
|
||||||
} = workspaceService.getState();
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const actionBtnLoadingState = useLoading(false);
|
const actionBtnLoadingState = useLoading(false);
|
||||||
@ -27,7 +25,7 @@ const SignIn: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "demo") {
|
if (workspaceStore.profile.mode === "demo") {
|
||||||
setEmail("steven@yourselfhosted.com");
|
setEmail("steven@yourselfhosted.com");
|
||||||
setPassword("secret");
|
setPassword("secret");
|
||||||
}
|
}
|
||||||
@ -72,7 +70,7 @@ const SignIn: React.FC = () => {
|
|||||||
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center">
|
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center">
|
||||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
||||||
<div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4">
|
<div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4">
|
||||||
<img src="/logo.png" className="w-12 h-auto mr-2 -mt-1" alt="logo" />
|
<img id="logo-img" src="/logo.png" className="w-12 h-auto mr-2 -mt-1" alt="logo" />
|
||||||
<span className="text-3xl opacity-80 dark:text-gray-500">Slash</span>
|
<span className="text-3xl opacity-80 dark:text-gray-500">Slash</span>
|
||||||
</div>
|
</div>
|
||||||
<form className="w-full mt-6" onSubmit={handleSigninBtnClick}>
|
<form className="w-full mt-6" onSubmit={handleSigninBtnClick}>
|
||||||
@ -105,7 +103,7 @@ const SignIn: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{enableSignup && (
|
{workspaceStore.setting.enableSignup && (
|
||||||
<p className="w-full mt-4 text-sm">
|
<p className="w-full mt-4 text-sm">
|
||||||
<span className="dark:text-gray-500">{"Don't have an account yet?"}</span>
|
<span className="dark:text-gray-500">{"Don't have an account yet?"}</span>
|
||||||
<Link to="/auth/signup" className="cursor-pointer ml-2 text-blue-600 hover:underline">
|
<Link to="/auth/signup" className="cursor-pointer ml-2 text-blue-600 hover:underline">
|
||||||
|
@ -3,18 +3,16 @@ import React, { FormEvent, useEffect, useState } from "react";
|
|||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
import * as api from "../helpers/api";
|
import * as api from "../helpers/api";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { workspaceService } from "../services";
|
|
||||||
import useUserStore from "../stores/v1/user";
|
import useUserStore from "../stores/v1/user";
|
||||||
|
|
||||||
const SignUp: React.FC = () => {
|
const SignUp: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const {
|
const workspaceStore = useWorkspaceStore();
|
||||||
workspaceProfile: { enableSignup },
|
|
||||||
} = workspaceService.getState();
|
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [nickname, setNickname] = useState("");
|
const [nickname, setNickname] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@ -28,7 +26,7 @@ const SignUp: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!enableSignup) {
|
if (!workspaceStore.setting.enableSignup) {
|
||||||
return navigate("/auth", {
|
return navigate("/auth", {
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
@ -79,7 +77,7 @@ const SignUp: React.FC = () => {
|
|||||||
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center">
|
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-start items-center">
|
||||||
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
<div className="w-full py-4 grow flex flex-col justify-center items-center">
|
||||||
<div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4">
|
<div className="flex flex-row justify-start items-center w-auto mx-auto gap-y-2 mb-4">
|
||||||
<img src="/logo.png" className="w-12 h-auto mr-2 -mt-1" alt="logo" />
|
<img id="logo-img" src="/logo.png" className="w-12 h-auto mr-2 -mt-1" alt="logo" />
|
||||||
<span className="text-3xl opacity-80 dark:text-gray-500">Slash</span>
|
<span className="text-3xl opacity-80 dark:text-gray-500">Slash</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="w-full text-2xl mt-6 dark:text-gray-500">Create your account</p>
|
<p className="w-full text-2xl mt-6 dark:text-gray-500">Create your account</p>
|
||||||
|
188
frontend/web/src/pages/SubscriptionSetting.tsx
Normal file
188
frontend/web/src/pages/SubscriptionSetting.tsx
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import { Button, Divider, Link, Textarea } from "@mui/joy";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import Icon from "@/components/Icon";
|
||||||
|
import SubscriptionFAQ from "@/components/SubscriptionFAQ";
|
||||||
|
import { subscriptionServiceClient } from "@/grpcweb";
|
||||||
|
import { stringifyPlanType } from "@/stores/v1/subscription";
|
||||||
|
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
|
import { PlanType } from "@/types/proto/api/v2/subscription_service";
|
||||||
|
import useUserStore from "../stores/v1/user";
|
||||||
|
|
||||||
|
const SubscriptionSetting: React.FC = () => {
|
||||||
|
const workspaceStore = useWorkspaceStore();
|
||||||
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
|
const [licenseKey, setLicenseKey] = useState<string>("");
|
||||||
|
const isAdmin = currentUser.role === "ADMIN";
|
||||||
|
const profile = workspaceStore.profile;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin) {
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateLicenseKey = async () => {
|
||||||
|
try {
|
||||||
|
const { subscription } = await subscriptionServiceClient.updateSubscription({
|
||||||
|
licenseKey,
|
||||||
|
});
|
||||||
|
if (subscription) {
|
||||||
|
toast.success(`Welcome to Slash-${stringifyPlanType(subscription.plan)}🎉`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.details);
|
||||||
|
}
|
||||||
|
setLicenseKey("");
|
||||||
|
await workspaceStore.fetchWorkspaceProfile();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 pt-8 pb-24 flex flex-col justify-start items-start gap-y-12">
|
||||||
|
<div className="w-full">
|
||||||
|
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Subscription</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="text-gray-500 mr-2">Current plan:</span>
|
||||||
|
<span className="text-2xl mr-4 dark:text-gray-400">{stringifyPlanType(profile.plan)}</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
className="w-full mt-2"
|
||||||
|
minRows={2}
|
||||||
|
maxRows={2}
|
||||||
|
placeholder="Enter your license key here - write only"
|
||||||
|
value={licenseKey}
|
||||||
|
onChange={(event) => setLicenseKey(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="w-full flex justify-between items-center mt-4">
|
||||||
|
<div>
|
||||||
|
{profile.plan === PlanType.FREE && (
|
||||||
|
<Link href="https://yourselfhosted.lemonsqueezy.com/checkout?cart=df958121-81ad-4815-8b28-3f2d8e9c8e51" target="_blank">
|
||||||
|
Buy a license key
|
||||||
|
<Icon.ExternalLink className="w-4 h-auto ml-1" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button disabled={licenseKey === ""} onClick={handleUpdateLicenseKey}>
|
||||||
|
Upload license
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
<section className="w-full pb-8 dark:bg-zinc-900 flex items-center justify-center">
|
||||||
|
<div className="w-full px-6">
|
||||||
|
<div className="w-full grid grid-cols-1 gap-12 mt-8 md:grid-cols-3">
|
||||||
|
<div className="flex flex-col p-6 bg-white dark:bg-zinc-800 shadow-lg rounded-lg justify-between border border-gray-300 dark:border-zinc-700">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-bold text-center dark:text-gray-300">Free</h3>
|
||||||
|
<div className="mt-4 text-center text-zinc-600 dark:text-zinc-400">
|
||||||
|
<span className="text-4xl font-bold">$0</span>/ month
|
||||||
|
</div>
|
||||||
|
<ul className="mt-4 space-y-3">
|
||||||
|
<li className="flex items-center dark:text-gray-300">
|
||||||
|
<Icon.CheckCircle2 className="w-5 h-auto text-green-600 mr-1 shrink-0" />
|
||||||
|
Unlimited shortcuts
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center dark:text-gray-300">
|
||||||
|
<Icon.CheckCircle2 className="w-5 h-auto text-green-600 mr-1 shrink-0" />
|
||||||
|
Basic analytics
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center dark:text-gray-300">
|
||||||
|
<Icon.CheckCircle2 className="w-5 h-auto text-green-600 mr-1 shrink-0" />
|
||||||
|
Browser extension
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center dark:text-gray-300">
|
||||||
|
<Icon.CheckCircle2 className="w-5 h-auto text-green-600 mr-1 shrink-0" />
|
||||||
|
Full API access
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center dark:text-gray-300">
|
||||||
|
<Icon.AlertCircle className="w-5 h-auto text-gray-400 mr-1 shrink-0" />
|
||||||
|
Up to 5 members
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex flex-col p-6 bg-white dark:bg-zinc-800 shadow-lg rounded-lg dark:bg-zinc-850 justify-between border border-purple-500">
|
||||||
|
<div className="px-3 py-1 text-sm text-white bg-gradient-to-r from-pink-500 to-purple-500 rounded-full inline-block absolute top-0 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
|
||||||
|
Popular
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-bold text-center dark:text-gray-300">Pro</h3>
|
||||||
|
<div className="mt-4 text-center text-zinc-600 dark:text-zinc-400">
|
||||||
|
<span className="text-4xl font-bold">$4</span>/ month
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 font-medium dark:text-gray-300">Everything in Free, and</p>
|
||||||
|
<ul className="mt-4 space-y-3">
|
||||||
|
<li className="flex items-center dark:text-gray-300">
|
||||||
|
<Icon.CheckCircle2 className="w-5 h-auto text-green-600 mr-1 shrink-0" />
|
||||||
|
Unlimited members
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center dark:text-gray-300">
|
||||||
|
<Icon.CheckCircle2 className="w-5 h-auto text-green-600 mr-1 shrink-0" />
|
||||||
|
Advanced analytics
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center dark:text-gray-300">
|
||||||
|
<Icon.CheckCircle2 className="w-5 h-auto text-green-600 mr-1 shrink-0" />
|
||||||
|
Custom styles
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center dark:text-gray-300">
|
||||||
|
<Icon.CheckCircle2 className="w-5 h-auto text-green-600 mr-1 shrink-0" />
|
||||||
|
High-priority in roadmap
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6">
|
||||||
|
<Link
|
||||||
|
className="w-full"
|
||||||
|
underline="none"
|
||||||
|
href="https://yourselfhosted.lemonsqueezy.com/checkout?cart=df958121-81ad-4815-8b28-3f2d8e9c8e51"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Button className="w-full bg-gradient-to-r from-pink-500 to-purple-500 shadow hover:opacity-80">Get Started</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col p-6 bg-white dark:bg-zinc-800 shadow-lg rounded-lg dark:bg-zinc-850 justify-between border border-gray-300 dark:border-zinc-700">
|
||||||
|
<div>
|
||||||
|
<span className="block text-2xl text-center dark:text-gray-200 opacity-80">More</span>
|
||||||
|
<div className="mt-4 text-center text-zinc-800 dark:text-zinc-400">
|
||||||
|
<span className="text-4xl font-bold">Custom</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 font-medium dark:text-gray-300">Everything in Pro, and</p>
|
||||||
|
<ul className="mt-4 space-y-3">
|
||||||
|
<li className="flex items-center dark:text-gray-300">
|
||||||
|
<Icon.Smile className="w-5 h-auto text-green-600 mr-1 shrink-0" />
|
||||||
|
Custom branding
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center dark:text-gray-300">
|
||||||
|
<Icon.Shield className="w-5 h-auto text-green-600 mr-1 shrink-0" />
|
||||||
|
Single Sign-On(SSO)
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center dark:text-gray-300">
|
||||||
|
<Icon.HeartHandshake className="w-5 h-auto text-green-600 mr-1 shrink-0" />
|
||||||
|
Dedicated support
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center dark:text-gray-300">
|
||||||
|
<Icon.Sparkles className="w-5 h-auto text-green-600 mr-1 shrink-0" />
|
||||||
|
More Coming Soon
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6">
|
||||||
|
<Link className="w-full" underline="none" href="mailto:stevenlgtm@gmail.com" target="_blank">
|
||||||
|
<Button className="w-full">Contact us</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<SubscriptionFAQ />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubscriptionSetting;
|
50
frontend/web/src/pages/WorkspaceSetting.tsx
Normal file
50
frontend/web/src/pages/WorkspaceSetting.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Alert, Button } from "@mui/joy";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import Icon from "@/components/Icon";
|
||||||
|
import { stringifyPlanType } from "@/stores/v1/subscription";
|
||||||
|
import useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
|
import MemberSection from "../components/setting/MemberSection";
|
||||||
|
import WorkspaceSection from "../components/setting/WorkspaceSection";
|
||||||
|
import useUserStore from "../stores/v1/user";
|
||||||
|
|
||||||
|
const WorkspaceSetting: React.FC = () => {
|
||||||
|
const workspaceStore = useWorkspaceStore();
|
||||||
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
|
const isAdmin = currentUser.role === "ADMIN";
|
||||||
|
const profile = workspaceStore.profile;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin) {
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl w-full px-3 md:px-12 py-6 flex flex-col justify-start items-start gap-y-12">
|
||||||
|
<Alert variant="soft" color="warning" startDecorator={<Icon.Info />}>
|
||||||
|
You can see the settings items below because you are an Admin.
|
||||||
|
</Alert>
|
||||||
|
<div className="w-full flex flex-col">
|
||||||
|
<p className="text-base font-semibold leading-6 text-gray-900 dark:text-gray-500">Subscription</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="text-gray-500 mr-2">Current plan:</span>
|
||||||
|
<span className="text-2xl mr-4 dark:text-gray-400">{stringifyPlanType(profile.plan)}</span>
|
||||||
|
<Link to="/setting/subscription">
|
||||||
|
<Button size="sm" variant="outlined" startDecorator={<Icon.Settings className="w-4 h-auto" />}>
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MemberSection />
|
||||||
|
<WorkspaceSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkspaceSetting;
|
@ -1,12 +1,13 @@
|
|||||||
import { createBrowserRouter } from "react-router-dom";
|
import { createBrowserRouter } from "react-router-dom";
|
||||||
|
import SignIn from "@/pages/SignIn";
|
||||||
|
import SignUp from "@/pages/SignUp";
|
||||||
|
import SubscriptionSetting from "@/pages/SubscriptionSetting";
|
||||||
|
import UserSetting from "@/pages/UserSetting";
|
||||||
|
import WorkspaceSetting from "@/pages/WorkspaceSetting";
|
||||||
import App from "../App";
|
import App from "../App";
|
||||||
import Root from "../layouts/Root";
|
import Root from "../layouts/Root";
|
||||||
import Home from "../pages/Home";
|
import Home from "../pages/Home";
|
||||||
import Setting from "../pages/Setting";
|
|
||||||
import ShortcutDetail from "../pages/ShortcutDetail";
|
import ShortcutDetail from "../pages/ShortcutDetail";
|
||||||
import SignIn from "../pages/SignIn";
|
|
||||||
import SignUp from "../pages/SignUp";
|
|
||||||
import UserSetting from "../pages/UserSetting";
|
|
||||||
import { shortcutService } from "../services";
|
import { shortcutService } from "../services";
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
@ -44,7 +45,11 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/setting/workspace",
|
path: "/setting/workspace",
|
||||||
element: <Setting />,
|
element: <WorkspaceSetting />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/setting/subscription",
|
||||||
|
element: <SubscriptionSetting />,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import shortcutService from "./shortcutService";
|
import shortcutService from "./shortcutService";
|
||||||
import workspaceService from "./workspaceService";
|
|
||||||
|
|
||||||
export { workspaceService, shortcutService };
|
export { shortcutService };
|
||||||
|
@ -1,21 +0,0 @@
|
|||||||
import { workspaceServiceClient } from "@/grpcweb";
|
|
||||||
import { WorkspaceProfile } from "@/types/proto/api/v2/workspace_service";
|
|
||||||
import store from "../stores";
|
|
||||||
import { setWorkspaceState } from "../stores/modules/workspace";
|
|
||||||
|
|
||||||
const workspaceService = {
|
|
||||||
getState: () => {
|
|
||||||
return store.getState().workspace;
|
|
||||||
},
|
|
||||||
|
|
||||||
initialState: async () => {
|
|
||||||
try {
|
|
||||||
const workspaceProfile = (await workspaceServiceClient.getWorkspaceProfile({})).profile as WorkspaceProfile;
|
|
||||||
store.dispatch(setWorkspaceState({ workspaceProfile }));
|
|
||||||
} catch (error) {
|
|
||||||
// do nth
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default workspaceService;
|
|
@ -1,11 +1,9 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit";
|
import { configureStore } from "@reduxjs/toolkit";
|
||||||
import { TypedUseSelectorHook, useSelector } from "react-redux";
|
import { TypedUseSelectorHook, useSelector } from "react-redux";
|
||||||
import shortcutReducer from "./modules/shortcut";
|
import shortcutReducer from "./modules/shortcut";
|
||||||
import workspaceReducer from "./modules/workspace";
|
|
||||||
|
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
workspace: workspaceReducer,
|
|
||||||
shortcut: shortcutReducer,
|
shortcut: shortcutReducer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -1,20 +0,0 @@
|
|||||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
|
||||||
import { WorkspaceProfile } from "@/types/proto/api/v2/workspace_service";
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
workspaceProfile: WorkspaceProfile;
|
|
||||||
};
|
|
||||||
|
|
||||||
const workspaceSlice = createSlice({
|
|
||||||
name: "workspace",
|
|
||||||
initialState: {} as State,
|
|
||||||
reducers: {
|
|
||||||
setWorkspaceState: (_, action: PayloadAction<State>) => {
|
|
||||||
return action.payload;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const { setWorkspaceState } = workspaceSlice.actions;
|
|
||||||
|
|
||||||
export default workspaceSlice.reducer;
|
|
11
frontend/web/src/stores/v1/subscription.ts
Normal file
11
frontend/web/src/stores/v1/subscription.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { PlanType } from "@/types/proto/api/v2/subscription_service";
|
||||||
|
|
||||||
|
export const stringifyPlanType = (planType: PlanType) => {
|
||||||
|
if (planType === PlanType.FREE) {
|
||||||
|
return "Free";
|
||||||
|
} else if (planType === PlanType.PRO) {
|
||||||
|
return "Pro";
|
||||||
|
} else {
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
};
|
29
frontend/web/src/stores/v1/workspace.ts
Normal file
29
frontend/web/src/stores/v1/workspace.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { workspaceServiceClient } from "@/grpcweb";
|
||||||
|
import { WorkspaceProfile, WorkspaceSetting } from "@/types/proto/api/v2/workspace_service";
|
||||||
|
|
||||||
|
interface WorkspaceState {
|
||||||
|
profile: WorkspaceProfile;
|
||||||
|
setting: WorkspaceSetting;
|
||||||
|
|
||||||
|
// Workspace related actions.
|
||||||
|
fetchWorkspaceProfile: () => Promise<WorkspaceProfile>;
|
||||||
|
fetchWorkspaceSetting: () => Promise<WorkspaceSetting>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useWorkspaceStore = create<WorkspaceState>()((set) => ({
|
||||||
|
profile: WorkspaceProfile.fromPartial({}),
|
||||||
|
setting: WorkspaceSetting.fromPartial({}),
|
||||||
|
fetchWorkspaceProfile: async () => {
|
||||||
|
const workspaceProfile = (await workspaceServiceClient.getWorkspaceProfile({})).profile as WorkspaceProfile;
|
||||||
|
set({ profile: workspaceProfile });
|
||||||
|
return workspaceProfile;
|
||||||
|
},
|
||||||
|
fetchWorkspaceSetting: async () => {
|
||||||
|
const workspaceSetting = (await workspaceServiceClient.getWorkspaceSetting({})).setting as WorkspaceSetting;
|
||||||
|
set({ setting: workspaceSetting });
|
||||||
|
return workspaceSetting;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useWorkspaceStore;
|
Loading…
x
Reference in New Issue
Block a user