├── .github
└── FUNDING.yml
├── pages
├── _error.tsx
├── faq
│ └── index.tsx
├── index.tsx
├── sitemap
│ └── index.tsx
├── privacy-policy
│ └── index.tsx
├── 404.tsx
├── terms-and-conditions
│ └── index.tsx
├── _document.tsx
├── _app.tsx
└── api
│ └── system-status.ts
├── public
├── favicon.ico
├── favicon-16x16.png
├── favicon-32x32.png
├── apple-touch-icon.png
├── assets
│ ├── app
│ │ ├── bento-1.png
│ │ ├── bento-2.png
│ │ ├── bento-3.png
│ │ ├── bento-4.png
│ │ ├── bento-5.png
│ │ ├── bg-hero.png
│ │ └── overview.png
│ ├── system-status
│ │ ├── down.png
│ │ ├── up.png
│ │ └── paused.png
│ ├── get-it-on
│ │ ├── ios-badge.png
│ │ └── android-badge.png
│ └── marketing
│ │ ├── bmc-button.png
│ │ └── marketing-github.png
├── robots.txt
├── android-chrome-192x192.png
├── android-chrome-512x512.png
└── sitemap.xml
├── postcss.config.js
├── .prettierrc
├── next.config.js
├── components
├── shared
│ ├── DocumentSection
│ │ └── index.tsx
│ ├── Skeleton
│ │ └── index.tsx
│ ├── Toast
│ │ ├── toaster.tsx
│ │ ├── use-toast.ts
│ │ └── index.tsx
│ ├── Tooltip
│ │ └── index.tsx
│ ├── SystemStatus
│ │ └── index.tsx
│ ├── Accordion
│ │ └── index.tsx
│ ├── Button
│ │ └── index.tsx
│ ├── CommandMenu
│ │ └── index.tsx
│ ├── BentoBox
│ │ └── index.tsx
│ ├── Dialog
│ │ └── index.tsx
│ ├── Sheet
│ │ └── index.tsx
│ └── Command
│ │ └── index.tsx
├── layout
│ ├── DesktopHeader.tsx
│ ├── MainLayout.tsx
│ ├── MobileHeader.tsx
│ ├── Header.tsx
│ ├── SEO.tsx
│ └── Footer.tsx
└── pages
│ ├── Error
│ └── index.tsx
│ ├── ErrorBoundary
│ └── index.tsx
│ ├── Sitemap.tsx
│ ├── TermsAndConditions.tsx
│ ├── PrivacyPolicy.tsx
│ ├── Home.tsx
│ └── FAQ.tsx
├── components.json
├── .gitignore
├── lib
├── enum.ts
├── api
│ ├── index.ts
│ └── queries.tsx
├── SpecialUtils.tsx
├── utils.ts
├── config.ts
├── hooks.tsx
└── data.ts
├── tsconfig.json
├── .eslintrc.json
├── styles
└── globals.css
├── package.json
├── README.md
└── tailwind.config.ts
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | custom: ['https://www.buymeacoffee.com/keiloktql']
2 |
--------------------------------------------------------------------------------
/pages/_error.tsx:
--------------------------------------------------------------------------------
1 | import Error from "@/components/pages/Error";
2 |
3 | export default Error;
4 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/pages/faq/index.tsx:
--------------------------------------------------------------------------------
1 | import FAQPage from "@/components/pages/FAQ";
2 |
3 | export default FAQPage;
4 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import HomePage from "@/components/pages/Home";
2 |
3 | export default HomePage;
4 |
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/favicon-32x32.png
--------------------------------------------------------------------------------
/pages/sitemap/index.tsx:
--------------------------------------------------------------------------------
1 | import SitemapPage from "@/components/pages/Sitemap";
2 |
3 | export default SitemapPage;
4 |
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/assets/app/bento-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/assets/app/bento-1.png
--------------------------------------------------------------------------------
/public/assets/app/bento-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/assets/app/bento-2.png
--------------------------------------------------------------------------------
/public/assets/app/bento-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/assets/app/bento-3.png
--------------------------------------------------------------------------------
/public/assets/app/bento-4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/assets/app/bento-4.png
--------------------------------------------------------------------------------
/public/assets/app/bento-5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/assets/app/bento-5.png
--------------------------------------------------------------------------------
/public/assets/app/bg-hero.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/assets/app/bg-hero.png
--------------------------------------------------------------------------------
/public/assets/app/overview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/assets/app/overview.png
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Allow: /
3 | Disallow: /api/
4 |
5 | Sitemap: https://dolcent.netlify.app/sitemap.xml
--------------------------------------------------------------------------------
/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/assets/system-status/down.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/assets/system-status/down.png
--------------------------------------------------------------------------------
/public/assets/system-status/up.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/assets/system-status/up.png
--------------------------------------------------------------------------------
/public/assets/get-it-on/ios-badge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/assets/get-it-on/ios-badge.png
--------------------------------------------------------------------------------
/public/assets/marketing/bmc-button.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/assets/marketing/bmc-button.png
--------------------------------------------------------------------------------
/public/assets/system-status/paused.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/assets/system-status/paused.png
--------------------------------------------------------------------------------
/pages/privacy-policy/index.tsx:
--------------------------------------------------------------------------------
1 | import PrivacyPolicyPage from "@/components/pages/PrivacyPolicy";
2 |
3 | export default PrivacyPolicyPage;
4 |
--------------------------------------------------------------------------------
/public/assets/get-it-on/android-badge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/assets/get-it-on/android-badge.png
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "useTabs": false,
3 | "tabWidth": 2,
4 | "trailingComma": "none",
5 | "singleQuote": false,
6 | "semi": true
7 | }
--------------------------------------------------------------------------------
/public/assets/marketing/marketing-github.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/keiloktql/dolcent-landing/HEAD/public/assets/marketing/marketing-github.png
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true
4 | };
5 |
6 | module.exports = nextConfig;
7 |
--------------------------------------------------------------------------------
/pages/404.tsx:
--------------------------------------------------------------------------------
1 | import Error from "@/components/pages/Error";
2 |
3 | export default function Custom404() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/pages/terms-and-conditions/index.tsx:
--------------------------------------------------------------------------------
1 | import TermsAndConditionsPage from "@/components/pages/TermsAndConditions";
2 |
3 | export default TermsAndConditionsPage;
4 |
--------------------------------------------------------------------------------
/components/shared/DocumentSection/index.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable arrow-body-style */
2 | import React from "react";
3 |
4 | const DocumentSection = ({ heading, desc }) => {
5 | return (
6 |
7 |
{heading}
8 |
{desc}
9 |
10 | );
11 | };
12 |
13 | export default DocumentSection;
14 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "styles/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": false
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
--------------------------------------------------------------------------------
/components/shared/Skeleton/index.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
15 | );
16 | }
17 |
18 | export { Skeleton };
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
--------------------------------------------------------------------------------
/components/layout/DesktopHeader.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Link from "next/link";
3 | import CommandMenu from "@/components/shared/CommandMenu";
4 |
5 | const DesktopHeader = () => (
6 |
7 |
8 | FAQ
9 |
10 |
11 |
12 |
13 |
14 | );
15 |
16 | export default DesktopHeader;
17 |
--------------------------------------------------------------------------------
/lib/enum.ts:
--------------------------------------------------------------------------------
1 | export enum BENTO_BOX_ENUM {
2 | LONG_TEXT_LEFT,
3 | LONG_TEXT_RIGHT,
4 | SMALL,
5 | SMALL_TEXT_LEFT,
6 | SMALL_TEXT_RIGHT
7 | }
8 |
9 | export enum SYSTEM_STATUS_ENUM {
10 | PAUSED, // gray
11 | DOWN, // yellow
12 | UP // green
13 | }
14 |
15 | export enum FOOTER_NAV_LINKS_ENUM {
16 | SUPPORT,
17 | PRODUCT,
18 | LEGAL
19 | }
20 |
21 | export enum HTTP_METHODS_ENUM {
22 | GET = "GET",
23 | POST = "POST",
24 | PUT = "PUT",
25 | DELETE = "DELETE"
26 | }
27 |
28 | export enum TOAST_ENUM {
29 | SUCCESS,
30 | ERROR
31 | }
32 |
--------------------------------------------------------------------------------
/public/sitemap.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | https://dolcent.netlify.app/
5 |
6 |
7 | https://dolcent.netlify.app/faq/
8 |
9 |
10 | https://dolcent.netlify.app/terms-and-conditions/
11 |
12 |
13 | https://dolcent.netlify.app/privacy-policy/
14 |
15 |
16 | https://dolcent.netlify.app/sitemap/
17 |
18 |
19 |
--------------------------------------------------------------------------------
/components/layout/MainLayout.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Header from "@/components/layout/Header";
3 | import Footer from "@/components/layout/Footer";
4 | import SEO from "@/components/layout/SEO";
5 |
6 | interface MainLayoutProps {
7 | children: React.ReactNode;
8 | title?: string;
9 | className?: string;
10 | }
11 |
12 | const MainLayout = ({ children, title, className }: MainLayoutProps) => (
13 | <>
14 |
15 |
16 | {children}
17 |
18 | >
19 | );
20 |
21 | export default MainLayout;
22 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": {
4 | "@/*": ["./*"]
5 | },
6 | "lib": [
7 | "dom",
8 | "dom.iterable",
9 | "esnext"
10 | ],
11 | "allowJs": true,
12 | "skipLibCheck": true,
13 | "strict": false,
14 | "forceConsistentCasingInFileNames": true,
15 | "noEmit": true,
16 | "incremental": true,
17 | "esModuleInterop": true,
18 | "module": "esnext",
19 | "moduleResolution": "node",
20 | "resolveJsonModule": true,
21 | "isolatedModules": true,
22 | "jsx": "preserve"
23 | },
24 | "include": [
25 | "next-env.d.ts",
26 | "**/*.ts",
27 | "**/*.tsx"],
28 | "exclude": [
29 | "node_modules"
30 | ]
31 | }
32 |
--------------------------------------------------------------------------------
/lib/api/index.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { HTTP_METHODS_ENUM } from "../enum";
3 | import { API_URL } from "../config";
4 |
5 | /**
6 | *
7 | * @param {string} url
8 | * @param {string=} accessToken
9 | * @param {string=} method
10 | * @returns {Promise}
11 | */
12 | export const fetcher = async (
13 | url: string,
14 | accessToken?: string,
15 | method: HTTP_METHODS_ENUM = HTTP_METHODS_ENUM.GET
16 | ) => {
17 | const response = await axios({
18 | url,
19 | method,
20 | ...(accessToken && {
21 | headers: {
22 | Authorization: `Bearer ${accessToken}`
23 | }
24 | })
25 | });
26 | return response.data;
27 | };
28 |
29 | // API LIST
30 | export const SYSTEM_STATUS_API = `${API_URL}/system-status`;
31 |
--------------------------------------------------------------------------------
/components/layout/MobileHeader.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Link from "next/link";
3 | import { Sheet, SheetContent, SheetTrigger } from "@/components/shared/Sheet";
4 | import { Menu } from "lucide-react";
5 |
6 | const MobileHeader = () => (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | Home
15 |
16 |
20 | FAQ
21 |
22 |
23 |
24 |
25 | );
26 | export default MobileHeader;
27 |
--------------------------------------------------------------------------------
/components/shared/Toast/toaster.tsx:
--------------------------------------------------------------------------------
1 | import { useToast } from "@/components/shared/Toast/use-toast";
2 | import {
3 | Toast,
4 | ToastClose,
5 | ToastDescription,
6 | ToastProvider,
7 | ToastTitle,
8 | ToastViewport
9 | } from "@/components/shared/Toast";
10 |
11 | export function Toaster() {
12 | const { toasts } = useToast();
13 |
14 | return (
15 |
16 | {toasts.map(({ id, title, description, action, ...props }) => (
17 |
18 |
19 | {title && {title} }
20 | {description && {description} }
21 |
22 | {action}
23 |
24 |
25 | ))}
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["prettier"],
3 | "extends": ["next/core-web-vitals", "airbnb", "prettier"],
4 | "rules": {
5 | "import/no-extraneous-dependencies": 0,
6 | "import/prefer-default-export": 0,
7 | "import/extensions": 0,
8 | "import/no-unresolved": 0,
9 | "react/self-closing-comp": 0,
10 | "react/jsx-filename-extension": 0,
11 | "react/require-default-props": 0,
12 | "react/react-in-jsx-scope": 0,
13 | "react/function-component-definition": 0,
14 | "react/jsx-no-useless-fragment": 0,
15 | "react/prop-types": 0,
16 | "react/no-array-index-key": 0, // temp
17 | "react/jsx-props-no-spreading": 0, // temp
18 | "jsx-a11y/click-events-have-key-events": 0,
19 | "jsx-a11y/no-static-element-interactions": 0,
20 | "jsx-a11y/interactive-supports-focus": 0,
21 | "no-undef": 0,
22 | "no-unused-expressions": 0,
23 | "no-shadow": 0,
24 | "no-unused-vars": 0,
25 | "no-param-reassign": ["error", { "props": false }],
26 | "no-console": 0,
27 | "prettier/prettier": 1
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/components/layout/Header.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import Link from "next/link";
3 | import MobileHeader from "@/components/layout/MobileHeader";
4 | import DesktopHeader from "@/components/layout/DesktopHeader";
5 |
6 | const Header = () => {
7 | const [logoAnimation, setLogoAnimation] = useState(true);
8 |
9 | return (
10 |
31 | );
32 | };
33 |
34 | export default Header;
35 |
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | html {
7 | scroll-behavior: smooth;
8 | font-family:
9 | Inter,
10 | -apple-system,
11 | system-ui,
12 | BlinkMacSystemFont,
13 | "Segoe UI",
14 | Roboto,
15 | "Helvetica Neue",
16 | Arial,
17 | sans-serif;
18 | }
19 | }
20 |
21 | /* Link */
22 | a {
23 | font-weight: 600;
24 | }
25 |
26 | a:hover {
27 | opacity: 0.8;
28 | }
29 |
30 | a:active {
31 | opacity: 0.7;
32 | }
33 |
34 | /* General */
35 | #nprogress .bar {
36 | background-color: #5d55f2 !important;
37 | }
38 |
39 | /* Header */
40 | .shining-effect {
41 | background: linear-gradient(
42 | 100deg,
43 | rgb(0, 0, 0) 25%,
44 | #41347a 30%,
45 | #5d55f2 50%,
46 | #ffb5f1 60%,
47 | rgb(0, 0, 0) 75%
48 | );
49 | background-repeat: no-repeat;
50 | background-size: 350% 100%;
51 | animation: shining-effect 4s;
52 | color: black;
53 | background-clip: text;
54 | -webkit-background-clip: text;
55 | -webkit-text-fill-color: transparent;
56 | }
57 |
58 | @keyframes shining-effect {
59 | 0% {
60 | background-position: 100% 50%;
61 | }
62 | 100% {
63 | background-position: 0% 50%;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/components/pages/Error/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useRouter } from "next/router";
3 | import MainLayout from "@/components/layout/MainLayout";
4 | import { Button } from "@/components/shared/Button";
5 |
6 | function Error({ statusCode = 500 }) {
7 | let header = "";
8 | let desc = "";
9 | const router = useRouter();
10 |
11 | switch (statusCode) {
12 | case 404: {
13 | header = "We can't find that page";
14 | desc =
15 | "Sorry, the page you are looking for doesn't exist or has been moved.";
16 | break;
17 | }
18 | default: {
19 | header = "An unknown error has occured";
20 | desc = "Please try again later.";
21 | }
22 | }
23 |
24 | return (
25 |
29 | {statusCode} error
30 | {header}
31 | {desc}
32 | router.push("/")}>
33 | Take me home
34 |
35 |
36 | );
37 | }
38 |
39 | export default Error;
40 |
--------------------------------------------------------------------------------
/components/layout/SEO.tsx:
--------------------------------------------------------------------------------
1 | import Head from "next/head";
2 | import React from "react";
3 | import { HOST_URL } from "@/lib/config";
4 |
5 | interface SEOProps {
6 | title?: string;
7 | openGraph?: {
8 | ogTitle?: string;
9 | ogDescription?: string;
10 | ogImage?: string;
11 | ogUrl?: string;
12 | };
13 | }
14 |
15 | const SEO = ({
16 | title = "Dolcent",
17 | openGraph = {
18 | ogTitle: "Dolcent",
19 | ogDescription: "Supercharge your Finance Tracking ⚡",
20 | ogImage: `${HOST_URL}/android-chrome-192x192.png`,
21 | ogUrl: HOST_URL
22 | }
23 | }: SEOProps) => (
24 |
25 |
26 | {title}
27 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 |
41 | export default SEO;
42 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import { APP_STORE_LINK_ID } from "@/lib/config";
2 | import { Html, Head, Main, NextScript } from "next/document";
3 |
4 | export default function Document() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
17 |
23 |
24 |
30 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/components/shared/Tooltip/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider;
7 |
8 | const Tooltip = TooltipPrimitive.Root;
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger;
11 |
12 | const TooltipContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, sideOffset = 4, ...props }, ref) => (
16 |
25 | ));
26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
27 |
28 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
29 |
--------------------------------------------------------------------------------
/components/pages/ErrorBoundary/index.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/destructuring-assignment */
2 | /* eslint-disable react/state-in-constructor */
3 | /* eslint-disable @next/next/no-html-link-for-pages */
4 | // import React from 'react';
5 |
6 | // // This class component can only be written in class format,
7 | // // purpose of this is to show a friendly message to users
8 | // // if there is an error with the app e.g. api call fail
9 | // // However, they do NOT catch all types of errors
10 | // // More information here: https://reactjs.org/docs/error-boundaries.html
11 |
12 | import React, { ReactNode } from "react";
13 |
14 | interface ErrorBoundaryProps {
15 | children: ReactNode;
16 | }
17 |
18 | interface ErrorBoundaryState {
19 | hasError: boolean;
20 | }
21 |
22 | export default class ErrorBoundary extends React.Component<
23 | ErrorBoundaryProps,
24 | ErrorBoundaryState
25 | > {
26 | state = { hasError: false };
27 |
28 | static getDerivedStateFromError(error: Error) {
29 | console.log(error);
30 | // Update state so the next render will show the fallback UI.
31 | return { hasError: true };
32 | }
33 |
34 | render() {
35 | if (this.state.hasError) {
36 | // You can render any custom fallback UI
37 | return (
38 |
39 |
Something went wrong
40 |
Oops! Try again later
41 |
Go to Home
42 |
43 | );
44 | }
45 |
46 | return this.props.children;
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/components/pages/Sitemap.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable arrow-body-style */
2 | import React from "react";
3 | import Link from "next/link";
4 | import MainLayout from "@/components/layout/MainLayout";
5 |
6 | const SitemapPage = () => {
7 | return (
8 |
12 | Sitemap
13 |
14 |
18 | Home
19 |
20 |
24 | Frequently Asked Questions
25 |
26 |
30 | Terms and Conditions
31 |
32 |
36 | Privacy Policy
37 |
38 |
42 | Sitemap
43 |
44 |
45 |
46 | );
47 | };
48 |
49 | export default SitemapPage;
50 |
--------------------------------------------------------------------------------
/lib/SpecialUtils.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from "@iconify/react";
2 | import get from "lodash/get";
3 | import {
4 | EMAIL_INCLUDED_REGEX,
5 | EMAIL_OR_HTTPS_INCLUDED_REGEX,
6 | HTTPS_INCLUDED_REGEX
7 | } from "@/lib/config";
8 |
9 | /**
10 | * Replaces Urls and Emails with anchor tags
11 | *
12 | * @param {string} text
13 | * @param {object} linkMap
14 | * @returns {JSX.Element}
15 | */
16 | export const replaceUrlsAndEmailsWithAnchors = (
17 | text: string = "",
18 | linkMap: Record = {}
19 | ): (string | React.JSX.Element)[] =>
20 | text.split(EMAIL_OR_HTTPS_INCLUDED_REGEX).map((str, i) => {
21 | if (HTTPS_INCLUDED_REGEX.test(str)) {
22 | return (
23 |
30 | {linkMap ? get(linkMap, [str], str) : str}
31 |
32 |
33 | );
34 | }
35 | if (EMAIL_INCLUDED_REGEX.test(str)) {
36 | return (
37 |
42 | {linkMap ? get(linkMap, [str], str) : str}
43 |
47 |
48 | );
49 | }
50 | return str;
51 | });
52 |
--------------------------------------------------------------------------------
/lib/api/queries.tsx:
--------------------------------------------------------------------------------
1 | import useSWR from "swr";
2 | import { useCustomToast } from "@/lib/hooks";
3 | import { SYSTEM_STATUS_API, fetcher } from "@/lib/api";
4 | import { TOAST_ENUM } from "@/lib/enum";
5 | import { getLocalStorageItem, setLocalStorageItem } from "../utils";
6 | import { STATUS_CHECK_CACHE_KEY } from "../data";
7 |
8 | export const useSystemStatus = () => {
9 | const toastTrigger = useCustomToast();
10 | const checkNeedToFetch = () => {
11 | const statusCheckObj = getLocalStorageItem(STATUS_CHECK_CACHE_KEY);
12 | const { timestamp, data }: { timestamp: number; data: string } =
13 | statusCheckObj ? JSON.parse(statusCheckObj) : {};
14 | if (statusCheckObj && timestamp && data) {
15 | const currentTime = new Date().getTime();
16 | const expirationTime = 5 * 60 * 1000; // 5 minutes in milliseconds
17 | const expired = currentTime - timestamp >= expirationTime;
18 | if (expired) {
19 | return true;
20 | }
21 | }
22 | if (!statusCheckObj) {
23 | return true;
24 | }
25 | return false;
26 | };
27 |
28 | const needToFetch = checkNeedToFetch();
29 | const results = useSWR(needToFetch ? SYSTEM_STATUS_API : null, fetcher);
30 |
31 | if (results.error) {
32 | toastTrigger(TOAST_ENUM.ERROR);
33 | }
34 |
35 | if (needToFetch && !results.error && !results.isLoading) {
36 | const currentTime = new Date().getTime();
37 | setLocalStorageItem(STATUS_CHECK_CACHE_KEY, {
38 | data: results.data.data,
39 | timestamp: currentTime
40 | });
41 | }
42 |
43 | return results;
44 | };
45 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | /**
5 | * Utility function to combine tailwind compatible classnames
6 | *
7 | * @param {...any} inputs
8 | * @returns {string}
9 | */
10 | export const cn = (...inputs: any[]): string => twMerge(clsx(inputs));
11 |
12 | /**
13 | * Sets key-value pair in LocalStorage.
14 | *
15 | * @param {string=} key
16 | * @param {string=} value
17 | */
18 | export const setLocalStorageItem = (
19 | key: string | undefined,
20 | value: Record | string | undefined
21 | ) => {
22 | try {
23 | localStorage.setItem(key, JSON.stringify(value));
24 | } catch (error) {
25 | console.error("Error setting localStorage item:", error);
26 | }
27 | };
28 |
29 | /**
30 | * Retrieves a value from LocalStorage based on given key.
31 | *
32 | * @param {string} key
33 | * @returns {string | null}
34 | */
35 | export const getLocalStorageItem = (key: string): string | null => {
36 | if (typeof window === "undefined") {
37 | console.error(
38 | "Error getting localStorage item: not possible on server side"
39 | );
40 | return null;
41 | }
42 | try {
43 | const item = localStorage.getItem(key);
44 | return item || null;
45 | } catch (error) {
46 | console.error("Error getting localStorage item:", error);
47 | return null;
48 | }
49 | };
50 |
51 | /**
52 | * Removes a value from LocalStraoge based on given key.
53 | *
54 | * @param {string} key
55 | */
56 | export const removeLocalStorageItem = (key: string) => {
57 | try {
58 | localStorage.removeItem(key);
59 | } catch (error) {
60 | console.error("Error removing localStorage item:", error);
61 | }
62 | };
63 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dolcent-landing",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@iconify/react": "^4.1.1",
13 | "@radix-ui/react-accordion": "^1.1.2",
14 | "@radix-ui/react-dialog": "^1.0.5",
15 | "@radix-ui/react-icons": "^1.3.0",
16 | "@radix-ui/react-slot": "^1.0.2",
17 | "@radix-ui/react-toast": "^1.2.1",
18 | "@radix-ui/react-tooltip": "^1.0.7",
19 | "autoprefixer": "10.4.14",
20 | "axios": "^1.6.8",
21 | "class-variance-authority": "^0.7.0",
22 | "clsx": "^2.0.0",
23 | "cmdk": "^0.2.1",
24 | "eslint-config-next": "13.4.12",
25 | "firebase": "^10.4.0",
26 | "lodash": "^4.17.21",
27 | "lucide-react": "^0.279.0",
28 | "next": "^14.2.35",
29 | "nprogress": "^0.2.0",
30 | "postcss": "8.4.27",
31 | "react": "18.2.0",
32 | "react-dom": "18.2.0",
33 | "react-toastify": "^9.1.3",
34 | "swr": "^2.2.5",
35 | "tailwind-merge": "^1.14.0",
36 | "tailwindcss": "3.3.3",
37 | "tailwindcss-animate": "^1.0.7"
38 | },
39 | "devDependencies": {
40 | "@eslint/js": "^9.1.1",
41 | "@types/react": "18.3.1",
42 | "eslint": "^8.57.0",
43 | "eslint-config-airbnb": "^19.0.4",
44 | "eslint-config-prettier": "^9.0.0",
45 | "eslint-plugin-import": "^2.28.1",
46 | "eslint-plugin-jsx-a11y": "^6.7.1",
47 | "eslint-plugin-prettier": "^5.0.0",
48 | "eslint-plugin-react": "^7.34.1",
49 | "eslint-plugin-react-hooks": "^4.6.0",
50 | "globals": "^15.1.0",
51 | "prettier": "^3.0.1",
52 | "typescript-eslint": "^7.8.0"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 | import { useEffect } from "react";
3 | import "react-toastify/dist/ReactToastify.css";
4 | import { ToastContainer } from "react-toastify";
5 |
6 | import "@/styles/globals.css";
7 |
8 | import NProgress from "nprogress";
9 | import "nprogress/nprogress.css";
10 | import { useRouter } from "next/router";
11 | import { initializeApp } from "firebase/app";
12 | import { getAnalytics } from "firebase/analytics";
13 | import ErrorBoundary from "@/components/pages/ErrorBoundary";
14 | import { ENVIRONMENT, FIREBASE_CONFIG } from "@/lib/config";
15 | import { TooltipProvider } from "@/components/shared/Tooltip";
16 |
17 | export default function App({ Component, pageProps }) {
18 | const router = useRouter();
19 |
20 | const app = initializeApp(FIREBASE_CONFIG);
21 | const analytics = typeof window !== "undefined" ? getAnalytics(app) : null;
22 |
23 | // hide console.log in PROD
24 | if (ENVIRONMENT === "PROD") console.log = () => {};
25 |
26 | useEffect(() => {
27 | NProgress.configure({ showSpinner: false });
28 | router.events.on("routeChangeStart", () => NProgress.start());
29 | router.events.on("routeChangeComplete", () => NProgress.done());
30 | router.events.on("routeChangeError", () => NProgress.done());
31 | }, []);
32 |
33 | return (
34 |
35 |
36 |
47 |
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/lib/config.ts:
--------------------------------------------------------------------------------
1 | export const ENVIRONMENT = process.env.NEXT_PUBLIC_ENVIRONMENT || "PROD";
2 |
3 | // REGEX
4 | export const EMAIL_INCLUDED_REGEX =
5 | /([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)/gi;
6 | export const HTTPS_INCLUDED_REGEX = /(https?:\/\/\S+)/gi;
7 | export const EMAIL_OR_HTTPS_INCLUDED_REGEX =
8 | /(https?:\/\/\S+|[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9._-]+)/gi;
9 |
10 | // URL
11 | const PORT = 3000;
12 | export const PROD_HOST_URL = "https://dolcent.netlify.app";
13 | const HOST_URLS = {
14 | PROD: PROD_HOST_URL,
15 | DEV: `http://localhost:${PORT}`
16 | };
17 | export const HOST_URL = HOST_URLS[ENVIRONMENT];
18 | export const API_URL = `${HOST_URL}/api`;
19 | export const GITHUB_URL = "https://github.com/keiloktql";
20 | export const BUY_ME_A_COFFEE_URL = "https://www.buymeacoffee.com/keiloktql";
21 | export const APP_STORE_LINK_ID = "6466705209";
22 | export const APP_STORE_LISTING_URL = `https://apps.apple.com/us/app/dolcent/id${APP_STORE_LINK_ID}`;
23 | export const PLAY_STORE_LISTING_URL =
24 | "https://play.google.com/store/apps/details?id=com.kl.dolcent";
25 | export const REALM_ENCRYPTION_URL =
26 | "https://www.mongodb.com/docs/realm/sdk/react-native/realm-files/encrypt/";
27 | export const UPTIME_URL = "https://dolcent.betteruptime.com";
28 | export const CANNY_URL = "https://dolcent.canny.io";
29 |
30 | // EMAIL
31 | export const SUPPORT_EMAIL = "dolcent.connect@gmail.com";
32 |
33 | // FIREBASE
34 | export const FIREBASE_CONFIG = {
35 | apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
36 | authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
37 | projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
38 | storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
39 | messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
40 | appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
41 | measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID
42 | };
43 |
--------------------------------------------------------------------------------
/pages/api/system-status.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { SYSTEM_STATUS_ENUM } from "@/lib/enum";
3 | import { NextApiRequest, NextApiResponse } from "next";
4 |
5 | /**
6 | * Gets the system status for Dolcent
7 | * Note: list of status type from uptime: https://betterstack.com/docs/uptime/api/list-all-existing-monitors/
8 | *
9 | * @param {NextApiRequest} req
10 | * @param {NextApiResponse} res
11 | */
12 | export default async function handler(
13 | req: NextApiRequest,
14 | res: NextApiResponse
15 | ) {
16 | if (req.method !== "GET") {
17 | res.status(500).json({ message: "HTTP method not supported" });
18 | }
19 | let data = null;
20 | let message = null;
21 | try {
22 | const response = await axios.get(
23 | "https://uptime.betterstack.com/api/v2/monitors",
24 | {
25 | headers: { Authorization: `Bearer ${process.env.UPTIME_API_KEY}` }
26 | }
27 | );
28 | if (response?.data?.data?.length === 0) {
29 | throw Error("NO_MONITORS");
30 | }
31 | const monitors = response.data.data;
32 | const down = monitors.some((oneMonitor) => {
33 | const { status } = oneMonitor.attributes;
34 | return status === "down" || status === "validating";
35 | });
36 | const paused = monitors.some((oneMonitor) => {
37 | const { status } = oneMonitor.attributes;
38 | return (
39 | status === "paused" || status === "pending" || status === "maintenance"
40 | );
41 | });
42 | const up = monitors.some((oneMonitor) => {
43 | const { status } = oneMonitor.attributes;
44 | return status === "up";
45 | });
46 | if (down) {
47 | data = SYSTEM_STATUS_ENUM.DOWN;
48 | } else if (paused && !down && !up) {
49 | data = SYSTEM_STATUS_ENUM.PAUSED;
50 | } else {
51 | data = SYSTEM_STATUS_ENUM.UP;
52 | }
53 | } catch (error) {
54 | if (error.message && error.message === "NO_MONITORS") {
55 | message = "NO_MONITORS";
56 | } else {
57 | message = "Something went wrong.";
58 | }
59 | return res.status(500).json({ success: false, message, data });
60 | }
61 | return res.status(200).json({ success: true, message, data });
62 | }
63 |
--------------------------------------------------------------------------------
/components/shared/SystemStatus/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Image from "next/image";
3 | import { Clock8 } from "lucide-react";
4 | import SSUp from "@/public/assets/system-status/up.png";
5 | import SSDown from "@/public/assets/system-status/down.png";
6 | import SSPaused from "@/public/assets/system-status/paused.png";
7 | import { Skeleton } from "@/components/shared/Skeleton";
8 | import { SYSTEM_STATUS_ENUM } from "@/lib/enum";
9 | import { UPTIME_URL } from "@/lib/config";
10 | import {
11 | Tooltip,
12 | TooltipContent,
13 | TooltipTrigger
14 | } from "@/components/shared/Tooltip";
15 |
16 | const SystemStatus = ({ systemStatus, loading, className }) => {
17 | if (loading) {
18 | return (
19 |
20 |
21 |
22 |
23 | );
24 | }
25 |
26 | let img = SSPaused;
27 | let text = "All systems checks paused.";
28 |
29 | if (systemStatus === SYSTEM_STATUS_ENUM.DOWN) {
30 | img = SSDown;
31 | text = "System(s) down.";
32 | }
33 | if (systemStatus === SYSTEM_STATUS_ENUM.UP) {
34 | img = SSUp;
35 | text = "All systems operational.";
36 | }
37 |
38 | return (
39 |
40 |
41 |
48 |
53 | {text}
54 |
55 |
56 |
57 |
58 | Updated every 5 mins
59 |
60 |
61 | );
62 | };
63 |
64 | export default SystemStatus;
65 |
--------------------------------------------------------------------------------
/components/shared/Accordion/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as AccordionPrimitive from "@radix-ui/react-accordion"
3 | import { ChevronDown } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const Accordion = AccordionPrimitive.Root
8 |
9 | const AccordionItem = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
18 | ))
19 | AccordionItem.displayName = "AccordionItem"
20 |
21 | const AccordionTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, children, ...props }, ref) => (
25 |
26 | svg]:rotate-180",
30 | className
31 | )}
32 | {...props}
33 | >
34 | {children}
35 |
36 |
37 |
38 | ))
39 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
40 |
41 | const AccordionContent = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef
44 | >(({ className, children, ...props }, ref) => (
45 |
50 | {children}
51 |
52 | ))
53 |
54 | AccordionContent.displayName = AccordionPrimitive.Content.displayName
55 |
56 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
57 |
--------------------------------------------------------------------------------
/components/shared/Button/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-white hover:bg-primary/90",
13 | destructive:
14 | "bg-red-500 text-slate-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90",
15 | outline:
16 | "border border-slate-200 bg-white hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50",
17 | secondary:
18 | "bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
19 | ghost:
20 | "hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50",
21 | link: "text-slate-900 underline-offset-4 hover:underline dark:text-slate-50"
22 | },
23 | size: {
24 | default: "h-10 px-4 py-2",
25 | sm: "h-9 rounded-md px-3",
26 | lg: "h-11 rounded-md px-8",
27 | icon: "h-10 w-10"
28 | }
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default"
33 | }
34 | }
35 | );
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean;
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button";
46 | return (
47 |
52 | );
53 | }
54 | );
55 | Button.displayName = "Button";
56 |
57 | export { Button, buttonVariants };
58 |
--------------------------------------------------------------------------------
/lib/hooks.tsx:
--------------------------------------------------------------------------------
1 | import { Icon } from "@iconify/react";
2 | import get from "lodash/get";
3 | import {
4 | EMAIL_INCLUDED_REGEX,
5 | EMAIL_OR_HTTPS_INCLUDED_REGEX,
6 | HTTPS_INCLUDED_REGEX
7 | } from "@/lib/config";
8 | import { useToast } from "@/components/shared/Toast/use-toast";
9 | import { TOAST_ENUM } from "./enum";
10 |
11 | /**
12 | * Hook to replace URLs and emails with anchor tags.
13 | *
14 | * @param {string} initialText Initial text value.
15 | * @param {object} linkMap Map of links to be replaced.
16 | * @returns Tuple containing the text and a function to set the text.
17 | */
18 | const useTextWithAnchors = (
19 | initialText: (string | Element)[],
20 | linkMap: Record = {}
21 | ): JSX.Element => {
22 | let text = initialText as any;
23 | const replaceText = (inputText: any) =>
24 | inputText.split(EMAIL_OR_HTTPS_INCLUDED_REGEX).map((str, i) => {
25 | if (HTTPS_INCLUDED_REGEX.test(str)) {
26 | return (
27 |
34 | {linkMap ? get(linkMap, [str], str) : str}
35 |
36 |
37 | );
38 | }
39 | if (EMAIL_INCLUDED_REGEX.test(str)) {
40 | return (
41 |
46 | {linkMap ? get(linkMap, [str], str) : str}
47 |
51 |
52 | );
53 | }
54 | return str;
55 | });
56 | text = replaceText(text);
57 |
58 | return text;
59 | };
60 |
61 | export default useTextWithAnchors;
62 |
63 | export const useCustomToast = () => {
64 | const { toast } = useToast();
65 |
66 | const trigger = (variant?: TOAST_ENUM, desc?: string) => {
67 | switch (variant) {
68 | case TOAST_ENUM.SUCCESS:
69 | return toast({
70 | variant: "default",
71 | title: "Success!",
72 | description: desc || "Operation successful."
73 | });
74 | case TOAST_ENUM.ERROR:
75 | return toast({
76 | variant: "destructive",
77 | title: "Uh oh! Something went wrong.",
78 | description: desc || "There was a problem with your request."
79 | });
80 |
81 | default:
82 | return toast({
83 | variant: "destructive",
84 | title: "Uh oh! Something went wrong.",
85 | description: "There was a problem with your request."
86 | });
87 | }
88 | };
89 | return trigger;
90 | };
91 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://app.netlify.com/sites/dolcent/deploys)
2 | [](https://uptime.betterstack.com/?utm_source=status_badge)
3 |
4 | # Dolcent - Your Financial Companion
5 |
6 | 
7 |
8 | Welcome to **Dolcent**, your all-in-one finance companion designed to simplify your financial journey. Dolcent empowers you to effortlessly manage your income, expenses, and budgets in over 140 currencies. With daily currency exchange updates and powerful analytics, you'll have a clear view of your financial landscape. This is the repository for the landing website of Dolcent.
9 |
10 | ## Features
11 |
12 | - **Beautiful UI**: Enjoy a stunning and user-friendly interface designed with simplicity in mind.
13 | - **Multi-Currency Support**: Track your finances in 140+ currencies.
14 | - **Daily Currency Updates**: Stay up-to-date with exchange rates.
15 | - **Interactive Graphs**: Visualize your cash flow, income, and expenses.
16 | - **Transaction History**: Easily view and filter all your financial transactions.
17 | - **Wallets**: Keep tabs on your balances, from cash to digital wallets.
18 | - **Budget Management**: Set and manage budgets for a better financial future.
19 | - **Face ID/Touch ID**: Securely access your financial data.
20 | - **Secure Data**: Your data is stored locally on your device and encrypted for ultimate privacy.
21 | - **Import/Export**: Seamlessly back up and restore your data.
22 | - **Ad-Free**: Dolcent is completely free to download and use, with no annoying ads.
23 |
24 | ## About Dolcent
25 |
26 | Dolcent is my passion project, created with a love for software engineering. Join me on this journey and experience the simplicity of Dolcent while supporting my aspirations as a young developer.
27 |
28 | > Fun fact: Dolcent is a fusion of the words "Dollars" and "Cents," symbolizing its core purpose of helping you manage your financial matters down to the last cent.
29 |
30 | ## Get Started
31 |
32 | Dolcent is available on the following platforms
33 |
34 | [](https://apps.apple.com/us/app/dolcent/id6466705209) [](https://play.google.com/store/apps/details?id=com.kl.dolcent)
35 |
36 | ## Support Dolcent
37 |
38 | If you enjoy using Dolcent and would like to support its development, you can [buy me a coffee](https://www.buymeacoffee.com/keiloktql) or send a tip via the Dolcent app (iOS only).
39 |
40 | [](https://www.buymeacoffee.com/keiloktql)
41 |
--------------------------------------------------------------------------------
/components/pages/TermsAndConditions.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable arrow-body-style */
2 | import MainLayout from "@/components/layout/MainLayout";
3 | import { HOST_URL, SUPPORT_EMAIL } from "@/lib/config";
4 | import DocumentSection from "@/components/shared/DocumentSection";
5 |
6 | const TermsAndConditionsPage = () => {
7 | return (
8 |
12 |
16 | You are now visiting {`${HOST_URL}/terms-and-conditions`}
17 |
18 | Terms and Conditions
19 |
20 | Last updated: September 2023
21 |
22 |
26 |
32 |
39 |
45 |
51 |
59 |
65 |
69 |
70 | );
71 | };
72 |
73 | export default TermsAndConditionsPage;
74 |
--------------------------------------------------------------------------------
/components/pages/PrivacyPolicy.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable arrow-body-style */
2 | import DocumentSection from "@/components/shared/DocumentSection";
3 | import MainLayout from "@/components/layout/MainLayout";
4 | import { HOST_URL, SUPPORT_EMAIL } from "@/lib/config";
5 |
6 | const PrivacyPolicyPage = () => {
7 | return (
8 |
12 |
16 | You are now visiting {`${HOST_URL}/privacy-policy`}
17 |
18 | Privacy Policy
19 | Last updated: September 2023
20 |
21 |
28 |
33 |
40 |
48 |
54 |
58 |
59 | );
60 | };
61 |
62 | export default PrivacyPolicyPage;
63 |
--------------------------------------------------------------------------------
/components/shared/CommandMenu/index.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from "react";
2 | import { CircleIcon, FileIcon } from "@radix-ui/react-icons";
3 | import { useRouter } from "next/router";
4 | import {
5 | CommandDialog,
6 | CommandEmpty,
7 | CommandGroup,
8 | CommandInput,
9 | CommandItem,
10 | CommandList,
11 | CommandSeparator
12 | } from "@/components/shared/Command";
13 | import { COMMAND_MENU_DATA } from "@/lib/data";
14 |
15 | const CommandMenu = () => {
16 | const router = useRouter();
17 | const [open, setOpen] = useState(false);
18 |
19 | useEffect(() => {
20 | const down = (e) => {
21 | if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || e.key === "/") {
22 | if (
23 | (e.target instanceof HTMLElement && e.target.isContentEditable) ||
24 | e.target instanceof HTMLInputElement ||
25 | e.target instanceof HTMLTextAreaElement ||
26 | e.target instanceof HTMLSelectElement
27 | ) {
28 | return;
29 | }
30 |
31 | e.preventDefault();
32 | setOpen((open) => !open);
33 | }
34 | };
35 |
36 | document.addEventListener("keydown", down);
37 | return () => document.removeEventListener("keydown", down);
38 | }, []);
39 |
40 | const runCommand = useCallback((command) => {
41 | setOpen(false);
42 | command();
43 | }, []);
44 |
45 | return (
46 | <>
47 | setOpen(true)}
51 | >
52 | Search...
53 |
54 | ⌘ K
55 |
56 |
57 |
58 |
59 |
60 | No results found.
61 |
62 | {COMMAND_MENU_DATA.mainNav.map((navItem) => (
63 | {
67 | runCommand(() => router.push(navItem.href));
68 | }}
69 | >
70 |
71 | {navItem.title}
72 |
73 | ))}
74 |
75 |
76 | {COMMAND_MENU_DATA.faqNav.map((group) => (
77 |
78 | {group.items.map((navItem) => (
79 | {
83 | runCommand(() => router.push(navItem.href));
84 | }}
85 | >
86 |
87 |
88 |
89 | {navItem.title}
90 |
91 | ))}
92 |
93 | ))}
94 |
95 |
96 | >
97 | );
98 | };
99 |
100 | export default CommandMenu;
101 |
--------------------------------------------------------------------------------
/components/shared/BentoBox/index.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable global-require */
2 | /* eslint-disable import/no-dynamic-require */
3 | import React from "react";
4 | import Image from "next/image";
5 | import { BENTO_BOX_ENUM } from "@/lib/enum";
6 |
7 | const BentoBox = ({
8 | type = BENTO_BOX_ENUM.LONG_TEXT_LEFT,
9 | imageHref,
10 | heading,
11 | desc,
12 | content,
13 | className,
14 | textClassName
15 | }) => {
16 | if (type === BENTO_BOX_ENUM.LONG_TEXT_LEFT) {
17 | return (
18 |
21 |
22 |
25 | {heading}
26 |
27 |
{desc}
28 |
29 |
30 |
31 |
32 |
33 | );
34 | }
35 | if (type === BENTO_BOX_ENUM.LONG_TEXT_RIGHT) {
36 | return (
37 |
40 |
41 |
42 |
43 |
44 |
47 | {heading}
48 |
49 |
{desc}
50 |
51 |
52 | );
53 | }
54 |
55 | // BENTO_BOX_TYPE.SMALL
56 | return (
57 |
58 | {content.map((oneContent, index) => {
59 | if (oneContent.type === BENTO_BOX_ENUM.SMALL_TEXT_LEFT) {
60 | return (
61 |
65 |
66 |
69 | {oneContent.heading}
70 |
71 |
72 | {oneContent.desc}
73 |
74 |
75 |
76 |
82 |
83 |
84 | );
85 | }
86 | return (
87 |
91 |
92 |
95 | {oneContent.heading}
96 |
97 |
98 | {oneContent.desc}
99 |
100 |
101 |
102 |
108 |
109 |
110 | );
111 | })}
112 |
113 | );
114 | };
115 |
116 | export default BentoBox;
117 |
--------------------------------------------------------------------------------
/components/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | import React, { useState } from "react";
3 | import Image from "next/image";
4 | import MainLayout from "@/components/layout/MainLayout";
5 | import IOSBadge from "@/public/assets/get-it-on/ios-badge.png";
6 | import AndroidBadge from "@/public/assets/get-it-on/android-badge.png";
7 | import SCOverview from "@/public/assets/app/overview.png";
8 | import { APP_STORE_LISTING_URL, PLAY_STORE_LISTING_URL } from "@/lib/config";
9 | import { OVERVIEW_FEATURES_LIST } from "@/lib/data";
10 | import BentoBox from "@/components/shared/BentoBox";
11 |
12 | const HomePage = () => {
13 | const [shiningEffect, setShiningEffect] = useState(true);
14 |
15 | return (
16 |
17 | {/* Hero */}
18 |
19 |
20 |
34 |
setShiningEffect(false)}
36 | className={`font-bold text-center md:text-left text-display-md md:text-display-xl mt-4 ${
37 | shiningEffect ? "shining-effect" : ""
38 | }`}
39 | >
40 | Superchage your Finance Tracking ⚡
41 |
42 |
43 | With Dolcent, you can effortlessly manage your income, expenses, and
44 | budgets in over 140 currencies, making it easier than ever to take
45 | control of your finances. 100% free, with no paywall or ads.
46 |
47 |
64 |
65 |
66 |
72 |
73 |
74 | {/* Features */}
75 |
76 |
77 | Powerful Features
78 |
79 |
80 | {OVERVIEW_FEATURES_LIST.map((oneFeature, index) => (
81 |
91 | ))}
92 |
93 |
94 | and many more...
95 |
96 |
97 |
98 | );
99 | };
100 |
101 | export default HomePage;
102 |
--------------------------------------------------------------------------------
/components/shared/Dialog/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as DialogPrimitive from "@radix-ui/react-dialog";
3 | import { X } from "lucide-react";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const Dialog = DialogPrimitive.Root;
8 |
9 | const DialogTrigger = DialogPrimitive.Trigger;
10 |
11 | const DialogPortal = DialogPrimitive.Portal;
12 |
13 | const DialogClose = DialogPrimitive.Close;
14 |
15 | const DialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ));
28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
29 |
30 | const DialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, children, ...props }, ref) => (
34 |
35 |
36 |
44 | {children}
45 |
46 |
47 | Close
48 |
49 |
50 |
51 | ));
52 | DialogContent.displayName = DialogPrimitive.Content.displayName;
53 |
54 | const DialogHeader = ({
55 | className,
56 | ...props
57 | }: React.HTMLAttributes) => (
58 |
65 | );
66 | DialogHeader.displayName = "DialogHeader";
67 |
68 | const DialogFooter = ({
69 | className,
70 | ...props
71 | }: React.HTMLAttributes) => (
72 |
79 | );
80 | DialogFooter.displayName = "DialogFooter";
81 |
82 | const DialogTitle = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => (
86 |
94 | ));
95 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
96 |
97 | const DialogDescription = React.forwardRef<
98 | React.ElementRef,
99 | React.ComponentPropsWithoutRef
100 | >(({ className, ...props }, ref) => (
101 |
106 | ));
107 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
108 |
109 | export {
110 | Dialog,
111 | DialogPortal,
112 | DialogOverlay,
113 | DialogClose,
114 | DialogTrigger,
115 | DialogContent,
116 | DialogHeader,
117 | DialogFooter,
118 | DialogTitle,
119 | DialogDescription
120 | };
121 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable global-require */
2 | /** @type {import('tailwindcss').Config} */
3 | module.exports = {
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{js,jsx,ts,tsx}",
7 | "./components/**/*.{js,jsx,ts,tsx}",
8 | "./app/**/*.{js,jsx,ts,tsx}",
9 | "./src/**/*.{js,jsx,ts,tsx}",
10 | "./lib/**/*.{js,jsx,ts,tsx}",
11 | "./config/**/*.{js,jsx,ts,tsx}"
12 | ],
13 | theme: {
14 | container: {
15 | center: true,
16 | padding: "2rem",
17 | screens: {
18 | "2xl": "1400px"
19 | }
20 | },
21 | extend: {
22 | backgroundImage: {
23 | "bg-hero": "url('/assets/app/bg-hero.png')"
24 | },
25 | fontSize: {
26 | "display-2xl": [
27 | "4.5rem",
28 | {
29 | lineHeight: "5.625rem",
30 | letterSpacing: "-0.02rem"
31 | }
32 | ],
33 | "display-xl": [
34 | "3.75rem",
35 | {
36 | lineHeight: "4.5rem",
37 | letterSpacing: "-0.02rem"
38 | }
39 | ],
40 | "display-lg": [
41 | "3rem",
42 | {
43 | lineHeight: "2.75rem",
44 | letterSpacing: "-0.02rem"
45 | }
46 | ],
47 | "display-md": [
48 | "2.25rem",
49 | {
50 | lineHeight: "2.75rem",
51 | letterSpacing: "-0.02rem"
52 | }
53 | ],
54 | "display-sm": [
55 | "1.875rem",
56 | {
57 | lineHeight: "2.375rem"
58 | }
59 | ],
60 | "display-xs": [
61 | "1.5rem",
62 | {
63 | lineHeight: "2rem"
64 | }
65 | ],
66 | xl: [
67 | "1.25rem",
68 | {
69 | lineHeight: "1.875rem"
70 | }
71 | ],
72 | lg: [
73 | "1.125rem",
74 | {
75 | lineHeight: "1.75rem"
76 | }
77 | ],
78 | md: [
79 | "1rem",
80 | {
81 | lineHeight: "1.5rem"
82 | }
83 | ],
84 | sm: [
85 | "0.875rem",
86 | {
87 | lineHeight: "1.25rem"
88 | }
89 | ],
90 | xs: [
91 | "0.75rem",
92 | {
93 | lineHeight: "1.125rem"
94 | }
95 | ]
96 | },
97 | colors: {
98 | primary: "#5D55F2",
99 | secondary: "#41347A",
100 | accent: "#FFB5F1",
101 | gray: {
102 | DEFAULT: "#667085",
103 | 25: "#FCFCFD",
104 | 50: "#F9FAFB",
105 | 100: "#F2F4F7",
106 | 200: "#EAECF0",
107 | 300: "#D0D5DD",
108 | 400: "#98A2B3",
109 | 500: "#667085",
110 | 600: "#475467",
111 | 700: "#344054",
112 | 800: "#1D2939",
113 | 900: "#101828"
114 | },
115 | error: {
116 | DEFAULT: "#F04438",
117 | 25: "#FFFBFA",
118 | 50: "#FEF3F2",
119 | 100: "#FEE4E2",
120 | 200: "#FECDCA",
121 | 300: "#FDA29B",
122 | 400: "#F97066",
123 | 500: "#F04438",
124 | 600: "#D92D20",
125 | 700: "#B42318",
126 | 800: "#912018",
127 | 900: "#7A271A"
128 | },
129 | warning: {
130 | DEFAULT: "#F79009",
131 | 25: "#FFFCF5",
132 | 50: "#FFFAEB",
133 | 100: "#FEF0C7",
134 | 200: "#FEDF89",
135 | 300: "#FEC84B",
136 | 400: "#FDB022",
137 | 500: "#F79009",
138 | 600: "#DC6803",
139 | 700: "#B54708",
140 | 800: "#93370D",
141 | 900: "#7A2E0E"
142 | },
143 | success: {
144 | DEFAULT: "#12B76A",
145 | 25: "#F6FEF9",
146 | 50: "#ECFDF3",
147 | 100: "#D1FADF",
148 | 200: "#A6F4C5",
149 | 300: "#6CE9A6",
150 | 400: "#32D583",
151 | 500: "#12B76A",
152 | 600: "#039855",
153 | 700: "#027A48",
154 | 800: "#05603A",
155 | 900: "#054F31"
156 | }
157 | },
158 | keyframes: {
159 | "accordion-down": {
160 | from: { height: 0 },
161 | to: { height: "var(--radix-accordion-content-height)" }
162 | },
163 | "accordion-up": {
164 | from: { height: "var(--radix-accordion-content-height)" },
165 | to: { height: 0 }
166 | }
167 | },
168 | animation: {
169 | "accordion-down": "accordion-down 0.2s ease-out",
170 | "accordion-up": "accordion-up 0.2s ease-out"
171 | }
172 | }
173 | },
174 | plugins: [require("tailwindcss-animate")]
175 | };
176 |
--------------------------------------------------------------------------------
/components/pages/FAQ.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable arrow-body-style */
2 | import React, { useEffect, useState } from "react";
3 | import { Icon } from "@iconify/react";
4 | import { useRouter } from "next/router";
5 | import MainLayout from "@/components/layout/MainLayout";
6 | import {
7 | Accordion,
8 | AccordionContent,
9 | AccordionItem,
10 | AccordionTrigger
11 | } from "@/components/shared/Accordion";
12 | import {
13 | APP_STORE_LISTING_URL,
14 | BUY_ME_A_COFFEE_URL,
15 | CANNY_URL,
16 | GITHUB_URL,
17 | PLAY_STORE_LISTING_URL,
18 | REALM_ENCRYPTION_URL,
19 | SUPPORT_EMAIL
20 | } from "@/lib/config";
21 | import { FAQ_DATA } from "@/lib/data";
22 | import useTextWithAnchors from "@/lib/hooks";
23 |
24 | const OneFAQContent = ({ content }) => {
25 | const formattedContent = useTextWithAnchors(content, {
26 | [SUPPORT_EMAIL]: SUPPORT_EMAIL,
27 | [BUY_ME_A_COFFEE_URL]: "buy me a coffee.",
28 | [REALM_ENCRYPTION_URL]: "Realm",
29 | [PLAY_STORE_LISTING_URL]: "Google Play Store",
30 | [APP_STORE_LISTING_URL]: "Apple App Store",
31 | [GITHUB_URL]: "Kei Lok",
32 | [CANNY_URL]: "Canny"
33 | });
34 | return formattedContent;
35 | };
36 |
37 | const AccordionSection = ({
38 | heading,
39 | contents,
40 | className,
41 | selectedAccordion,
42 | setSelectedAccordion
43 | }) => {
44 | return (
45 |
46 |
{heading}
47 | {contents.map((oneFaq, index) => (
48 |
{
50 | if (oneFaq.id === selectedAccordion) {
51 | setSelectedAccordion(null);
52 | } else {
53 | setSelectedAccordion(oneFaq.id);
54 | }
55 | }}
56 | id={oneFaq.id}
57 | key={index}
58 | value={oneFaq.id}
59 | className="scroll-mt-[80px]"
60 | >
61 |
62 | {oneFaq.qns}
63 |
64 |
65 |
66 |
67 |
68 | ))}
69 |
70 | );
71 | };
72 |
73 | const FAQPage = () => {
74 | const [urlFragment, setUrlFragment] = useState(null);
75 | const [selectedAccordion, setSelectedAccordion] = useState(null);
76 | const router = useRouter();
77 |
78 | const onHashChangeStart = () => {
79 | if (window.location.hash) {
80 | setUrlFragment(window.location.hash.substring(1));
81 | }
82 | };
83 |
84 | useEffect(() => {
85 | onHashChangeStart();
86 | }, [router.asPath]);
87 |
88 | return (
89 |
93 |
94 | Frequently Asked Questions
95 |
96 | {
102 | setUrlFragment(null);
103 | }}
104 | >
105 | {FAQ_DATA.map((oneFAQSection, index) => {
106 | return (
107 |
115 | );
116 | })}
117 |
118 |
119 |
149 |
150 | );
151 | };
152 |
153 | export default FAQPage;
154 |
--------------------------------------------------------------------------------
/components/shared/Toast/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from "react";
3 |
4 | import type { ToastActionElement, ToastProps } from "@/components/shared/Toast";
5 |
6 | const TOAST_LIMIT = 1;
7 | const TOAST_REMOVE_DELAY = 1000000;
8 |
9 | type ToasterToast = ToastProps & {
10 | id: string;
11 | title?: React.ReactNode;
12 | description?: React.ReactNode;
13 | action?: ToastActionElement;
14 | };
15 |
16 | const actionTypes = {
17 | ADD_TOAST: "ADD_TOAST",
18 | UPDATE_TOAST: "UPDATE_TOAST",
19 | DISMISS_TOAST: "DISMISS_TOAST",
20 | REMOVE_TOAST: "REMOVE_TOAST"
21 | } as const;
22 |
23 | let count = 0;
24 |
25 | function genId() {
26 | count = (count + 1) % Number.MAX_SAFE_INTEGER;
27 | return count.toString();
28 | }
29 |
30 | type ActionType = typeof actionTypes;
31 |
32 | type Action =
33 | | {
34 | type: ActionType["ADD_TOAST"];
35 | toast: ToasterToast;
36 | }
37 | | {
38 | type: ActionType["UPDATE_TOAST"];
39 | toast: Partial;
40 | }
41 | | {
42 | type: ActionType["DISMISS_TOAST"];
43 | toastId?: ToasterToast["id"];
44 | }
45 | | {
46 | type: ActionType["REMOVE_TOAST"];
47 | toastId?: ToasterToast["id"];
48 | };
49 |
50 | interface State {
51 | toasts: ToasterToast[];
52 | }
53 |
54 | const toastTimeouts = new Map>();
55 |
56 | const addToRemoveQueue = (toastId: string) => {
57 | if (toastTimeouts.has(toastId)) {
58 | return;
59 | }
60 |
61 | const timeout = setTimeout(() => {
62 | toastTimeouts.delete(toastId);
63 | // eslint-disable-next-line no-use-before-define
64 | dispatch({
65 | type: "REMOVE_TOAST",
66 | toastId
67 | });
68 | }, TOAST_REMOVE_DELAY);
69 |
70 | toastTimeouts.set(toastId, timeout);
71 | };
72 |
73 | // eslint-disable-next-line consistent-return
74 | export const reducer = (state: State, action: Action): State => {
75 | // eslint-disable-next-line default-case
76 | switch (action.type) {
77 | case "ADD_TOAST":
78 | return {
79 | ...state,
80 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT)
81 | };
82 |
83 | case "UPDATE_TOAST":
84 | return {
85 | ...state,
86 | toasts: state.toasts.map((t) =>
87 | t.id === action.toast.id ? { ...t, ...action.toast } : t
88 | )
89 | };
90 |
91 | case "DISMISS_TOAST": {
92 | const { toastId } = action;
93 |
94 | // ! Side effects ! - This could be extracted into a dismissToast() action,
95 | // but I'll keep it here for simplicity
96 | if (toastId) {
97 | addToRemoveQueue(toastId);
98 | } else {
99 | state.toasts.forEach((toast) => {
100 | addToRemoveQueue(toast.id);
101 | });
102 | }
103 |
104 | return {
105 | ...state,
106 | toasts: state.toasts.map((t) =>
107 | t.id === toastId || toastId === undefined
108 | ? {
109 | ...t,
110 | open: false
111 | }
112 | : t
113 | )
114 | };
115 | }
116 | case "REMOVE_TOAST":
117 | if (action.toastId === undefined) {
118 | return {
119 | ...state,
120 | toasts: []
121 | };
122 | }
123 | return {
124 | ...state,
125 | toasts: state.toasts.filter((t) => t.id !== action.toastId)
126 | };
127 | }
128 | };
129 |
130 | const listeners: Array<(state: State) => void> = [];
131 |
132 | let memoryState: State = { toasts: [] };
133 |
134 | function dispatch(action: Action) {
135 | memoryState = reducer(memoryState, action);
136 | listeners.forEach((listener) => {
137 | listener(memoryState);
138 | });
139 | }
140 |
141 | type Toast = Omit;
142 |
143 | function toast({ ...props }: Toast) {
144 | const id = genId();
145 |
146 | const update = (props: ToasterToast) =>
147 | dispatch({
148 | type: "UPDATE_TOAST",
149 | toast: { ...props, id }
150 | });
151 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
152 |
153 | dispatch({
154 | type: "ADD_TOAST",
155 | toast: {
156 | ...props,
157 | id,
158 | open: true,
159 | onOpenChange: (open) => {
160 | if (!open) dismiss();
161 | }
162 | }
163 | });
164 |
165 | return {
166 | id,
167 | dismiss,
168 | update
169 | };
170 | }
171 |
172 | function useToast() {
173 | const [state, setState] = React.useState(memoryState);
174 |
175 | React.useEffect(() => {
176 | listeners.push(setState);
177 | return () => {
178 | const index = listeners.indexOf(setState);
179 | if (index > -1) {
180 | listeners.splice(index, 1);
181 | }
182 | };
183 | }, [state]);
184 |
185 | return {
186 | ...state,
187 | toast,
188 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId })
189 | };
190 | }
191 |
192 | export { useToast, toast };
193 |
--------------------------------------------------------------------------------
/components/shared/Sheet/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as SheetPrimitive from "@radix-ui/react-dialog";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 | import { X } from "lucide-react";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Sheet = SheetPrimitive.Root;
9 |
10 | const SheetTrigger = SheetPrimitive.Trigger;
11 |
12 | const SheetClose = SheetPrimitive.Close;
13 |
14 | const SheetPortal = SheetPrimitive.Portal;
15 |
16 | const SheetOverlay = React.forwardRef<
17 | React.ElementRef,
18 | React.ComponentPropsWithoutRef
19 | >(({ className, ...props }, ref) => (
20 |
28 | ));
29 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
30 |
31 | const sheetVariants = cva(
32 | "fixed z-50 gap-4 bg-white p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500 dark:bg-slate-950",
33 | {
34 | variants: {
35 | side: {
36 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
37 | bottom:
38 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
39 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
40 | right:
41 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm"
42 | }
43 | },
44 | defaultVariants: {
45 | side: "right"
46 | }
47 | }
48 | );
49 |
50 | interface SheetContentProps
51 | extends React.ComponentPropsWithoutRef,
52 | VariantProps {}
53 |
54 | const SheetContent = React.forwardRef<
55 | React.ElementRef,
56 | SheetContentProps
57 | >(({ side = "right", className, children, ...props }, ref) => (
58 |
59 |
60 |
65 | {children}
66 |
67 |
68 | Close
69 |
70 |
71 |
72 | ));
73 | SheetContent.displayName = SheetPrimitive.Content.displayName;
74 |
75 | const SheetHeader = ({
76 | className,
77 | ...props
78 | }: React.HTMLAttributes) => (
79 |
86 | );
87 | SheetHeader.displayName = "SheetHeader";
88 |
89 | const SheetFooter = ({
90 | className,
91 | ...props
92 | }: React.HTMLAttributes) => (
93 |
100 | );
101 | SheetFooter.displayName = "SheetFooter";
102 |
103 | const SheetTitle = React.forwardRef<
104 | React.ElementRef,
105 | React.ComponentPropsWithoutRef
106 | >(({ className, ...props }, ref) => (
107 |
115 | ));
116 | SheetTitle.displayName = SheetPrimitive.Title.displayName;
117 |
118 | const SheetDescription = React.forwardRef<
119 | React.ElementRef,
120 | React.ComponentPropsWithoutRef
121 | >(({ className, ...props }, ref) => (
122 |
127 | ));
128 | SheetDescription.displayName = SheetPrimitive.Description.displayName;
129 |
130 | export {
131 | Sheet,
132 | SheetPortal,
133 | SheetOverlay,
134 | SheetTrigger,
135 | SheetClose,
136 | SheetContent,
137 | SheetHeader,
138 | SheetFooter,
139 | SheetTitle,
140 | SheetDescription
141 | };
142 |
--------------------------------------------------------------------------------
/components/shared/Command/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { type DialogProps } from "@radix-ui/react-dialog";
3 | import { Command as CommandPrimitive } from "cmdk";
4 | import { Search } from "lucide-react";
5 |
6 | import { cn } from "@/lib/utils";
7 | import { Dialog, DialogContent } from "@/components/shared/Dialog";
8 |
9 | const Command = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 | ));
22 | Command.displayName = CommandPrimitive.displayName;
23 |
24 | interface CommandDialogProps extends DialogProps {}
25 |
26 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => (
27 |
28 |
29 |
30 | {children}
31 |
32 |
33 |
34 | );
35 |
36 | const CommandInput = React.forwardRef<
37 | React.ElementRef,
38 | React.ComponentPropsWithoutRef
39 | >(({ className, ...props }, ref) => (
40 | // eslint-disable-next-line react/no-unknown-property
41 |
42 |
43 |
51 |
52 | ));
53 |
54 | CommandInput.displayName = CommandPrimitive.Input.displayName;
55 |
56 | const CommandList = React.forwardRef<
57 | React.ElementRef,
58 | React.ComponentPropsWithoutRef
59 | >(({ className, ...props }, ref) => (
60 |
65 | ));
66 |
67 | CommandList.displayName = CommandPrimitive.List.displayName;
68 |
69 | const CommandEmpty = React.forwardRef<
70 | React.ElementRef,
71 | React.ComponentPropsWithoutRef
72 | >((props, ref) => (
73 |
78 | ));
79 |
80 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
81 |
82 | const CommandGroup = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => (
86 |
94 | ));
95 |
96 | CommandGroup.displayName = CommandPrimitive.Group.displayName;
97 |
98 | const CommandSeparator = React.forwardRef<
99 | React.ElementRef,
100 | React.ComponentPropsWithoutRef
101 | >(({ className, ...props }, ref) => (
102 |
107 | ));
108 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
109 |
110 | const CommandItem = React.forwardRef<
111 | React.ElementRef,
112 | React.ComponentPropsWithoutRef
113 | >(({ className, ...props }, ref) => (
114 |
122 | ));
123 |
124 | CommandItem.displayName = CommandPrimitive.Item.displayName;
125 |
126 | const CommandShortcut = ({
127 | className,
128 | ...props
129 | }: React.HTMLAttributes) => (
130 |
137 | );
138 | CommandShortcut.displayName = "CommandShortcut";
139 |
140 | export {
141 | Command,
142 | CommandDialog,
143 | CommandInput,
144 | CommandList,
145 | CommandEmpty,
146 | CommandGroup,
147 | CommandItem,
148 | CommandShortcut,
149 | CommandSeparator
150 | };
151 |
--------------------------------------------------------------------------------
/components/shared/Toast/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ToastPrimitives from "@radix-ui/react-toast";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 | import { X } from "lucide-react";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const ToastProvider = ToastPrimitives.Provider;
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
24 |
25 | const toastVariants = cva(
26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border border-slate-200 p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-slate-800",
27 | {
28 | variants: {
29 | variant: {
30 | default:
31 | "border bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50",
32 | destructive:
33 | "destructive group border-red-500 bg-red-500 text-slate-50 dark:border-red-900 dark:bg-red-900 dark:text-slate-50"
34 | }
35 | },
36 | defaultVariants: {
37 | variant: "default"
38 | }
39 | }
40 | );
41 |
42 | const Toast = React.forwardRef<
43 | React.ElementRef,
44 | React.ComponentPropsWithoutRef &
45 | VariantProps
46 | >(({ className, variant, ...props }, ref) => (
47 |
52 | ));
53 | Toast.displayName = ToastPrimitives.Root.displayName;
54 |
55 | const ToastAction = React.forwardRef<
56 | React.ElementRef,
57 | React.ComponentPropsWithoutRef
58 | >(({ className, ...props }, ref) => (
59 |
67 | ));
68 | ToastAction.displayName = ToastPrimitives.Action.displayName;
69 |
70 | const ToastClose = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, ...props }, ref) => (
74 |
83 |
84 |
85 | ));
86 | ToastClose.displayName = ToastPrimitives.Close.displayName;
87 |
88 | const ToastTitle = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ));
98 | ToastTitle.displayName = ToastPrimitives.Title.displayName;
99 |
100 | const ToastDescription = React.forwardRef<
101 | React.ElementRef,
102 | React.ComponentPropsWithoutRef
103 | >(({ className, ...props }, ref) => (
104 |
109 | ));
110 | ToastDescription.displayName = ToastPrimitives.Description.displayName;
111 |
112 | type ToastProps = React.ComponentPropsWithoutRef;
113 |
114 | type ToastActionElement = React.ReactElement;
115 |
116 | export {
117 | type ToastProps,
118 | type ToastActionElement,
119 | ToastProvider,
120 | ToastViewport,
121 | Toast,
122 | ToastTitle,
123 | ToastDescription,
124 | ToastClose,
125 | ToastAction
126 | };
127 |
--------------------------------------------------------------------------------
/components/layout/Footer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | /* eslint-disable @next/next/no-img-element */
4 | /* eslint-disable no-unused-vars */
5 | import Link from "next/link";
6 | import React, { useEffect, useState } from "react";
7 | import Image from "next/image";
8 | import { Icon } from "@iconify/react";
9 | import { FOOTER_NAV_LINKS_ENUM } from "@/lib/enum";
10 | import IOSBadge from "@/public/assets/get-it-on/ios-badge.png";
11 | import AndroidBadge from "@/public/assets/get-it-on/android-badge.png";
12 | import BMCBadge from "@/public/assets/marketing/bmc-button.png";
13 | import {
14 | APP_STORE_LISTING_URL,
15 | BUY_ME_A_COFFEE_URL,
16 | PLAY_STORE_LISTING_URL
17 | } from "@/lib/config";
18 | import { FOOTER_NAV_LINKS_META, STATUS_CHECK_CACHE_KEY } from "@/lib/data";
19 | import SystemStatus from "@/components/shared/SystemStatus";
20 | import { useSystemStatus } from "@/lib/api/queries";
21 | import { getLocalStorageItem } from "@/lib/utils";
22 |
23 | interface FooterNavLinksProps {
24 | variation: FOOTER_NAV_LINKS_ENUM;
25 | className?: string;
26 | }
27 |
28 | const FooterNavLinks = ({ variation, className }: FooterNavLinksProps) => {
29 | const { heading, links } = FOOTER_NAV_LINKS_META[variation];
30 | return (
31 |
32 |
{heading}
33 |
34 | {links.map(({ href, text, external = false }, key) => {
35 | if (external) {
36 | return (
37 |
44 | {text}
45 |
49 |
50 | );
51 | }
52 | return (
53 |
58 | {text}
59 |
60 | );
61 | })}
62 |
63 |
64 | );
65 | };
66 |
67 | const Footer = () => {
68 | const { data, isLoading } = useSystemStatus();
69 | const [isClient, setIsClient] = useState(false);
70 | const cachedSystemStatus = JSON.parse(
71 | getLocalStorageItem(STATUS_CHECK_CACHE_KEY)
72 | )?.data;
73 | useEffect(() => {
74 | setIsClient(true);
75 | }, []);
76 |
77 | // to resolve the react rehydration error
78 | if (!isClient) {
79 | return null;
80 | }
81 | return (
82 |
179 | );
180 | };
181 |
182 | export default Footer;
183 |
--------------------------------------------------------------------------------
/lib/data.ts:
--------------------------------------------------------------------------------
1 | import {
2 | APP_STORE_LISTING_URL,
3 | BUY_ME_A_COFFEE_URL,
4 | CANNY_URL,
5 | GITHUB_URL,
6 | PLAY_STORE_LISTING_URL,
7 | REALM_ENCRYPTION_URL,
8 | SUPPORT_EMAIL,
9 | UPTIME_URL
10 | } from "@/lib/config";
11 | import { BENTO_BOX_ENUM, FOOTER_NAV_LINKS_ENUM } from "@/lib/enum";
12 |
13 | // LOCALSTORAGE KEY
14 | export const STATUS_CHECK_CACHE_KEY = "STATUS_CHECK_CACHE_KEY";
15 |
16 | // DATA
17 | export const FAQ_DATA = [
18 | {
19 | heading: "General",
20 | contents: [
21 | {
22 | id: "OZ514",
23 | qns: "What is Dolcent?",
24 | ans: "Dolcent is a powerful budget expense app designed to help you manage your finances and track your expenses with ease."
25 | },
26 | {
27 | id: "YOW2Y",
28 | qns: "Is Dolcent free to use?",
29 | ans: `Yes, Dolcent is completely free to use with no hidden fees or subscriptions. However, if you enjoy using Dolcent and would like to support its development, you can ${BUY_ME_A_COFFEE_URL} or send a tip via the app (iOS only)`
30 | },
31 | {
32 | id: "VNASO",
33 | qns: "Does Dolcent run ads?",
34 | ans: "No, Dolcent does not run ads."
35 | },
36 | {
37 | id: "TOCBB",
38 | qns: "Who built Dolcent?",
39 | ans: `Dolcent is developed by ${GITHUB_URL} , a student studying Computer Science in Singapore.`
40 | },
41 | {
42 | id: "PPWOV",
43 | qns: "What does Dolcent mean?",
44 | ans: `Dolcent is a fusion of the words "Dollars" and "Cents," symbolizing its core purpose of helping you manage your financial matters down to the last cent.`
45 | }
46 | ]
47 | },
48 | {
49 | heading: "Getting Started",
50 | contents: [
51 | {
52 | id: "BDW5W",
53 | qns: "How do I download and install Dolcent?",
54 | ans: `You can download Dolcent from the ${APP_STORE_LISTING_URL} or ${PLAY_STORE_LISTING_URL} , and then simply follow the installation instructions on your device.`
55 | },
56 | {
57 | id: "35MVA",
58 | qns: "Do I need to create an account to use Dolcent?",
59 | ans: "No, Dolcent does not require you to create an account. You can start using the app right away."
60 | }
61 | ]
62 | },
63 | {
64 | heading: "Features",
65 | contents: [
66 | {
67 | id: "5CBL4",
68 | qns: "What currencies does Dolcent support?",
69 | ans: "Dolcent supports over 140 currencies, allowing you to track your expenses no matter where you are. To view the list of supported currnecies, please Open Dolcent app > Settings > Exchange Rates"
70 | },
71 | {
72 | id: "BZ8E5",
73 | qns: "Is my data safe with Dolcent?",
74 | ans: `Absolutely, your data is securely stored on your device and is never uploaded to the cloud or accessible by the developer. Additionally, it's encrypted with ${REALM_ENCRYPTION_URL} , ensuring the highest level of security for your financial information.`
75 | },
76 | {
77 | id: "664XZ",
78 | qns: "Can I set a passcode or biometric lock for added security?",
79 | ans: "Yes, Dolcent offers enhanced security options. You can set a passcode lock and, if your device supports it, use biometric authentication such as Face ID or Touch ID for added protection. You can enable these security features in the app's settings"
80 | },
81 | {
82 | id: "K3Y1A",
83 | qns: "Can I use Dolcent on multiple devices?",
84 | ans: "Currently, Dolcent is designed to be used on a single device. However, you can export your data in JSON format and import it into Dolcent on another device."
85 | },
86 | {
87 | id: "KVY1A",
88 | qns: "Where can I find information about upcoming updates for Dolcent?",
89 | ans: `You can access our product roadmap via ${CANNY_URL} which provides an overview of all the features that are currently worked on and their respective statuses.`
90 | }
91 | ]
92 | },
93 | {
94 | heading: "Troubleshooting",
95 | contents: [
96 | {
97 | id: "S85PV",
98 | qns: "I'm having trouble with currency exchange updates. What should I do?",
99 | ans: `Please ensure you have an internet connection, and Dolcent will automatically update currency exchange rates daily. If you're still facing issues, contact support at ${SUPPORT_EMAIL}`
100 | },
101 | {
102 | id: "CKBL8",
103 | qns: "I forgot my passcode. How can I reset it?",
104 | ans: "If you are already logged in to Dolcent, you can reset your passcode by going to the settings page and turn Passcode off. Unfortunately, if you are at the lockscreen page, you will not be able to reset your passcode."
105 | },
106 | {
107 | id: "PAA8F",
108 | qns: "How do I report issues or submit feature requests?",
109 | ans: `For general support inquiries or concerns related to security, please feel free to reach out to us via email at ${SUPPORT_EMAIL} . If you'd like to submit user feedback or make a feature requests, you can use our feedback platform, ${CANNY_URL} and share your thoughts.`
110 | }
111 | ]
112 | }
113 | ];
114 |
115 | export const COMMAND_MENU_DATA = {
116 | mainNav: [
117 | {
118 | title: "Features",
119 | href: "/#features"
120 | },
121 | {
122 | title: "FAQ",
123 | href: "/faq"
124 | }
125 | ],
126 | faqNav: [
127 | {
128 | title: "FAQ",
129 | items: (() => {
130 | const newItemsArr = [];
131 | FAQ_DATA.forEach((oneFAQSection) =>
132 | oneFAQSection.contents.forEach((oneFAQ) =>
133 | newItemsArr.push({
134 | title: oneFAQ.qns,
135 | href: `/faq#${oneFAQ.id}`,
136 | items: []
137 | })
138 | )
139 | );
140 | return newItemsArr;
141 | })()
142 | }
143 | ]
144 | };
145 |
146 | export const FOOTER_NAV_LINKS_META = {
147 | [FOOTER_NAV_LINKS_ENUM.SUPPORT]: {
148 | heading: "Support",
149 | links: [
150 | {
151 | text: "FAQ",
152 | href: "/faq"
153 | },
154 | {
155 | text: "Contact",
156 | href: "/faq#contact"
157 | },
158 | {
159 | text: "System Status",
160 | href: UPTIME_URL,
161 | external: true
162 | },
163 | {
164 | text: "Feature Request",
165 | href: `${CANNY_URL}/feature-request`,
166 | external: true
167 | }
168 | ]
169 | },
170 | [FOOTER_NAV_LINKS_ENUM.PRODUCT]: {
171 | heading: "Product",
172 | links: [
173 | {
174 | text: "Roadmap",
175 | href: CANNY_URL,
176 | external: true
177 | },
178 | {
179 | text: "Sitemap",
180 | href: "/sitemap"
181 | }
182 | ]
183 | },
184 | [FOOTER_NAV_LINKS_ENUM.LEGAL]: {
185 | heading: "Legal",
186 | links: [
187 | {
188 | text: "Terms and Conditions",
189 | href: "/terms-and-conditions"
190 | },
191 | {
192 | text: "Privacy Policy",
193 | href: "/privacy-policy"
194 | }
195 | ]
196 | }
197 | };
198 |
199 | export const OVERVIEW_FEATURES_LIST = [
200 | {
201 | type: BENTO_BOX_ENUM.LONG_TEXT_LEFT,
202 | imageHref: "/assets/app/bento-1.png",
203 | heading: "Interactive Graphs ⚡",
204 | desc: "Visualize your cash flow, income, and expenses.",
205 | className: "bg-gray-200"
206 | },
207 | {
208 | type: BENTO_BOX_ENUM.LONG_TEXT_RIGHT,
209 | imageHref: "/assets/app/bento-2.png",
210 | heading: "See your Money in One Place 👀",
211 | desc: "Keep tabs on your balances, from cash to digital wallets.",
212 | className: "bg-gray-500",
213 | textClassName: "text-white"
214 | },
215 | {
216 | type: BENTO_BOX_ENUM.SMALL,
217 | content: [
218 | {
219 | type: BENTO_BOX_ENUM.SMALL_TEXT_LEFT,
220 | imageHref: "/assets/app/bento-3.png",
221 | heading: "Multi Currency Support 🌎",
222 | desc: "Track your finances in 140+ currencies.",
223 | className: "bg-gray-200"
224 | },
225 | {
226 | type: BENTO_BOX_ENUM.SMALL_TEXT_RIGHT,
227 | imageHref: "/assets/app/bento-4.png",
228 | heading: "Easily Add Transactions 🔥",
229 | desc: "Easily view and filter all your financial transactions.",
230 | className: "bg-gray-300"
231 | }
232 | ]
233 | },
234 | {
235 | type: BENTO_BOX_ENUM.LONG_TEXT_LEFT,
236 | imageHref: "/assets/app/bento-5.png",
237 | heading: "Know How Much You can Spend 💰",
238 | desc: "Set and manage budgets for a better financial future.",
239 | className: "bg-gray-100"
240 | }
241 | ];
242 |
--------------------------------------------------------------------------------