diff --git a/.DS_Store b/.DS_Store index a897700..db924df 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/Backend/.DS_Store b/Backend/.DS_Store new file mode 100644 index 0000000..21361e9 Binary files /dev/null and b/Backend/.DS_Store differ diff --git a/Dockerfile b/Backend/Dockerfile similarity index 100% rename from Dockerfile rename to Backend/Dockerfile diff --git a/README.md b/Backend/README.md similarity index 100% rename from README.md rename to Backend/README.md diff --git a/bin/app-amd64-linux b/Backend/bin/app-amd64-linux similarity index 100% rename from bin/app-amd64-linux rename to Backend/bin/app-amd64-linux diff --git a/go.mod b/Backend/go.mod similarity index 100% rename from go.mod rename to Backend/go.mod diff --git a/go.sum b/Backend/go.sum similarity index 100% rename from go.sum rename to Backend/go.sum diff --git a/main.go b/Backend/main.go similarity index 100% rename from main.go rename to Backend/main.go diff --git a/pb_bootstrap/pb_schema.json b/Backend/pb_bootstrap/pb_schema.json similarity index 100% rename from pb_bootstrap/pb_schema.json rename to Backend/pb_bootstrap/pb_schema.json diff --git a/script.sh b/Backend/script.sh similarity index 100% rename from script.sh rename to Backend/script.sh diff --git a/stripe_bootstrap/stripe-fixtures.json b/Backend/stripe_bootstrap/stripe-fixtures.json similarity index 100% rename from stripe_bootstrap/stripe-fixtures.json rename to Backend/stripe_bootstrap/stripe-fixtures.json diff --git a/Frontend/.eslintrc.json b/Frontend/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/Frontend/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/Frontend/.gitignore b/Frontend/.gitignore new file mode 100644 index 0000000..259e99a --- /dev/null +++ b/Frontend/.gitignore @@ -0,0 +1,38 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Local Netlify folder +.netlify diff --git a/Frontend/.nvmrc b/Frontend/.nvmrc new file mode 100644 index 0000000..016efd8 --- /dev/null +++ b/Frontend/.nvmrc @@ -0,0 +1 @@ +v20.10.0 \ No newline at end of file diff --git a/Frontend/README.md b/Frontend/README.md new file mode 100644 index 0000000..f4da3c4 --- /dev/null +++ b/Frontend/README.md @@ -0,0 +1,34 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/Frontend/app/(admin)/blogs/[slug]/page.tsx b/Frontend/app/(admin)/blogs/[slug]/page.tsx new file mode 100644 index 0000000..359bbf5 --- /dev/null +++ b/Frontend/app/(admin)/blogs/[slug]/page.tsx @@ -0,0 +1,84 @@ +import fs from "fs"; +import matter from "gray-matter"; +import BlogContent from "@/sections/BlogContent"; +import Link from "next/link"; +import Image from "next/image"; +import xButton from "@/images/icon-x.svg"; +import getPostMetadata from "@/utils/getPostMetaData"; +import { headers } from "next/headers"; +import React from "react"; + +const getPostContent = (slug: string) => { + const folder = "blogs/"; + const file = `${folder}${slug}.md`; + const content = fs.readFileSync(file, "utf8"); + const matterResult = matter(content); + return matterResult; +}; + +export async function generateMetadata({ + params, +}: { + params: { slug: string }; +}) { + const { slug } = params; + const headersList = headers(); + const siteURL = headersList.get("host"); + const post = getPostContent(slug); + + return { + title: `${post.data.title}`, + description: `${post.data.subtitle}`, + alternates: { + canonical: `https://${siteURL}/blogs/${slug}`, + }, + }; +} + +export const generateStaticParams = async () => { + const posts = getPostMetadata(); + return posts.map((post) => ({ + slug: post.slug, + })); +}; + +const PostPage = (props: any) => { + const slug = props.params.slug; + const post = getPostContent(slug); + const showModal = props.searchParams?.modal; + return ( + <> + {showModal ? ( +
+
+
+ + ← Back to Blogs + +
+ + close button + +
+
+
+ +
+
+
+ ) : ( +
+
+ +
+
+ )} + + ); +}; + +export default PostPage; diff --git a/Frontend/app/(admin)/blogs/page.tsx b/Frontend/app/(admin)/blogs/page.tsx new file mode 100644 index 0000000..5ae41e7 --- /dev/null +++ b/Frontend/app/(admin)/blogs/page.tsx @@ -0,0 +1,34 @@ +import PageHeader from "@/sections/PageHeader"; +import BlogCard from "@/components/BlogCard"; +import getPostMetadata from "@/utils/getPostMetaData"; +import React from "react"; + +export default function BlogsPage() { + const postMetadata = getPostMetadata(); + + const postPreviews = postMetadata + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + .map((post) => ); + return ( + <> + {/* Page sections */} + + {" "} +

+ Find case studies and information for how Sign365 is giving + businesses superpowers +

+ + } + /> +
+
+ {postPreviews} +
+
+ + ); +} diff --git a/Frontend/app/(admin)/layout.tsx b/Frontend/app/(admin)/layout.tsx new file mode 100644 index 0000000..b64798e --- /dev/null +++ b/Frontend/app/(admin)/layout.tsx @@ -0,0 +1,11 @@ +import React from "react"; + +function layout({ children }: { children: React.ReactNode }) { + return ( + <> + {children} + + ); +} + +export default layout; diff --git a/Frontend/app/(admin)/pricing/actions.ts b/Frontend/app/(admin)/pricing/actions.ts new file mode 100644 index 0000000..75e8a42 --- /dev/null +++ b/Frontend/app/(admin)/pricing/actions.ts @@ -0,0 +1,45 @@ +"use server"; + +import { Product, Price } from "@/types"; +import pb from "@/lib/pocketbase"; +import { getAuthCookie } from "@/app/(auth)/actions"; + +export async function apiPrices() { + console.log('prices') + try { + const pocketbaseUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL_STRING; + if (!pocketbaseUrl) { + throw Error('Connection Timeout'); + } + const productRequest = await fetch( + `${pocketbaseUrl}/api/collections/product/records?filter=(active=true)`, + { + cache: 'no-cache', + method: "GET", + headers: { + "Content-Type": "application/json", + }, + } + ); + const productResponse = await productRequest.json(); + const unsortedProducts: Product[] = productResponse.items; + const prices = await pb.collection("price").getFullList(); + for (const product of unsortedProducts) { + product.metadata.benefits = JSON.parse(product.metadata.benefits as any); + const pricesOfProduct = prices.filter(price => price.product_id === product.product_id); + for (const priceOfProduct of pricesOfProduct){ + if (priceOfProduct.interval === "year"){ + product.yearlyPrice = priceOfProduct; + } else { + product.monthlyPrice = priceOfProduct; + } + } + } + + const sortedProducts = unsortedProducts.sort((a: Product, b: Product) => a.product_order - b.product_order) + return sortedProducts; + } catch (error) { + console.log(error); + return []; + } +} \ No newline at end of file diff --git a/Frontend/app/(admin)/pricing/page.tsx b/Frontend/app/(admin)/pricing/page.tsx new file mode 100644 index 0000000..ea8a1c1 --- /dev/null +++ b/Frontend/app/(admin)/pricing/page.tsx @@ -0,0 +1,192 @@ +"use client"; + +import React, { useEffect } from "react"; +import PageHeader from "@/sections/PageHeader"; +import { Price, Product, SourceModal } from "@/types"; +import { Check } from "@styled-icons/entypo/Check"; +import { ChangeEventHandler, useState } from "react"; +import { apiPrices } from "./actions"; +import Newsletter from "@/sections/Newsletter/Newsletter"; +import { createCheckoutSession, isAuthenticated } from "@/app/(auth)/actions"; +import { toast } from "react-toastify"; +import { useRouter } from "next/navigation"; + +export default function PricingPage() { + const [isAnnual, setIsAnnual] = useState(false); + const [products, setProducts] = useState([]); + + const handleToggle = () => { + setIsAnnual((prev) => !prev); + }; + useEffect(() => { + (async () => { + const resposeProducts: Product[] = await apiPrices(); + setProducts(resposeProducts); + })(); + }, []); + + return ( + <> + + {" "} +

+ Select a subscription plan for your team or try advanced + functionality for free. +

+ + } + /> +
+ +
+
+
+ {products.map((x, i) => ( + + ))} +
+
+ + + ); +} + +function PriceCard({ + product, + isAnnual, +}: { + product: Product; + isAnnual: boolean; +}) { + const router = useRouter(); + const openSignUpModalOnPriceClick = (price: Price) => { + const signUpModal = document.getElementById("sign-up-modal"); + if (!signUpModal) return; + signUpModal.setAttribute("price_id", price.price_id); + signUpModal.setAttribute("name", SourceModal.SignUpViaPurchase); + signUpModal.click(); + }; + const generateCheckoutPage = async (price: Price) => { + try { + const checkoutSessionResponse = await createCheckoutSession( + price.price_id + ); + router.push(checkoutSessionResponse.url); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message, { + position: "bottom-left", + autoClose: 5000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + progress: undefined, + theme: "colored", + }); + } + } + }; + const submitForm = async (price: Price) => { + const userIsAuthenticated = await isAuthenticated(); + if (userIsAuthenticated) { + await generateCheckoutPage(price); + } else { + openSignUpModalOnPriceClick(price); + } + }; + return ( +
+
+
+ {false && ( +

+ Popular +

+ )} + +

+ {product.name} +

+

+ {product.description} +

+
+
+
    + {product.metadata?.benefits?.map((x, i) => ( +
  • + + +

    + {x} +

    +
  • + ))} +
+
+
+
+

+ $ + {isAnnual + ? product.yearlyPrice.unit_amount / 100 + : product.monthlyPrice.unit_amount / 100} +

+

+ per user per {isAnnual ? "year" : "month"} +

+
+ +
+
+
+ ); +} + +function PriceToggle({ + isAnnual, + onChange, +}: { + isAnnual: boolean; + onChange?: ChangeEventHandler; +}) { + return ( + <> + + + ); +} diff --git a/Frontend/app/(admin)/signup/page.tsx b/Frontend/app/(admin)/signup/page.tsx new file mode 100644 index 0000000..47e5da3 --- /dev/null +++ b/Frontend/app/(admin)/signup/page.tsx @@ -0,0 +1,37 @@ +"use client"; +import HeroHome from "@/sections/HeroHome"; +import FeaturesBlocks from "@/sections/FeaturesBlocks"; +import FeaturesZigZag from "@/sections/FeaturesZigzag"; +import Newsletter from "@/sections/Newsletter/Newsletter"; +import HeroVideo from "@/sections/HeroVideo"; +import Aos from "aos"; +import "aos/dist/aos.css"; +import { useEffect } from "react"; +import React from "react"; +import { SourceModal } from "@/types"; + +export default function Home() { + useEffect(() => { + Aos.init({ + delay: 50, + easing: "ease-out-cubic", + once: true, + offset: 50, + }); + }, []); + useEffect(() => { + document.getElementById("sign-up-modal")?.setAttribute("name", SourceModal.SignUp); + document.getElementById("sign-up-modal")?.removeAttribute("price_id"); + document.getElementById("sign-up-modal")?.click(); + }, []); + return ( + <> + {/* Page sections */} + + + + + + + ); +} diff --git a/Frontend/app/(auth)/account/page.tsx b/Frontend/app/(auth)/account/page.tsx new file mode 100644 index 0000000..ec3b7a5 --- /dev/null +++ b/Frontend/app/(auth)/account/page.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import PageHeader from "@/sections/PageHeader"; +import { cookies } from "next/headers"; +import { getUserFromCookie } from "@/lib/auth"; +import { User } from "@/types"; +import AccountContent from "@/sections/AccountContent"; +import { redirect } from "next/navigation"; +import pb from "@/lib/pocketbase"; + +export default async function AccountPage() { + const user = (await getUserFromCookie(cookies())) as User; + const cookie = cookies().get("pb_auth"); + //server side + pb.authStore.loadFromCookie(cookie?.value || ""); + !pb.authStore.isValid && redirect("/"); + return ( + user && ( + <> + } /> + + + + ) + ); +} diff --git a/Frontend/app/(auth)/actions.ts b/Frontend/app/(auth)/actions.ts new file mode 100644 index 0000000..067488c --- /dev/null +++ b/Frontend/app/(auth)/actions.ts @@ -0,0 +1,254 @@ +"use server"; + +import { redirect } from "next/navigation"; +import pb from "@/lib/pocketbase"; +import { cookies } from "next/headers"; +import CryptoJS from 'crypto-js'; + +import { CheckoutSession, SignUpForm, SourceModal, Subscription, SubscriptionSession, User } from "@/types"; +import { apiPrices } from "../(admin)/pricing/actions"; + +export async function mailchimp(formData: { + email: string; + first_name: string; + last_name: string; + phone_number?: string; + company_size: string; + source?: SourceModal; +}) { + const email = formData.email; + const mailchimpBaseUrl = process.env.NEXT_PUBLIC_MAILCHIMP_BASE_URL; + const mailchimpApiKey = process.env.NEXT_PUBLIC_MAILCHIMP_BASE64_API_KEY; + const mailchimpList = process.env.NEXT_PUBLIC_MAILCHIMP_LIST_ID; + if (!mailchimpApiKey) return; + try { + const subscriberHash = CryptoJS.MD5(email.toLocaleLowerCase()); + const mailchimpResponse = await fetch(`${mailchimpBaseUrl}/3.0/lists/${mailchimpList}/members/${subscriberHash}`, + { + method: 'PUT', + headers: { + "Content-Type": "application/json", + Authorization: mailchimpApiKey, + }, + body: JSON.stringify( + { + "email_address": email, + status: "subscribed", + merge_fields: { + EMAIL: formData.email, + FNAME: formData.first_name, + LNAME: formData.last_name, + PHONE: !formData.phone_number ? '' : formData.phone_number, + CSIZE: formData.company_size, + SOURCE: formData.source + } + } + ) + }) + if (mailchimpResponse.status !== 200) { + throw new Error("couldn't complete the request"); + } + return mailchimpResponse.json(); + } catch (err) { + throw new Error("couldn't complete the request"); + } +} + +export async function signup(formData: SignUpForm) { + const email = formData.email; + const password = formData.password; + const organisation = formData.organisation; + const pocketbaseUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL_STRING as string; + const adminToken = process.env.NEXT_PUBLIC_POCKETBASE_ADMIN_TOKEN as string; + try { + const orgRes = await fetch( + `${pocketbaseUrl}/api/collections/organisation/records`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: adminToken, + }, + body: JSON.stringify({ + name: organisation, + }), + } + ); + console.log("orgRes.status: ", orgRes.status); + if (orgRes.status !== 200) { + throw new Error("Failed to create organisation"); + } + const orgData = await orgRes.json(); + console.log(orgData); + const userRes = await fetch( + `${pocketbaseUrl}/api/collections/user/records`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: adminToken, + }, + body: JSON.stringify({ + firstName: formData.first_name, + lastName: formData.last_name, + displayName: formData.first_name + ' ' + formData.last_name, + email: email, + password: password, + passwordConfirm: password, + organisation: orgData?.id, + role: "Admin", + lastSeen: new Date(), + }), + } + ); + console.log("userRes.status: ", userRes.status); + if (userRes.status !== 200) { + console.log(userRes); + throw new Error("Failed to create user"); + } + } catch (err) { + if (err instanceof Error) { + throw new Error(err.message); + } + } +} + +export async function login(formData: { email: string; password: string }) { + console.log('login') + const email = formData.email as string; + const password = formData.password as string; + try { + const { token, record: data } = await pb + .collection("user") + .authWithPassword(email, password); + if (pb.authStore.isValid) { + const cookie = pb.authStore.exportToCookie(); + + cookies().set("pb_auth", cookie, { + secure: true, + path: "/", + sameSite: "strict", + httpOnly: true, + }); + } + return { success: true, error: "Failed to log in", token: token, data: data }; + } catch (error) { + throw error; + } +} + +export async function getAuthCookie() { + try { + const cookie = cookies().get('pb_auth'); + pb.authStore.loadFromCookie(cookie?.value || ''); + return pb.authStore.token; + } catch (error) { + return undefined; + } +} + +export async function isAuthenticated() { + try { + const cookie = cookies().get('pb_auth'); + if(!cookie) return false; + pb.authStore.loadFromCookie(cookie?.value || ''); + return pb.authStore.isValid || false; + } catch (error) { + return undefined; + } +} + +export async function logout() { + cookies().delete("pb_auth"); + redirect('/blogs'); +} + +export async function createCheckoutSession(price_id: string) { + const pocketbaseUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL_STRING; + if (!pocketbaseUrl) { + throw Error('Connection Timeout'); + } + if (!price_id) { + throw Error('There was an error during the payment processing'); + } + const token = await getAuthCookie(); + if (!token) { + throw Error('Could not authenticate'); + } + console.log('token', token); + console.log('url ', `${pocketbaseUrl}/create-checkout-session`); + try{ + const createCheckoutSessionResponse = await fetch( + `${pocketbaseUrl}/create-checkout-session`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: token, + }, + body: JSON.stringify({ + price: { + id: price_id, + type: "recurring" + }, + quantity: 1 + }), + } + ); + console.log('createCheckoutSessionResponse.status', createCheckoutSessionResponse.status) + if (createCheckoutSessionResponse.status !== 200) { + throw new Error("Failed to process Request"); + } + const createCheckoutSessionData: CheckoutSession = await createCheckoutSessionResponse.json(); + return createCheckoutSessionData; + } catch (error) { + throw error; + } +} + +export async function getSubscriptions() { + const cookie = cookies().get('pb_auth'); + pb.authStore.loadFromCookie(cookie?.value || ''); + const userId = (pb.authStore.model as User).id + const subscriptions = await pb.collection("subscription").getFullList({filter: `user_id="${userId}" && status="active"`}); + if (subscriptions.length === 0){ + return []; + } + const products = await apiPrices(); + const subscriptionWithProducts = subscriptions.map(subscription => {return {...subscription, product: products.find(product => product?.yearlyPrice?.price_id === subscription.price_id || product?.monthlyPrice?.price_id === subscription.price_id)}}) as Subscription[] + return subscriptionWithProducts; +} + +export async function createManagementSubscriptionSession() { + const pocketbaseUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL_STRING; + if (!pocketbaseUrl) { + throw Error('Connection Timeout'); + } + const token = await getAuthCookie(); + if (!token) { + throw Error('Could not authenticate'); + } + console.log('token', token); + console.log('url ', `${pocketbaseUrl}/create-checkout-session`); + try{ + const createManagementSessionResponse = await fetch( + `${pocketbaseUrl}/create-portal-link`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: token, + }, + body: JSON.stringify({}), + } + ); + console.log('createCheckoutSessionResponse.status', createManagementSessionResponse.status) + if (createManagementSessionResponse.status !== 200) { + throw new Error("Failed to process Request"); + } + const createManagementSessionData: SubscriptionSession = await createManagementSessionResponse.json(); + return createManagementSessionData; + } catch (error) { + throw error; + } +} \ No newline at end of file diff --git a/Frontend/app/favicon.ico b/Frontend/app/favicon.ico new file mode 100644 index 0000000..cea8d6a Binary files /dev/null and b/Frontend/app/favicon.ico differ diff --git a/Frontend/app/globals.css b/Frontend/app/globals.css new file mode 100644 index 0000000..dad9ac6 --- /dev/null +++ b/Frontend/app/globals.css @@ -0,0 +1,29 @@ + + +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; +} + +@media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + } +} + +body { + color: rgb(var(--foreground-rgb)); + background: linear-gradient( + to bottom, + transparent, + rgb(var(--background-end-rgb)) + ) + rgb(var(--background-start-rgb)); +} diff --git a/Frontend/app/layout.tsx b/Frontend/app/layout.tsx new file mode 100644 index 0000000..7476d5d --- /dev/null +++ b/Frontend/app/layout.tsx @@ -0,0 +1,77 @@ +import "./globals.css"; +import type { Metadata } from "next"; +import "@/styles/style.css"; +import { Arimo, Raleway } from "next/font/google"; +import { ToastContainer, toast } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; +import Footer from "@/components/Footer"; +import Header from "@/components/Header"; +import { cookies } from "next/headers"; +import { isAuthenticated } from "@/lib/auth"; +import Script from "next/script"; +import React from "react"; +import { PHProvider } from "./providers"; +import GoogleAnalytics from "@/components/GoogleAnalytics"; +import PrelineScript from "@/components/PrelineScript"; + +const raleway = Raleway({ + variable: "--display-font", + subsets: ["latin"], +}); + +const arimo = Arimo({ + variable: "--body-font", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Sign365", + description: + "Sign365 is your window into freedom from paper work. Get the paper work to do itself", +}; + +export default async function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + const isUserLoggedIn = await isAuthenticated(cookies()); + return ( + + + + {process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS ? ( + + ) : null} +
+ {/* Page content */} +
+ {/* Site header */} +
+
+ + {children} +
+
+
+ + + {/* Site footer */} +