diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index a36cde0..984afab 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,4 +1,7 @@
+import { PropsWithChildren } from "react";
+import LandingLayout from "@/components/Landing/LandingLayout";
import type { Metadata } from "next";
+import { twMerge } from "tailwind-merge";
import localFont from "next/font/local";
import "./globals.css";
@@ -14,21 +17,23 @@ const geistMono = localFont({
});
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: "Next.js Polar starter kit",
+ description: "An easy to use setup for polar.sh with Next.js",
};
-export default function RootLayout({
- children,
-}: Readonly<{
- children: React.ReactNode;
-}>) {
+export const dynamic = "force-static";
+export const dynamicParams = false;
+
+export default function Layout({ children }: PropsWithChildren) {
return (
-
+
- {children}
+ {children}
);
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 6fe62d1..79af392 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,101 +1,42 @@
-import Image from "next/image";
+"use client";
-export default function Home() {
- return (
-
-
-
-
- -
- Get started by editing{" "}
-
- src/app/page.tsx
-
- .
-
- - Save and see your changes instantly.
-
+import { Section } from "@/components/Landing/Section";
+
+// import GetStartedButton from "@/components/Auth/GetStartedButton";
+// import Button from "@/components/ui/atoms/button";
-
-
-
- Deploy now
-
-
- Read our docs
-
+export default function Page() {
+ return (
+
+
+
+
+
+
+
+
+
+ Next.js Polar starter kit
+
+
+
+ An easy to use setup for polar.sh with Next.js
+
+
+
+
+
-
-
+
);
}
diff --git a/src/components/Auth/GetStartedButton.tsx b/src/components/Auth/GetStartedButton.tsx
new file mode 100644
index 0000000..5e698f6
--- /dev/null
+++ b/src/components/Auth/GetStartedButton.tsx
@@ -0,0 +1,45 @@
+import { CONFIG } from "@/utils/config";
+import Link from "next/link";
+import Button from "@/components/ui/atoms/button";
+import { ComponentProps } from "react";
+import { twMerge } from "tailwind-merge";
+import { ArrowRight } from "lucide-react";
+
+interface GetStartedButtonProps extends ComponentProps
{
+ text?: string;
+ href?: string;
+}
+
+const GetStartedButton: React.FC = ({
+ text: _text,
+ href: _href,
+ wrapperClassNames,
+ size = "lg",
+ ...props
+}) => {
+ const text = _text || "Get started";
+
+ const signupPath = `${CONFIG.FRONTEND_BASE_URL}/signup?return_to/dashboard`;
+ const href = _href ? _href : signupPath;
+
+ return (
+
+
+
+ );
+};
+
+export default GetStartedButton;
diff --git a/src/components/Brand/LogoIcon.tsx b/src/components/Brand/LogoIcon.tsx
new file mode 100644
index 0000000..af864b0
--- /dev/null
+++ b/src/components/Brand/LogoIcon.tsx
@@ -0,0 +1,41 @@
+import { twMerge } from "tailwind-merge";
+
+const LogoIcon = ({
+ className,
+ size = 29,
+}: {
+ className?: string;
+ size?: number;
+}) => {
+ return (
+
+ );
+};
+
+export default LogoIcon;
diff --git a/src/components/Brand/LogoType.tsx b/src/components/Brand/LogoType.tsx
new file mode 100644
index 0000000..30445b3
--- /dev/null
+++ b/src/components/Brand/LogoType.tsx
@@ -0,0 +1,63 @@
+import { twMerge } from "tailwind-merge";
+
+const LogoType = ({
+ className,
+ width,
+ height,
+}: {
+ className?: string;
+ width?: number;
+ height?: number;
+}) => {
+ return (
+
+ );
+};
+
+export default LogoType;
diff --git a/src/components/Landing/LandingLayout.tsx b/src/components/Landing/LandingLayout.tsx
new file mode 100644
index 0000000..3eef629
--- /dev/null
+++ b/src/components/Landing/LandingLayout.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { Section } from "@/components/Landing/Section";
+import { TopbarNavigation } from "@/components/Landing/TopbarNavigation";
+import { BrandingMenu } from "@/components/Layout/Public/BrandingMenu";
+import Footer from "@/components/Organization/Footer";
+// import { motion } from "framer-motion";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import Button from "@/components/ui/atoms/button";
+import { PropsWithChildren, useEffect } from "react";
+
+export default function Layout({ children }: PropsWithChildren) {
+ const pathname = usePathname();
+
+ useEffect(() => {
+ window.scroll(0, 0);
+ }, [pathname]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+const LandingPageTopbar = () => {
+ return (
+
+ );
+};
+
+const LandingPageFooter = () => {
+ return (
+ //
+
+ //
+ );
+};
diff --git a/src/components/Landing/Section.tsx b/src/components/Landing/Section.tsx
new file mode 100644
index 0000000..acda700
--- /dev/null
+++ b/src/components/Landing/Section.tsx
@@ -0,0 +1,34 @@
+import { PropsWithChildren } from "react";
+import { twMerge } from "tailwind-merge";
+
+export type SectionProps = PropsWithChildren<{
+ id?: string;
+ className?: string;
+ wrapperClassName?: string;
+}>;
+
+export const Section = ({
+ id,
+ className,
+ wrapperClassName,
+ children,
+}: SectionProps) => {
+ return (
+
+ );
+};
diff --git a/src/components/Landing/TopbarNavigation.tsx b/src/components/Landing/TopbarNavigation.tsx
new file mode 100644
index 0000000..b687bcb
--- /dev/null
+++ b/src/components/Landing/TopbarNavigation.tsx
@@ -0,0 +1,222 @@
+import useDebouncedCallback from "@/hooks/utils";
+// import { ArrowForward } from "@mui/icons-material";
+import Link from "next/link";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { useState } from "react";
+
+const PopoverLinkItem = ({
+ title,
+ description,
+ link,
+}: {
+ title: string;
+ description: string;
+ link: string;
+}) => {
+ return (
+
+
+ {/* */}
+ {title}
+
+
+ {description}
+
+
+ );
+};
+
+const PlatformPopover = () => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const togglePopover = useDebouncedCallback((toggle: boolean) => {
+ setIsOpen(toggle);
+ }, 100);
+
+ return (
+
+ {
+ togglePopover(true);
+ }}
+ onMouseLeave={() => {
+ togglePopover(false);
+ }}
+ >
+ Platform
+
+
+
+
Offer Built-in Benefits
+
+
+
+
+
+ );
+};
+
+const DocumentationPopover = () => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const togglePopover = useDebouncedCallback((toggle: boolean) => {
+ setIsOpen(toggle);
+ }, 100);
+
+ return (
+
+ {
+ togglePopover(true);
+ }}
+ onMouseLeave={() => {
+ togglePopover(false);
+ }}
+ >
+ Docs
+
+
+
+
+
+
+ );
+};
+
+export const TopbarNavigation = () => {
+ return (
+
+ );
+};
diff --git a/src/components/Layout/Public/BrandingMenu.tsx b/src/components/Layout/Public/BrandingMenu.tsx
new file mode 100644
index 0000000..821bac2
--- /dev/null
+++ b/src/components/Layout/Public/BrandingMenu.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import LogoIcon from "@/components/Brand/LogoIcon";
+// import {
+// ArrowDownwardOutlined,
+// ContentPasteOutlined,
+// } from "@mui/icons-material";
+import Link from "next/link";
+
+import LogoType from "@/components/Brand/LogoType";
+import { useOutsideClick } from "@/utils/useOutsideClick";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { MouseEventHandler, useCallback, useRef, useState } from "react";
+import { twMerge } from "tailwind-merge";
+import { ArrowDown, ClipboardPasteIcon } from "lucide-react";
+
+export const BrandingMenu = ({
+ logoVariant = "icon",
+ size,
+ className,
+ logoClassName,
+}: {
+ logoVariant?: "icon" | "logotype";
+ size?: number;
+ className?: string;
+ logoClassName?: string;
+}) => {
+ const brandingMenuRef = useRef(null);
+
+ useOutsideClick([brandingMenuRef], () => setBrandingMenuOpen(false));
+
+ const [brandingMenuOpen, setBrandingMenuOpen] = useState(false);
+
+ const handleTriggerClick: MouseEventHandler = useCallback(
+ (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setBrandingMenuOpen(true);
+ },
+ []
+ );
+
+ const handleCopyLogoToClipboard = useCallback(() => {
+ navigator.clipboard.writeText(
+ logoVariant === "icon" ? PolarIconSVGString : PolarLogoSVGString
+ );
+ setBrandingMenuOpen(false);
+ }, [logoVariant]);
+
+ return (
+
+
+
+
+ {logoVariant === "logotype" ? (
+
+ ) : (
+
+ )}
+
+
+
+ Platform
+
+
+ Copy Logo as SVG
+
+ setBrandingMenuOpen(false)}
+ >
+
+
+ Download Branding Assets
+
+
+
+
+
+ );
+};
+
+const PolarIconSVGString = ``;
+
+const PolarLogoSVGString = `
+`;
diff --git a/src/components/Organization/Footer.tsx b/src/components/Organization/Footer.tsx
new file mode 100644
index 0000000..e59c2e8
--- /dev/null
+++ b/src/components/Organization/Footer.tsx
@@ -0,0 +1,108 @@
+import LogoType from "@/components/Brand/LogoType";
+import Link, { LinkProps } from "next/link";
+import { PropsWithChildren } from "react";
+import { twMerge } from "tailwind-merge";
+
+const Footer = ({ wide }: { wide?: boolean }) => {
+ return (
+
+
+
+
+
+
+
+ © Polar Software Inc. {new Date().getFullYear()}
+
+
+
+
+
Platform
+
+
+ Create an Account
+
+
+ Issue Funding
+
+
+ Products & Subscriptions
+
+
+ Donations
+
+
+ Newsletters
+
+
+
+
+
Company
+
+ Careers
+ Blog
+
+ Brand Assets
+
+
+ Terms of Service
+
+
+ Privacy Policy
+
+
+
+
+
Community
+
+
+ Join our Discord
+
+
+ GitHub
+
+ X / Twitter
+
+
+
+
Support
+
+ Docs
+
+ FAQ
+
+ Contact
+
+
+
+
+
+ );
+};
+
+export default Footer;
+
+const FooterLink = (props: PropsWithChildren) => {
+ return (
+
+ {props.children}
+
+ );
+};
diff --git a/src/components/ui/atoms/button.tsx b/src/components/ui/atoms/button.tsx
new file mode 100644
index 0000000..fbafd24
--- /dev/null
+++ b/src/components/ui/atoms/button.tsx
@@ -0,0 +1,144 @@
+import { cva } from "class-variance-authority";
+import React from "react";
+import { twMerge } from "tailwind-merge";
+import { ButtonProps, Button as ShadcnButton } from "../button";
+
+const buttonVariants = cva(
+ "relative font-normal inline-flex items-center select-none justify-center rounded-full text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 whitespace-nowrap",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-blue-500 text-white hover:opacity-85 transition-opacity duration-100",
+ destructive:
+ "bg-red-500 dark:bg-red-600 text-white hover:bg-red-400 dark:hover:bg-red-500",
+ outline:
+ "text-blue-500 dark:text-polar-200 hover:bg-blue-50 dark:bg-transparent dark:hover:bg-polar-700 border-transparent hover:border-blue-100 border dark:border-polar-700 bg-transparent border-blue-100",
+ secondary:
+ "text-blue-500 dark:text-polar-200 hover:bg-blue-100 dark:bg-polar-700 dark:hover:bg-polar-600 bg-blue-50 border-transparent",
+ ghost:
+ "text-blue-500 dark:text-blue-400 bg-transparent hover:bg-transparent dark:hover:bg-transparent",
+ link: "text-blue-400 underline-offset-4 hover:underline bg-transparent hover:bg-transparent",
+ },
+ size: {
+ default: "h-8 px-4 py-1.5 rounded-full text-sm",
+ sm: "h-7 rounded-full px-3 py-1.5 text-xs",
+ lg: "h-10 rounded-full px-5 py-4 text-sm",
+ icon: "flex items-center justify-center h-8 h-8 p-2 text-sm",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+const Button = React.forwardRef<
+ HTMLButtonElement,
+ ButtonProps & {
+ loading?: boolean;
+ fullWidth?: boolean;
+ wrapperClassNames?: string;
+ }
+>(
+ (
+ {
+ className,
+ wrapperClassNames,
+ variant,
+ size,
+ loading,
+ fullWidth,
+ disabled,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ return (
+
+ {loading ? (
+ <>
+
+
+
+
+ {children}
+
+ >
+ ) : (
+
+ {children}
+
+ )}
+
+ );
+ }
+);
+
+Button.displayName = ShadcnButton.displayName;
+
+export default Button;
+
+const LoadingSpinner = (props: {
+ disabled?: boolean;
+ size: ButtonProps["size"];
+}) => {
+ const classes = twMerge(
+ props.disabled ? "fill-white text-white/20" : "fill-white text-blue-300",
+ props.size === "default" || "large" ? "h-4 w-4" : "h-2 w-2",
+ "animate-spin"
+ );
+
+ return (
+ <>
+
+ >
+ );
+};
+
+export const RawButton = React.forwardRef(
+ ({ className, variant, size, children, ...props }, ref) => {
+ return (
+
+ {children}
+
+ );
+ }
+);
+
+RawButton.displayName = "RawButton";
+
+export type { ButtonProps };
diff --git a/src/hooks/utils.tsx b/src/hooks/utils.tsx
new file mode 100644
index 0000000..81e8551
--- /dev/null
+++ b/src/hooks/utils.tsx
@@ -0,0 +1,26 @@
+import React from "react";
+
+export const useDebouncedCallback = (
+ callback: Function,
+ delay: number,
+ dependencies?: any[]
+) => {
+ const timeout = React.useRef();
+
+ // Avoid error about spreading non-iterable (undefined)
+ const comboDeps = dependencies
+ ? [callback, delay, ...dependencies]
+ : [callback, delay];
+
+ return React.useCallback((...args: any[]) => {
+ if (timeout.current != null) {
+ clearTimeout(timeout.current);
+ }
+
+ timeout.current = setTimeout(() => {
+ callback(...args);
+ }, delay);
+ }, comboDeps);
+};
+
+export default useDebouncedCallback;
diff --git a/src/utils/config.ts b/src/utils/config.ts
new file mode 100644
index 0000000..4810a97
--- /dev/null
+++ b/src/utils/config.ts
@@ -0,0 +1,55 @@
+const stringToNumber = (
+ value: string | undefined,
+ fallback: number
+): number => {
+ if (value === undefined) return fallback;
+ return parseInt(value);
+};
+
+/*
+ * Keys sorted by their countries name in English.
+ *
+ * Generated list from Stripe console settings using:
+ *
+ * let whitelist = []
+ * document.querySelectorAll('.PressableContext').forEach((d) => { if (d.checked && d.name !== '') { whitelist.push(d.name) } })
+ * whitelist.join(',')
+ *
+ * All countries supported by Stripe except Gibraltar (transfers not supported)
+ *
+ */
+const STRIPE_COUNTRIES =
+ "AL,AG,AR,AM,AU,AT,BH,BE,BO,BA,BG,KH,CA,CL,CO,CR,HR,CY,CZ,CI,DK,DO,EC,EG,SV,EE,ET,FI,FR,GM,DE,GH,GR,GT,GY,HK,HU,IS,IN,ID,IE,IL,IT,JM,JP,JO,KE,KW,LV,LI,LT,LU,MO,MG,MY,MT,MU,MX,MD,MN,MA,NA,NL,NZ,NG,MK,NO,OM,PA,PY,PE,PH,PL,PT,QA,RO,RW,SA,SN,RS,SG,SK,SI,ZA,KR,ES,LK,LC,SE,CH,TZ,TH,TT,TN,TR,AE,GB,US,UY,UZ,VN,DZ,AO,AZ,BS,BD,BJ,BT,BW,BN,GA,KZ,LA,MC,MZ,NE,PK,SM,TW";
+
+let defaults = {
+ ENVIRONMENT:
+ process.env.VERCEL_ENV ||
+ process.env.NEXT_PUBLIC_VERCEL_ENV ||
+ "development",
+ FRONTEND_BASE_URL:
+ process.env.NEXT_PUBLIC_FRONTEND_BASE_URL || "http://127.0.0.1:3000",
+ BASE_URL: process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8000",
+ LOGIN_PATH: process.env.NEXT_PUBLIC_LOGIN_PATH || "/login",
+ GITHUB_APP_NAMESPACE:
+ process.env.NEXT_PUBLIC_GITHUB_APP_NAMESPACE || "polar-sh",
+ LOCALSTORAGE_PERSIST_KEY:
+ process.env.NEXT_PUBLIC_LOCALSTORAGE_PERSIST_KEY || "polar",
+ LOCALSTORAGE_PERSIST_VERSION: stringToNumber(
+ process.env.NEXT_PUBLIC_LOCALSTORAGE_PERSIST_VERSION,
+ 6
+ ),
+ GITHUB_BADGE_EMBED_DEFAULT_LABEL:
+ process.env.NEXT_PUBLIC_GITHUB_BADGE_EMBED_DEFAULT_LABEL || "Fund",
+ SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN || undefined,
+ POSTHOG_TOKEN: process.env.NEXT_PUBLIC_POSTHOG_TOKEN || "",
+ STRIPE_COUNTRIES_WHITELIST_CSV:
+ process.env.NEXT_PUBLIC_STRIPE_COUNTRIES_WHITELIST || STRIPE_COUNTRIES,
+ APPLE_DOMAIN_ASSOCIATION:
+ process.env.NEXT_PUBLIC_APPLE_DOMAIN_ASSOCIATION ||
+ "",
+};
+
+export const CONFIG = {
+ ...defaults,
+ GITHUB_INSTALLATION_URL: `https://github.com/apps/${defaults.GITHUB_APP_NAMESPACE}/installations/new`,
+};
diff --git a/src/utils/useOutsideClick.tsx b/src/utils/useOutsideClick.tsx
new file mode 100644
index 0000000..c610d8c
--- /dev/null
+++ b/src/utils/useOutsideClick.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { RefObject, useEffect } from "react";
+
+/**
+ * Hook that handles outside click event of the passed refs
+ *
+ * @param refs array of refs
+ * @param handler a handler function to be called when clicked outside
+ */
+export function useOutsideClick(
+ refs: Array | undefined>,
+ handler?: () => void
+) {
+ useEffect(() => {
+ function handleClickOutside(event: any) {
+ if (!handler) return;
+
+ // Clicked browser's scrollbar
+ if (
+ event.target === document.getElementsByTagName("html")[0] &&
+ event.clientX >= document.documentElement.offsetWidth
+ )
+ return;
+
+ let containedToAnyRefs = false;
+ for (const rf of refs) {
+ if (rf && rf.current && rf.current.contains(event.target)) {
+ containedToAnyRefs = true;
+ break;
+ }
+ }
+
+ // Not contained to any given refs
+ if (!containedToAnyRefs) {
+ handler();
+ }
+ }
+
+ // Bind the event listener
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ // Unbind the event listener on clean up
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, [refs, handler]);
+}