├── bun.lockb
├── public
└── favicon.ico
├── .prettierrc.json
├── postcss.config.js
├── next.config.js
├── .vscode
└── settings.json
├── app
├── api
│ └── comments
│ │ └── route.tsx
├── signup
│ └── page.tsx
├── forgot-password
│ └── page.tsx
├── verify
│ └── page.tsx
├── dashboard
│ └── page.tsx
├── login
│ └── page.tsx
├── contact
│ └── page.tsx
├── reset-password
│ └── page.tsx
├── events
│ ├── [slug]
│ │ └── page.tsx
│ └── page.tsx
├── blog
│ ├── [slug]
│ │ └── page.tsx
│ └── page.tsx
├── services
│ ├── [slug]
│ │ └── page.tsx
│ └── page.tsx
├── actions
│ └── checkout.ts
├── page.tsx
├── about
│ └── page.tsx
├── work
│ ├── page.tsx
│ └── [slug]
│ │ └── page.tsx
├── globals.css
└── layout.tsx
├── components
├── theme-provider.tsx
├── tailwind-indicator.tsx
├── theme-toggle.tsx
├── Header.tsx
├── project-card.tsx
├── Footer.tsx
├── Banner.tsx
└── ui
│ └── dropdown-menu.tsx
├── components.json
├── cosmic
├── client.ts
├── blocks
│ ├── image-gallery
│ │ ├── ImageGallery.tsx
│ │ └── ImageGalleryClient.tsx
│ ├── events
│ │ ├── EventsList.tsx
│ │ ├── EventCard.tsx
│ │ └── SingleEvent.tsx
│ ├── ecommerce
│ │ ├── ProductList.tsx
│ │ ├── CartProvider.tsx
│ │ ├── ProductCard.tsx
│ │ ├── AddToCart.tsx
│ │ ├── SingleProduct.tsx
│ │ └── CheckOut.tsx
│ ├── blog
│ │ ├── BlogList.tsx
│ │ ├── BlogCard.tsx
│ │ └── SingleBlog.tsx
│ ├── team
│ │ ├── TeamList.tsx
│ │ └── TeamCard.tsx
│ ├── testimonials
│ │ ├── Testimonials.tsx
│ │ └── Testimonial.tsx
│ ├── faqs
│ │ ├── FAQs.tsx
│ │ └── Accordion.tsx
│ ├── user-management
│ │ ├── AuthButtons.tsx
│ │ ├── AuthContext.tsx
│ │ ├── VerifyClient.tsx
│ │ ├── LoginClient.tsx
│ │ ├── SignUpClient.tsx
│ │ ├── ForgotPasswordForm.tsx
│ │ ├── DashboardClient.tsx
│ │ ├── ResetPasswordForm.tsx
│ │ ├── AuthForm.tsx
│ │ ├── UserProfileForm.tsx
│ │ └── actions.ts
│ ├── navigation-menu
│ │ ├── NavMenu.tsx
│ │ └── MobileNav.tsx
│ ├── contact-form
│ │ ├── actions.tsx
│ │ └── ContactForm.tsx
│ ├── comments
│ │ ├── Comments.tsx
│ │ └── CommentForm.tsx
│ └── pages
│ │ ├── Page.tsx
│ │ └── PageSection.tsx
├── elements
│ ├── Label.tsx
│ ├── TextArea.tsx
│ ├── Input.tsx
│ └── Button.tsx
└── utils.ts
├── .env.example
├── .gitignore
├── tsconfig.json
├── package.json
├── tailwind.config.js
└── README.md
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cosmicjs/agency-template/main/bun.lockb
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cosmicjs/agency-template/main/public/favicon.ico
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "tabWidth": 2,
4 | "semi": true,
5 | "singleQuote": false
6 | }
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | domains: ["imgix.cosmicjs.com"], // Add your Cosmic CDN domain
5 | },
6 | };
7 |
8 | module.exports = nextConfig;
9 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "esbenp.prettier-vscode",
3 | "editor.formatOnSave": true,
4 | "editor.formatOnPaste": false,
5 | "prettier.useEditorConfig": false,
6 | "prettier.useTabs": false,
7 | "prettier.configPath": ".prettierrc.json"
8 | }
9 |
--------------------------------------------------------------------------------
/app/api/comments/route.tsx:
--------------------------------------------------------------------------------
1 | // app/api/comments/route.ts
2 | import { type NextRequest } from "next/server";
3 | import { cosmic } from "@/cosmic/client";
4 |
5 | export async function POST(request: NextRequest) {
6 | const res = await request.json();
7 | const data = await cosmic.objects.insertOne(res.comment);
8 | return Response.json(data);
9 | }
10 |
--------------------------------------------------------------------------------
/app/signup/page.tsx:
--------------------------------------------------------------------------------
1 | import SignUpClient from "@/cosmic/blocks/user-management/SignUpClient";
2 | import { signUp } from "@/cosmic/blocks/user-management/actions";
3 |
4 | export default function SignUpPage() {
5 | return (
6 |
7 |
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ThemeProvider as NextThemesProvider } from "next-themes";
5 | import { type ThemeProviderProps } from "next-themes/dist/types";
6 |
7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 | return {children};
9 | }
10 |
--------------------------------------------------------------------------------
/app/forgot-password/page.tsx:
--------------------------------------------------------------------------------
1 | import ForgotPasswordForm from "@/cosmic/blocks/user-management/ForgotPasswordForm";
2 | import { forgotPassword } from "@/cosmic/blocks/user-management/actions";
3 |
4 | export default function ForgotPasswordPage() {
5 | return (
6 |
7 |
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "app/globals.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/cosmic/utils"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/app/verify/page.tsx:
--------------------------------------------------------------------------------
1 | // app/verify/page.tsx
2 | import { Suspense } from "react";
3 | import VerifyClient from "@/cosmic/blocks/user-management/VerifyClient";
4 | import { Loader2 } from "lucide-react";
5 |
6 | export default function VerifyPage() {
7 | return (
8 |
11 | }
12 | >
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/cosmic/client.ts:
--------------------------------------------------------------------------------
1 | import { createBucketClient } from "@cosmicjs/sdk";
2 |
3 | // Make sure to add/update your ENV variables
4 | export const cosmic = createBucketClient({
5 | bucketSlug: process.env.COSMIC_BUCKET_SLUG || "COSMIC_BUCKET_SLUG",
6 | readKey: process.env.COSMIC_READ_KEY || "COSMIC_READ_KEY",
7 | writeKey: process.env.COSMIC_WRITE_KEY || "COSMIC_WRITE_KEY",
8 | apiEnvironment:
9 | (process.env.COSMIC_API_ENVIRONMENT as "production" | "staging") ||
10 | "production",
11 | });
12 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # .env.local
2 | COSMIC_BUCKET_SLUG=change_to_your_bucket_slug
3 | COSMIC_READ_KEY=change_to_your_bucket_read_key
4 | COSMIC_WRITE_KEY=change_to_your_bucket_write_key
5 |
6 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=change_to_your_stripe_public_key
7 | STRIPE_SECRET_KEY=change_to_your_stripe_secret_key
8 |
9 | RESEND_API_KEY=change_to_your_resend_api_key
10 | NEXT_PUBLIC_APP_URL=change_to_your_app_url
11 | NEXT_PUBLIC_APP_NAME="Change to your app name"
12 | SUPPORT_EMAIL=change_to_your_support_email
13 | CONTACT_EMAIL=change_to_your_contact_email
--------------------------------------------------------------------------------
/.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 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/app/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | // app/dashboard/page.tsx
2 | import { Suspense } from "react";
3 | import DashboardClient from "@/cosmic/blocks/user-management/DashboardClient";
4 | import { Loader2 } from "lucide-react";
5 |
6 | export default function DashboardPage() {
7 | return (
8 |
9 |
12 | }
13 | >
14 |
15 |
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/app/login/page.tsx:
--------------------------------------------------------------------------------
1 | // app/login/page.tsx
2 | import { Suspense } from "react";
3 | import LoginClient from "@/cosmic/blocks/user-management/LoginClient";
4 | import { login } from "@/cosmic/blocks/user-management/actions";
5 | import { Loader2 } from "lucide-react";
6 |
7 | export default function LoginPage() {
8 | return (
9 |
10 |
13 | }
14 | >
15 |
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/app/contact/page.tsx:
--------------------------------------------------------------------------------
1 | // app/contact/page.tsx
2 | import { ContactForm } from "@/cosmic/blocks/contact-form/ContactForm";
3 | export default async function ContactPage() {
4 | return (
5 |
6 |
7 |
8 |
9 | Contact
10 |
11 |
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/app/reset-password/page.tsx:
--------------------------------------------------------------------------------
1 | // app/reset-password/page.tsx
2 | import { redirect } from "next/navigation";
3 | import ResetPasswordForm from "@/cosmic/blocks/user-management/ResetPasswordForm";
4 | import { resetPassword } from "@/cosmic/blocks/user-management/actions";
5 |
6 | export default function ResetPasswordPage({
7 | searchParams,
8 | }: {
9 | searchParams: { token?: string };
10 | }) {
11 | const token = searchParams.token;
12 |
13 | if (!token) {
14 | redirect("/login");
15 | }
16 |
17 | return (
18 |
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/components/tailwind-indicator.tsx:
--------------------------------------------------------------------------------
1 | export function TailwindIndicator() {
2 | if (process.env.NODE_ENV === "production") return null;
3 |
4 | return (
5 |
6 |
xs
7 |
sm
8 |
md
9 |
lg
10 |
xl
11 |
2xl
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/cosmic/blocks/image-gallery/ImageGallery.tsx:
--------------------------------------------------------------------------------
1 | import { ImageGalleryClient } from "./ImageGalleryClient";
2 | import { cosmic } from "@/cosmic/client";
3 |
4 | export async function ImageGallery({
5 | query,
6 | className,
7 | status,
8 | }: {
9 | query: any;
10 | className?: string;
11 | status?: "draft" | "published" | "any";
12 | }) {
13 | const { object: page } = await cosmic.objects
14 | .findOne(query)
15 | .props("slug,title,metadata")
16 | .depth(1)
17 | .status(status ? status : "published");
18 | if (!page.metadata.gallery?.length) return <>>;
19 | return (
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/app/events/[slug]/page.tsx:
--------------------------------------------------------------------------------
1 | import { SingleEvent } from "@/cosmic/blocks/events/SingleEvent";
2 | import { cosmic } from "@/cosmic/client";
3 |
4 | export const revalidate = 60;
5 |
6 | export async function generateStaticParams() {
7 | const { objects: events } = await cosmic.objects.find({
8 | type: "events",
9 | });
10 | return events.map((event: { slug: string }) => ({
11 | slug: event.slug,
12 | }));
13 | }
14 |
15 | export default async function SingleEventPage({
16 | params,
17 | }: {
18 | params: { slug: string };
19 | }) {
20 | return (
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/cosmic/blocks/events/EventsList.tsx:
--------------------------------------------------------------------------------
1 | import { cosmic } from "@/cosmic/client";
2 | import { EventCard, EventCardType } from "./EventCard";
3 |
4 | export async function EventsList({
5 | query,
6 | className,
7 | preview,
8 | }: {
9 | query: any;
10 | className?: string;
11 | preview?: boolean;
12 | }) {
13 | const { objects: events } = await cosmic.objects
14 | .find(query)
15 | .props("title,slug,metadata")
16 | .depth(1)
17 | .status(preview ? "any" : "published");
18 |
19 | return (
20 |
21 | {events?.map((event: EventCardType) => {
22 | return ;
23 | })}
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/cosmic/blocks/ecommerce/ProductList.tsx:
--------------------------------------------------------------------------------
1 | import { cosmic } from "@/cosmic/client";
2 | import { ProductCard, ProductType } from "./ProductCard";
3 |
4 | export async function ProductList({
5 | query,
6 | className,
7 | status,
8 | }: {
9 | query: any;
10 | className?: string;
11 | status?: "draft" | "published" | "any";
12 | }) {
13 | const { objects: products } = await cosmic.objects
14 | .find(query)
15 | .props("id,slug,title,metadata")
16 | .depth(1)
17 | .status(status ? status : "published");
18 |
19 | return (
20 |
21 | {products.map((product: ProductType) => {
22 | return
;
23 | })}
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/app/events/page.tsx:
--------------------------------------------------------------------------------
1 | // app/events/page.tsx
2 | import { EventsList } from "@/cosmic/blocks/events/EventsList";
3 |
4 | export default async function EventListPage() {
5 | return (
6 |
7 |
8 |
9 |
10 | Upcoming Events
11 |
12 |
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/app/blog/[slug]/page.tsx:
--------------------------------------------------------------------------------
1 | // app/blog/[slug]/page.tsx
2 | import { SingleBlog } from "@/cosmic/blocks/blog/SingleBlog";
3 | import { cosmic } from "@/cosmic/client";
4 |
5 | export const revalidate = 60;
6 |
7 | export async function generateStaticParams() {
8 | const { objects: posts } = await cosmic.objects.find({
9 | type: "blog-posts",
10 | });
11 | return posts.map((post: { slug: string }) => ({
12 | slug: post.slug,
13 | }));
14 | }
15 |
16 | export default async function BlogPost({
17 | params,
18 | searchParams,
19 | }: {
20 | params: { slug: string };
21 | searchParams?: { [key: string]: string | string[] | undefined };
22 | }) {
23 | return (
24 |
25 |
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/cosmic/elements/Label.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as LabelPrimitive from "@radix-ui/react-label";
5 | import { cva, type VariantProps } from "class-variance-authority";
6 |
7 | import { cn } from "@/cosmic/utils";
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | );
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ));
24 | Label.displayName = LabelPrimitive.Root.displayName;
25 |
26 | export { Label };
27 |
--------------------------------------------------------------------------------
/app/blog/page.tsx:
--------------------------------------------------------------------------------
1 | // app/blog/page.tsx
2 | import { BlogList } from "@/cosmic/blocks/blog/BlogList";
3 |
4 | export default async function BlogPage() {
5 | return (
6 |
7 |
8 |
9 |
10 | Blog
11 |
12 |
13 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/app/services/[slug]/page.tsx:
--------------------------------------------------------------------------------
1 | // app/shop/[slug]/page.tsx
2 | import { SingleProduct } from "@/cosmic/blocks/ecommerce/SingleProduct";
3 | import { cosmic } from "@/cosmic/client";
4 |
5 | export const revalidate = 60;
6 |
7 | export async function generateStaticParams() {
8 | const { objects: products } = await cosmic.objects.find({
9 | type: "products",
10 | });
11 | return products.map((product: { slug: string }) => ({
12 | slug: product.slug,
13 | }));
14 | }
15 | export default async function SingleProductPage({
16 | params,
17 | searchParams,
18 | }: {
19 | params: { slug: string };
20 | searchParams: {
21 | success?: string;
22 | };
23 | }) {
24 | return (
25 |
26 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/cosmic/blocks/blog/BlogList.tsx:
--------------------------------------------------------------------------------
1 | import { BlogCard, PostType } from "./BlogCard";
2 | import { cosmic } from "@/cosmic/client";
3 |
4 | export async function BlogList({
5 | query,
6 | sort,
7 | limit,
8 | skip,
9 | className,
10 | status,
11 | }: {
12 | query: any;
13 | sort?: string;
14 | limit?: number;
15 | skip?: number;
16 | className?: string;
17 | status?: "draft" | "published" | "any";
18 | }) {
19 | const { objects: posts } = await cosmic.objects
20 | .find(query)
21 | .props("id,slug,title,metadata")
22 | .depth(1)
23 | .sort(sort ? sort : "-order")
24 | .limit(limit ? limit : 100)
25 | .skip(skip ? skip : 0)
26 | .status(status ? status : "published");
27 |
28 | return (
29 |
30 | {posts.map((post: PostType) => {
31 | return ;
32 | })}
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/cosmic/blocks/team/TeamList.tsx:
--------------------------------------------------------------------------------
1 | import { cosmic } from "@/cosmic/client";
2 | import { TeamCard, MemberType } from "./TeamCard";
3 |
4 | export async function TeamList({
5 | query,
6 | sort,
7 | limit,
8 | skip,
9 | className,
10 | status,
11 | }: {
12 | query: any;
13 | sort?: string;
14 | limit?: number;
15 | skip?: number;
16 | className?: string;
17 | status?: "draft" | "published" | "any";
18 | }) {
19 | const { objects: members } = await cosmic.objects
20 | .find(query)
21 | .props("id,slug,title,metadata")
22 | .depth(1)
23 | .sort(sort ? sort : "-order")
24 | .limit(limit ? limit : 100)
25 | .skip(skip ? skip : 0)
26 | .status(status ? status : "published");
27 | return (
28 |
29 | {members.map((member: MemberType) => {
30 | return ;
31 | })}
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/cosmic/elements/TextArea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/cosmic/utils";
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | );
20 | }
21 | );
22 | Textarea.displayName = "Textarea";
23 |
24 | export { Textarea };
25 |
--------------------------------------------------------------------------------
/cosmic/blocks/testimonials/Testimonials.tsx:
--------------------------------------------------------------------------------
1 | import { cosmic } from "@/cosmic/client";
2 | import { Testimonial, TestimonialType } from "./Testimonial";
3 |
4 | export async function Testimonials({
5 | query,
6 | sort,
7 | limit,
8 | skip,
9 | className,
10 | status,
11 | }: {
12 | query: any;
13 | sort?: string;
14 | limit?: number;
15 | skip?: number;
16 | className?: string;
17 | status?: "draft" | "published" | "any";
18 | }) {
19 | const { objects: testimonials } = await cosmic.objects
20 | .find(query)
21 | .props("id,slug,title,metadata")
22 | .depth(1)
23 | .sort(sort ? sort : "-order")
24 | .limit(limit ? limit : 100)
25 | .skip(skip ? skip : 0)
26 | .status(status ? status : "published");
27 | return (
28 |
29 | {testimonials?.map((testimonial: TestimonialType) => {
30 | return ;
31 | })}
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/cosmic/elements/Input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/cosmic/utils";
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | );
21 | }
22 | );
23 | Input.displayName = "Input";
24 |
25 | export { Input };
26 |
--------------------------------------------------------------------------------
/cosmic/blocks/ecommerce/CartProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { createContext, useState, ReactNode } from "react";
4 |
5 | // TODO: Add Cart Type
6 | type CartItem = any;
7 |
8 | interface CartContextType {
9 | cart: CartItem[];
10 | setCart: (cart: CartItem[]) => void;
11 | cartOpen: boolean;
12 | setCartOpen: (isOpen: boolean) => void;
13 | }
14 |
15 | export const CartContext = createContext({
16 | cart: [],
17 | setCart: () => {},
18 | cartOpen: false,
19 | setCartOpen: () => {},
20 | });
21 |
22 | export function CartProvider({ children }: { children: ReactNode }) {
23 | let defaultCart: CartItem[] = [];
24 | if (typeof window !== "undefined") {
25 | defaultCart = JSON.parse(localStorage.getItem("cart") || "[]");
26 | }
27 | const [cart, setCart] = useState(defaultCart);
28 | const [cartOpen, setCartOpen] = useState(false);
29 | return (
30 |
31 | {children}
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/app/actions/checkout.ts:
--------------------------------------------------------------------------------
1 | 'use server';
2 |
3 | const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);
4 |
5 | export async function createCheckoutSession(stripe_product_ids: string[], redirect_url: string) {
6 | try {
7 | let line_items = [];
8 | let mode = "payment";
9 |
10 | for (const stripe_product_id of stripe_product_ids) {
11 | const product = await stripe.products.retrieve(stripe_product_id);
12 | const price = await stripe.prices.retrieve(product.default_price);
13 | line_items.push({
14 | price: price.id,
15 | quantity: 1,
16 | });
17 | // If any items are recurring
18 | if (price.type === "recurring") mode = "subscription";
19 | }
20 |
21 | const session = await stripe.checkout.sessions.create({
22 | line_items,
23 | mode,
24 | success_url: `${redirect_url}/?success=true`,
25 | cancel_url: `${redirect_url}/?canceled=true`,
26 | });
27 |
28 | return { url: session.url };
29 | } catch (err: any) {
30 | throw new Error(err.message);
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | // app/page.tsx
2 | import { FAQs } from "@/cosmic/blocks/faqs/FAQs";
3 | import { Testimonials } from "@/cosmic/blocks/testimonials/Testimonials";
4 | import { Page } from "@/cosmic/blocks/pages/Page";
5 |
6 | export default async function HomePage() {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 | Hear from our customers
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | Frequently Asked Questions
23 |
24 |
25 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/cosmic/blocks/faqs/FAQs.tsx:
--------------------------------------------------------------------------------
1 | // components/faqs.tsx
2 | import { cosmic } from "@/cosmic/client";
3 | import {
4 | Accordion,
5 | AccordionContent,
6 | AccordionItem,
7 | AccordionTrigger,
8 | } from "./Accordion";
9 |
10 | type FAQ = {
11 | question: string;
12 | answer: string;
13 | };
14 |
15 | export async function FAQs({
16 | query,
17 | className,
18 | status,
19 | }: {
20 | query: any;
21 | className?: string;
22 | status?: "draft" | "published" | "any";
23 | }) {
24 | const { object: page } = await cosmic.objects
25 | .findOne(query)
26 | .props("slug,title,metadata")
27 | .depth(1)
28 | .status(status ? status : "published");
29 | if (!page?.metadata?.faqs) return <>>;
30 | return (
31 |
32 | {(page?.metadata?.faqs).map((faq: FAQ) => {
33 | return (
34 |
35 |
36 | {faq.question}
37 | {faq.answer}
38 |
39 |
40 | );
41 | })}
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/components/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { Moon, Sun } from "lucide-react";
5 | import { useTheme } from "next-themes";
6 |
7 | import { Button } from "@/cosmic/elements/Button";
8 | import {
9 | DropdownMenu,
10 | DropdownMenuContent,
11 | DropdownMenuItem,
12 | DropdownMenuTrigger,
13 | } from "@/components/ui/dropdown-menu";
14 |
15 | export function ModeToggle() {
16 | const { setTheme } = useTheme();
17 |
18 | return (
19 |
20 |
21 |
26 |
27 |
28 | setTheme("light")}>
29 | Light
30 |
31 | setTheme("dark")}>
32 | Dark
33 |
34 | setTheme("system")}>
35 | System
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/cosmic/blocks/user-management/AuthButtons.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useAuth } from "@/cosmic/blocks/user-management/AuthContext";
4 | import Link from "next/link";
5 | import { useRouter } from "next/navigation";
6 |
7 | const buttonStyles =
8 | "group inline-flex h-10 w-full items-center justify-center rounded-md bg-transparent px-4 py-2 text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-100 focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-zinc-100 data-[state=open]:bg-zinc-100 dark:text-zinc-50 dark:hover:bg-zinc-800 dark:data-[state=active]:bg-zinc-900 dark:data-[state=open]:bg-zinc-900 md:w-max";
9 |
10 | export function AuthButtons() {
11 | const { user, logout, isLoading } = useAuth();
12 | const router = useRouter();
13 |
14 | if (isLoading) {
15 | return null;
16 | }
17 |
18 | if (!user) {
19 | return (
20 |
23 | );
24 | }
25 |
26 | return (
27 |
28 |
29 | Dashboard
30 |
31 |
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/app/services/page.tsx:
--------------------------------------------------------------------------------
1 | // app/page.tsx
2 | import { cosmic } from "@/cosmic/client";
3 | import { ProductList } from "@/cosmic/blocks/ecommerce/ProductList";
4 |
5 | export default async function ShopPage() {
6 | const { object: page } = await cosmic.objects
7 | .findOne({
8 | type: "pages",
9 | slug: "services",
10 | })
11 | .props("slug,title,metadata")
12 | .depth(1);
13 |
14 | return (
15 |
16 |
17 |
18 |
19 | {page.metadata.h1}
20 |
21 |
22 | {page.metadata.subheadline}
23 |
24 |
30 |
34 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/app/about/page.tsx:
--------------------------------------------------------------------------------
1 | // app/page.tsx
2 | import { cosmic } from "@/cosmic/client";
3 | import { TeamList } from "@/cosmic/blocks/team/TeamList";
4 |
5 | export default async function AboutPage() {
6 | const { object: page } = await cosmic.objects
7 | .findOne({
8 | type: "pages",
9 | slug: "about",
10 | })
11 | .props("slug,title,metadata")
12 | .depth(1);
13 | return (
14 |
15 |
16 |
17 |
18 | {page.metadata.h1}
19 |
20 |
24 |
25 |
26 |
27 |
28 |
29 | Our team
30 |
31 |
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/app/work/page.tsx:
--------------------------------------------------------------------------------
1 | // app/page.tsx
2 | import { ProjectCard, ProjectType } from "@/components/project-card";
3 | import { cosmic } from "@/cosmic/client";
4 |
5 | export default async function WorkPage() {
6 | const { object: page } = await cosmic.objects
7 | .findOne({
8 | type: "pages",
9 | slug: "work",
10 | })
11 | .props("slug,title,metadata")
12 | .depth(1);
13 |
14 | const { objects: projects } = await cosmic.objects
15 | .find({
16 | type: "projects",
17 | })
18 | .props("id,slug,title,metadata")
19 | .depth(1);
20 |
21 | return (
22 |
23 |
24 |
25 |
26 | {page.metadata.h1}
27 |
28 |
34 |
35 | {projects.map((project: ProjectType) => {
36 | return
;
37 | })}
38 |
39 |
40 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/cosmic/blocks/ecommerce/ProductCard.tsx:
--------------------------------------------------------------------------------
1 | // components/product-card.tsx
2 | import Link from "next/link";
3 | import { cn } from "@/cosmic/utils";
4 |
5 | export type ProductType = {
6 | id: string;
7 | title: string;
8 | slug: string;
9 | metadata: {
10 | image: {
11 | imgix_url: string;
12 | };
13 | description: string;
14 | price: number;
15 | };
16 | };
17 |
18 | export function ProductCard({
19 | product,
20 | className,
21 | }: {
22 | product: ProductType;
23 | className?: string;
24 | }) {
25 | return (
26 |
30 |
31 |

36 |
37 |
38 |
39 |
40 |
41 | {product.title}
42 |
43 |
44 |
45 | ${product.metadata.price.toLocaleString("en-US")}
46 |
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/cosmic/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export async function getMediaBlobFromURL(url: string, name: string) {
9 | const response = await fetch(url);
10 | const blob = await response.blob();
11 | const media: any = new Blob([blob], {
12 | type: "image/jpeg",
13 | });
14 | media.name = name;
15 | return media;
16 | }
17 |
18 | export function selectRandomValuesFromArray(arr: any[], limit: number) {
19 | const result = [];
20 | const copyArr = arr.slice();
21 | for (let i = 0; i < limit; i++) {
22 | const randomIndex = Math.floor(Math.random() * copyArr.length);
23 | result.push(copyArr[randomIndex]);
24 | copyArr.splice(randomIndex, 1);
25 | }
26 | return result;
27 | }
28 |
29 | export function pluralize(singular: string, count: number) {
30 | return count > 1 ? `${singular}s` : singular;
31 | }
32 |
33 | export const getFormattedDate = (inputDate: string) => {
34 | const dateParts = inputDate.split("-");
35 |
36 | const year = parseInt(dateParts[0]);
37 | const month = parseInt(dateParts[1]) - 1;
38 | const day = parseInt(dateParts[2]);
39 |
40 | // Create a new Date object using UTC timezone
41 | const date = new Date(Date.UTC(year, month, day));
42 |
43 | // Format the date in UTC
44 | const formattedDate = date.toLocaleDateString("en-US", {
45 | timeZone: "UTC",
46 | year: "numeric",
47 | month: "long",
48 | day: "numeric",
49 | });
50 |
51 | return formattedDate;
52 | };
53 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "agency-template",
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 | "format": "prettier --write \"**/*.{ts,tsx,js,jsx,md,mdx,css,scss}\""
11 | },
12 | "dependencies": {
13 | "@cosmicjs/sdk": "1.2.0",
14 | "@radix-ui/react-accordion": "^1.1.2",
15 | "@radix-ui/react-dropdown-menu": "^2.0.6",
16 | "@radix-ui/react-icons": "^1.3.0",
17 | "@radix-ui/react-label": "^2.1.0",
18 | "@radix-ui/react-navigation-menu": "^1.1.4",
19 | "@radix-ui/react-select": "^2.0.0",
20 | "@radix-ui/react-slot": "^1.1.0",
21 | "@stripe/stripe-js": "^3.1.0",
22 | "@types/bcryptjs": "^2.4.6",
23 | "bcryptjs": "^2.4.3",
24 | "class-variance-authority": "^0.7.0",
25 | "clsx": "^2.1.1",
26 | "lucide-react": "^0.456.0",
27 | "next": "v14.0.0",
28 | "next-themes": "^0.2.1",
29 | "react": "^18",
30 | "react-dom": "^18",
31 | "react-markdown": "^9.0.1",
32 | "react-medium-image-zoom": "^5.1.11",
33 | "resend": "^4.0.1-alpha.0",
34 | "stripe": "^14.22.0",
35 | "tailwind-merge": "^2.5.4",
36 | "tailwindcss-animate": "^1.0.7"
37 | },
38 | "devDependencies": {
39 | "typescript": "^5",
40 | "@types/node": "^20",
41 | "@types/react": "^18",
42 | "@types/react-dom": "^18",
43 | "autoprefixer": "^10.0.1",
44 | "postcss": "^8",
45 | "tailwindcss": "latest",
46 | "eslint": "^8",
47 | "eslint-config-next": "14.0.3",
48 | "prettier": "^3.1.1",
49 | "prettier-plugin-tailwindcss": "^0.5.9"
50 | }
51 | }
--------------------------------------------------------------------------------
/components/Header.tsx:
--------------------------------------------------------------------------------
1 | // components/header.tsx
2 | import Link from "next/link";
3 | import { cosmic } from "@/cosmic/client";
4 | import { NavMenu } from "@/cosmic/blocks/navigation-menu/NavMenu";
5 | import { CheckOut } from "@/cosmic/blocks/ecommerce/CheckOut";
6 |
7 | export default async function Header() {
8 | // Header data
9 | const { object: settings } = await cosmic.objects
10 | .findOne({
11 | type: "global-settings",
12 | slug: "settings",
13 | })
14 | .props("metadata")
15 | .depth(1);
16 |
17 | return (
18 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/cosmic/blocks/testimonials/Testimonial.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/cosmic/utils";
2 |
3 | export type TestimonialType = {
4 | title: string;
5 | slug: string;
6 | metadata: {
7 | company: string;
8 | position: string;
9 | quote: string;
10 | image: {
11 | imgix_url: string;
12 | };
13 | };
14 | };
15 |
16 | export function Testimonial({
17 | testimonial,
18 | className,
19 | }: {
20 | testimonial: TestimonialType;
21 | className?: string;
22 | }) {
23 | return (
24 |
30 |
35 |
36 |
37 |
38 | "{testimonial.metadata.quote}"
39 |
40 |
41 |
42 |
43 | {testimonial.title}
44 |
45 |
46 | {testimonial.metadata.position}, {testimonial.metadata.company}
47 |
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/cosmic/blocks/user-management/AuthContext.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { createContext, useContext, useEffect, useState } from "react";
4 | import { getAuthUser, logoutUser } from "./actions";
5 |
6 | type User = {
7 | id: string;
8 | name: string;
9 | email: string;
10 | image?: string;
11 | } | null;
12 |
13 | type AuthContextType = {
14 | user: User;
15 | login: (userData: User) => void;
16 | logout: () => void;
17 | isLoading: boolean;
18 | };
19 |
20 | const AuthContext = createContext({
21 | user: null,
22 | login: () => {},
23 | logout: () => {},
24 | isLoading: true,
25 | });
26 |
27 | export function AuthProvider({ children }: { children: React.ReactNode }) {
28 | const [user, setUser] = useState(null);
29 | const [isLoading, setIsLoading] = useState(true);
30 |
31 | useEffect(() => {
32 | getAuthUser()
33 | .then((userData) => {
34 | if (userData) {
35 | setUser(userData);
36 | }
37 | })
38 | .catch((error) => {
39 | console.error("Error fetching user data:", error);
40 | })
41 | .finally(() => {
42 | setIsLoading(false);
43 | });
44 | }, []);
45 |
46 | const login = (userData: User) => {
47 | setUser(userData);
48 | };
49 |
50 | const logout = async () => {
51 | try {
52 | await logoutUser();
53 | setUser(null);
54 | } catch (error) {
55 | console.error("Error logging out:", error);
56 | }
57 | };
58 |
59 | return (
60 |
61 | {children}
62 |
63 | );
64 | }
65 |
66 | export const useAuth = () => useContext(AuthContext);
67 |
--------------------------------------------------------------------------------
/cosmic/blocks/user-management/VerifyClient.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useRef } from "react";
4 | import { useRouter, useSearchParams } from "next/navigation";
5 | import { verifyEmail } from "@/cosmic/blocks/user-management/actions";
6 | import { Loader2 } from "lucide-react";
7 |
8 | export default function VerifyClient() {
9 | const searchParams = useSearchParams();
10 | const router = useRouter();
11 | const verificationAttempted = useRef(false);
12 |
13 | useEffect(() => {
14 | const verifyUserEmail = async () => {
15 | if (verificationAttempted.current) return;
16 |
17 | const code = searchParams.get("code");
18 | verificationAttempted.current = true;
19 |
20 | if (!code) {
21 | router.push("/login?error=Invalid verification link");
22 | return;
23 | }
24 |
25 | try {
26 | await verifyEmail(code);
27 | router.push(
28 | "/login?success=Email verified successfully. You may now log in."
29 | );
30 | } catch (error) {
31 | const errorMessage =
32 | error instanceof Error ? error.message : "Verification failed";
33 | router.push(`/login?error=${encodeURIComponent(errorMessage)}`);
34 | }
35 | };
36 |
37 | verifyUserEmail();
38 | }, [searchParams, router]);
39 |
40 | return (
41 |
42 |
43 |
44 |
45 | Verifying your email...
46 |
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/cosmic/blocks/image-gallery/ImageGalleryClient.tsx:
--------------------------------------------------------------------------------
1 | // components/image-gallery.tsx
2 | "use client";
3 |
4 | import Zoom from "react-medium-image-zoom";
5 | import "react-medium-image-zoom/dist/styles.css";
6 | import { useState } from "react";
7 | import { cn } from "@/cosmic/utils";
8 |
9 | export type GalleryItemType = {
10 | image: {
11 | imgix_url: string;
12 | };
13 | description: string;
14 | };
15 |
16 | export function ImageGalleryClient({
17 | items,
18 | className,
19 | }: {
20 | items: GalleryItemType[];
21 | className?: string;
22 | }) {
23 | const [mainItem, setMainItem] = useState(items[0]);
24 |
25 | return (
26 |
27 |
28 |
29 |
34 |
35 |
36 |
37 | {items.map((item: GalleryItemType) => {
38 | return (
39 |
setMainItem(item)}
41 | key={item.image.imgix_url}
42 | className={cn(
43 | `overflow-hidden rounded-xl border-4`,
44 | item.image.imgix_url === mainItem.image.imgix_url
45 | ? "border-orange-600"
46 | : ""
47 | )}
48 | >
49 |

54 |
55 | );
56 | })}
57 |
58 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/cosmic/blocks/navigation-menu/NavMenu.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { cosmic } from "@/cosmic/client";
3 | import { MobileNav } from "./MobileNav";
4 | import { AuthButtons } from "../user-management/AuthButtons";
5 |
6 | export type ItemType = {
7 | title: string;
8 | link: string;
9 | open_in_new_tab: boolean;
10 | };
11 |
12 | export async function NavMenu({
13 | query,
14 | className,
15 | status,
16 | hasMobileMenu,
17 | }: {
18 | query: any;
19 | className?: string;
20 | status?: "draft" | "published" | "any";
21 | hasMobileMenu?: boolean;
22 | }) {
23 | const { object: nav } = await cosmic.objects
24 | .findOne(query)
25 | .props("metadata")
26 | .depth(1)
27 | .status(status ? status : "published");
28 | return (
29 |
30 | {/* Desktop */}
31 |
32 | {nav.metadata.items.map((item: ItemType) => {
33 | return (
34 |
40 | {item.title}
41 |
42 | );
43 | })}
44 | {hasMobileMenu &&
}
45 |
46 | {/* Mobile */}
47 | {hasMobileMenu &&
}
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/cosmic/blocks/user-management/LoginClient.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 | import { useRouter, useSearchParams } from "next/navigation";
5 | import { useAuth } from "@/cosmic/blocks/user-management/AuthContext";
6 | import AuthForm from "@/cosmic/blocks/user-management/AuthForm";
7 | import { Loader2 } from "lucide-react";
8 |
9 | export default function LoginClient({
10 | onSubmit,
11 | redirect,
12 | }: {
13 | onSubmit: any;
14 | redirect: string;
15 | }) {
16 | const { user, isLoading, login: authLogin } = useAuth();
17 | const router = useRouter();
18 | const searchParams = useSearchParams();
19 | const success = searchParams.get("success");
20 | const error = searchParams.get("error");
21 |
22 | useEffect(() => {
23 | if (!isLoading && user) {
24 | router.push(redirect);
25 | }
26 | }, [user, isLoading, router, redirect]);
27 |
28 | if (isLoading) {
29 | return (
30 |
31 |
32 |
33 | );
34 | }
35 |
36 | return (
37 | <>
38 | {success && (
39 |
40 | {success}
41 |
42 | )}
43 | {error && (
44 |
45 | {error}
46 |
47 | )}
48 | {
51 | const result = await onSubmit(formData);
52 | if (result.error) {
53 | router.push(`/login?error=${encodeURIComponent(result.error)}`);
54 | } else if (result.user) {
55 | authLogin(result.user);
56 | }
57 | }}
58 | />
59 | >
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/components/project-card.tsx:
--------------------------------------------------------------------------------
1 | // components/product-card.tsx
2 | import Link from "next/link";
3 |
4 | export type ProjectType = {
5 | id: string;
6 | title: string;
7 | slug: string;
8 | metadata: {
9 | image: {
10 | imgix_url?: string;
11 | };
12 | summary: string;
13 | client: {
14 | title: string;
15 | };
16 | year: number;
17 | content: number;
18 | };
19 | };
20 |
21 | export function ProjectCard({ project }: { project: ProjectType }) {
22 | return (
23 |
24 |
25 | {project.metadata.image?.imgix_url && (
26 |

31 | )}
32 |
33 |
34 |
35 |
36 |
37 | {project.title}
38 |
39 |
40 |
41 |
45 |
46 |
47 | {project.metadata.client.title}
48 |
49 |
50 | {project.metadata.year}
51 |
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 240 10% 3.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 240 10% 3.9%;
15 |
16 | --primary: 240 5.9% 10%;
17 | --primary-foreground: 0 0% 98%;
18 |
19 | --secondary: 240 4.8% 95.9%;
20 | --secondary-foreground: 240 5.9% 10%;
21 |
22 | --muted: 240 4.8% 95.9%;
23 | --muted-foreground: 240 3.8% 46.1%;
24 |
25 | --accent: 240 4.8% 95.9%;
26 | --accent-foreground: 240 5.9% 10%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 0 0% 98%;
30 |
31 | --border: 240 5.9% 90%;
32 | --input: 240 5.9% 90%;
33 | --ring: 240 10% 3.9%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 240 10% 3.9%;
40 | --foreground: 0 0% 98%;
41 |
42 | --card: 240 10% 3.9%;
43 | --card-foreground: 0 0% 98%;
44 |
45 | --popover: 240 10% 3.9%;
46 | --popover-foreground: 0 0% 98%;
47 |
48 | --primary: 0 0% 98%;
49 | --primary-foreground: 240 5.9% 10%;
50 |
51 | --secondary: 240 3.7% 15.9%;
52 | --secondary-foreground: 0 0% 98%;
53 |
54 | --muted: 240 3.7% 15.9%;
55 | --muted-foreground: 240 5% 64.9%;
56 |
57 | --accent: 240 3.7% 15.9%;
58 | --accent-foreground: 0 0% 98%;
59 |
60 | --destructive: 0 62.8% 30.6%;
61 | --destructive-foreground: 0 0% 98%;
62 |
63 | --border: 240 3.7% 15.9%;
64 | --input: 240 3.7% 15.9%;
65 | --ring: 240 4.9% 83.9%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 | body {
74 | @apply bg-background text-foreground;
75 | }
76 | }
77 |
78 | .dark .custom-zoom [data-rmiz-modal-overlay="visible"] {
79 | background-color: black;
80 | }
81 |
82 | .custom-zoom img {
83 | @apply rounded-2xl;
84 | }
85 |
--------------------------------------------------------------------------------
/cosmic/blocks/navigation-menu/MobileNav.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 | import Link from "next/link";
5 | import { cn } from "@/cosmic/utils";
6 |
7 | import { MenuIcon, XIcon } from "lucide-react";
8 | import { ItemType } from "./NavMenu";
9 | import { AuthButtons } from "../user-management/AuthButtons";
10 |
11 | export function MobileNav({
12 | items,
13 | className,
14 | }: {
15 | items: ItemType[];
16 | className?: string;
17 | }) {
18 | const [isOpen, setIsOpen] = React.useState(false);
19 | return (
20 |
21 |
31 | {isOpen && (
32 |
33 |
34 | {items.map((item: ItemType) => {
35 | return (
36 |
setIsOpen(!isOpen)}
40 | target={item.open_in_new_tab ? "_blank" : ""}
41 | className="group inline-flex h-10 w-full items-center justify-center rounded-md bg-transparent px-4 py-2 text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-100 focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-zinc-100 data-[state=open]:bg-zinc-100 dark:text-zinc-50 dark:hover:bg-zinc-800 dark:data-[state=active]:bg-zinc-900 dark:data-[state=open]:bg-zinc-900 md:w-max"
42 | >
43 | {item.title}
44 |
45 | );
46 | })}
47 |
48 |
49 |
50 | )}
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/cosmic/blocks/user-management/SignUpClient.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 | import Link from "next/link";
5 | import { useRouter } from "next/navigation";
6 | import { useAuth } from "@/cosmic/blocks/user-management/AuthContext";
7 | import AuthForm from "@/cosmic/blocks/user-management/AuthForm";
8 | import { Loader2 } from "lucide-react";
9 |
10 | export default function SignUpClient({ onSubmit }: { onSubmit: any }) {
11 | const { user, isLoading } = useAuth();
12 | const router = useRouter();
13 | const [isSignupComplete, setIsSignupComplete] = useState(false);
14 | const [error, setError] = useState("");
15 |
16 | useEffect(() => {
17 | if (!isLoading && user) {
18 | router.push("/dashboard");
19 | }
20 | }, [user, isLoading, router]);
21 |
22 | if (isLoading) {
23 | return (
24 |
25 |
26 |
27 | );
28 | }
29 |
30 | if (isSignupComplete) {
31 | return (
32 |
33 |
Check your email
34 |
35 | We've sent you a verification link. Please check your email to
36 | complete the signup process.
37 |
38 |
39 | Go to login
40 |
41 |
42 | );
43 | }
44 |
45 | const handleSubmit = async (formData: FormData) => {
46 | setError("");
47 | const result = await onSubmit(formData);
48 |
49 | if (result.error) {
50 | setError(result.error);
51 | return result;
52 | }
53 |
54 | if (result.success) {
55 | setIsSignupComplete(true);
56 | }
57 |
58 | return result;
59 | };
60 |
61 | return (
62 | <>
63 | {error && (
64 |
65 | {error}
66 |
67 | )}
68 |
69 | >
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | // components/footer.tsx
2 | import { cosmic } from "@/cosmic/client";
3 | import { buttonVariants } from "@/cosmic/elements/Button";
4 | import { MailIcon, PhoneIcon } from "lucide-react";
5 | import { ModeToggle } from "./theme-toggle";
6 | import { NavMenu } from "@/cosmic/blocks/navigation-menu/NavMenu";
7 |
8 | export default async function Footer() {
9 | const { object: settings } = await cosmic.objects
10 | .findOne({
11 | type: "global-settings",
12 | slug: "settings",
13 | })
14 | .props("metadata")
15 | .depth(1);
16 |
17 | type Link = {
18 | url: string;
19 | company: string;
20 | icon: {
21 | imgix_url: string;
22 | };
23 | };
24 |
25 | return (
26 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | // app/layout.tsx
2 | import type { Metadata } from "next";
3 | import { Libre_Franklin, Fjalla_One } from "next/font/google";
4 | import "./globals.css";
5 | import Header from "@/components/Header";
6 | import Footer from "@/components/Footer";
7 | import { ThemeProvider } from "@/components/theme-provider";
8 | import { CartProvider } from "@/cosmic/blocks/ecommerce/CartProvider";
9 | import { TailwindIndicator } from "@/components/tailwind-indicator";
10 | import Banner from "@/components/Banner";
11 | import { Suspense } from "react";
12 | import { AuthProvider } from "@/cosmic/blocks/user-management/AuthContext";
13 |
14 | const sans = Libre_Franklin({ subsets: ["latin"], variable: "--font-sans" });
15 | const display = Fjalla_One({
16 | weight: "400",
17 | subsets: ["latin"],
18 | variable: "--font-display",
19 | });
20 |
21 | export const metadata: Metadata = {
22 | title: "ProLine Content Corps - Agency website template by Cosmic",
23 | description: "A Cosmic template built with Blocks.",
24 | openGraph: {
25 | title: "ProLine Content Corps - Agency website template by Cosmic",
26 | description: "A Cosmic template built with Blocks.",
27 | images:
28 | "https://imgix.cosmicjs.com/69313380-b156-11ee-9844-f9a09795e2a3-desktop.png?auto=format,compression",
29 | },
30 | };
31 |
32 | export default function RootLayout({
33 | children,
34 | }: {
35 | children: React.ReactNode;
36 | }) {
37 | return (
38 |
39 |
42 |
43 |
44 |
50 |
51 |
52 |
53 |
54 | {children}
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/cosmic/blocks/contact-form/actions.tsx:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { cosmic } from "@/cosmic/client";
4 | import { Resend } from "resend";
5 | const RESEND_KEY = process.env.RESEND_API_KEY;
6 | const CONTACT_EMAIL =
7 | process.env.CONTACT_EMAIL || "change_to_your_email@example.com";
8 | const resend = new Resend(RESEND_KEY);
9 |
10 | export type AddSubmissionType = {
11 | type: "form-submissions";
12 | title: string;
13 | metadata: {
14 | email: string;
15 | company: string;
16 | message: string;
17 | };
18 | };
19 |
20 | export async function addSubmission(comment: AddSubmissionType) {
21 | const { metadata: metadata, title } = comment;
22 | const data = await cosmic.objects.insertOne(comment);
23 | const submitterSubject = `Form submission received`;
24 | const submitterHTML = `
25 | Hello ${title},
26 | This is a message to confirm that we have received your form submission with the following information:
27 | Name: ${title}
28 | Email: ${metadata.email}
29 | Company: ${metadata.company}
30 | Message: ${metadata.message}
31 |
32 | A representative will be in touch with you soon.
33 | `;
34 | // Send confirmation email
35 | await sendEmail({
36 | to: metadata.email,
37 | from: CONTACT_EMAIL,
38 | reply_to: CONTACT_EMAIL,
39 | subject: submitterSubject,
40 | html: submitterHTML,
41 | });
42 | const adminSubject = `${title} submitted the form`;
43 | const adminHTML = `
44 | ${title} submitted the contact form with the following information:
45 | Name: ${title}
46 | Email: ${metadata.email}
47 | Company: ${metadata.company}
48 | Message: ${metadata.message}
49 | `;
50 | // Send email to admin
51 | await sendEmail({
52 | to: CONTACT_EMAIL,
53 | from: CONTACT_EMAIL,
54 | reply_to: metadata.email,
55 | subject: adminSubject,
56 | html: adminHTML,
57 | });
58 | return data;
59 | }
60 |
61 | async function sendEmail({
62 | from,
63 | to,
64 | subject,
65 | html,
66 | reply_to,
67 | }: {
68 | from: string;
69 | to: string;
70 | subject: string;
71 | html: string;
72 | reply_to: string;
73 | }) {
74 | const data = await resend.emails.send({
75 | from,
76 | to,
77 | subject,
78 | html,
79 | replyTo: reply_to,
80 | });
81 | return data;
82 | }
83 |
--------------------------------------------------------------------------------
/cosmic/blocks/comments/Comments.tsx:
--------------------------------------------------------------------------------
1 | // components/comments.tsx
2 | import { cosmic } from "@/cosmic/client";
3 | import { UserRound } from "lucide-react";
4 | import { cn } from "@/cosmic/utils";
5 | import { CommentForm } from "./CommentForm";
6 |
7 | type Comment = {
8 | title: string;
9 | slug: string;
10 | metadata: {
11 | comment: string;
12 | };
13 | created_at: string;
14 | };
15 |
16 | function Comment({
17 | comment,
18 | className,
19 | }: {
20 | comment: Comment;
21 | className?: string;
22 | }) {
23 | const date = new Date(comment.created_at).toLocaleDateString("en-us", {
24 | year: "numeric",
25 | month: "long",
26 | day: "numeric",
27 | hour: "numeric",
28 | minute: "numeric",
29 | });
30 | return (
31 |
37 |
38 |
39 |
40 |
{comment.title}
41 |
42 |
{date}
43 |
44 |
45 | {comment.metadata.comment}
46 |
47 |
48 | );
49 | }
50 |
51 | export async function Comments({
52 | query,
53 | className,
54 | status,
55 | }: {
56 | query: any;
57 | className?: string;
58 | status?: "draft" | "published" | "any";
59 | }) {
60 | let comments = [];
61 | const resourceId = query["metadata.resource"];
62 | try {
63 | // Get the id
64 | const { objects } = await cosmic.objects
65 | .find(query)
66 | .props("title,slug,metadata,created_at")
67 | .depth(1)
68 | .sort("created_at")
69 | .status(status ? status : "published");
70 | comments = objects;
71 | } catch (err) {}
72 | return (
73 |
74 |
Comments
75 | {comments.map((comment: Comment) => {
76 | return ;
77 | })}
78 |
79 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/cosmic/blocks/faqs/Accordion.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as AccordionPrimitive from "@radix-ui/react-accordion";
5 | import { ChevronDown } from "lucide-react";
6 |
7 | import { cn } from "@/cosmic/utils";
8 |
9 | const Accordion = AccordionPrimitive.Root;
10 |
11 | const AccordionItem = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
23 | ));
24 | AccordionItem.displayName = "AccordionItem";
25 |
26 | const AccordionTrigger = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, children, ...props }, ref) => (
30 |
31 | svg]:rotate-180",
35 | className
36 | )}
37 | {...props}
38 | >
39 | {children}
40 |
41 |
42 |
43 | ));
44 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
45 |
46 | const AccordionContent = React.forwardRef<
47 | React.ElementRef,
48 | React.ComponentPropsWithoutRef
49 | >(({ className, children, ...props }, ref) => (
50 |
55 | {children}
56 |
57 | ));
58 |
59 | AccordionContent.displayName = AccordionPrimitive.Content.displayName;
60 |
61 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
62 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./pages/**/*.{ts,tsx}",
5 | "./components/**/*.{ts,tsx}",
6 | "./app/**/*.{ts,tsx}",
7 | "./src/**/*.{ts,tsx}",
8 | "./cosmic/**/*.{ts,tsx,js,jsx}",
9 | ],
10 | darkMode: "class",
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: "2rem",
15 | screens: {
16 | "2xl": "1400px",
17 | },
18 | },
19 | extend: {
20 | fontFamily: {
21 | sans: ["var(--font-sans)", "sans-serif"],
22 | display: ["var(--font-display)", "sans-serif"],
23 | },
24 | colors: {
25 | border: "hsl(var(--border))",
26 | input: "hsl(var(--input))",
27 | ring: "hsl(var(--ring))",
28 | background: "hsl(var(--background))",
29 | foreground: "hsl(var(--foreground))",
30 | primary: {
31 | DEFAULT: "hsl(var(--primary))",
32 | foreground: "hsl(var(--primary-foreground))",
33 | },
34 | secondary: {
35 | DEFAULT: "hsl(var(--secondary))",
36 | foreground: "hsl(var(--secondary-foreground))",
37 | },
38 | destructive: {
39 | DEFAULT: "hsl(var(--destructive))",
40 | foreground: "hsl(var(--destructive-foreground))",
41 | },
42 | muted: {
43 | DEFAULT: "hsl(var(--muted))",
44 | foreground: "hsl(var(--muted-foreground))",
45 | },
46 | accent: {
47 | DEFAULT: "hsl(var(--accent))",
48 | foreground: "hsl(var(--accent-foreground))",
49 | },
50 | popover: {
51 | DEFAULT: "hsl(var(--popover))",
52 | foreground: "hsl(var(--popover-foreground))",
53 | },
54 | card: {
55 | DEFAULT: "hsl(var(--card))",
56 | foreground: "hsl(var(--card-foreground))",
57 | },
58 | },
59 | borderRadius: {
60 | lg: "var(--radius)",
61 | md: "calc(var(--radius) - 2px)",
62 | sm: "calc(var(--radius) - 4px)",
63 | },
64 | keyframes: {
65 | "accordion-down": {
66 | from: { height: 0 },
67 | to: { height: "var(--radix-accordion-content-height)" },
68 | },
69 | "accordion-up": {
70 | from: { height: "var(--radix-accordion-content-height)" },
71 | to: { height: 0 },
72 | },
73 | },
74 | animation: {
75 | "accordion-down": "accordion-down 0.2s ease-out",
76 | "accordion-up": "accordion-up 0.2s ease-out",
77 | },
78 | },
79 | },
80 | plugins: [require("tailwindcss-animate")],
81 | };
82 |
--------------------------------------------------------------------------------
/cosmic/elements/Button.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 "@/cosmic/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium 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",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-gradient-to-br from-red-400 to-orange-600 text-white hover:bg-primary/90 shadow-[inset_0_1px_1px_0_rgb(255_255_255_/_0.4),inset_0_-1px_1px_0_rgba(0,0,0,0.2)] dark:shadow-[inset_0_1px_1px_0_rgba(255,255,255,0.4),inset_0_-1px_1px_0_rgba(0,0,0,0.3)] border border-orange-600 hover:opacity-90",
14 | secondary:
15 | "bg-zinc-100 text-zinc-900 hover:bg-zinc-200/75 border border-zinc-200/75 dark:bg-gradient-to-b dark:from-zinc-700 dark:to-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80 dark:border-zinc-700 dark:shadow-white/10 hover:opacity-80 transition-all ease-in-out duration-150 shadow-[inset_0_1px_1px_0_rgb(255_255_255_/_0.9)] dark:shadow-[inset_0_1px_1px_0_rgba(255,255,255,0.2),inset_0_-1px_1px_0_rgba(0,0,0,0.8)]",
16 | destructive:
17 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
18 | outline:
19 | "border border-zinc-200 dark:border-zinc-700 bg-transparent hover:bg-zinc-50 dark:hover:bg-zinc-800 text-zinc-700 dark:text-zinc-300",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
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 |
--------------------------------------------------------------------------------
/cosmic/blocks/user-management/ForgotPasswordForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import Link from "next/link";
5 | import { Button } from "@/cosmic/elements/Button";
6 | import { Input } from "@/cosmic/elements/Input";
7 | import { Label } from "@/cosmic/elements/Label";
8 | import { Loader2 } from "lucide-react";
9 |
10 | interface ForgotPasswordFormProps {
11 | onSubmit: (formData: FormData) => Promise;
12 | }
13 |
14 | export default function ForgotPasswordForm({
15 | onSubmit,
16 | }: ForgotPasswordFormProps) {
17 | const [isLoading, setIsLoading] = useState(false);
18 | const [success, setSuccess] = useState(false);
19 |
20 | const handleSubmit = async (e: React.FormEvent) => {
21 | e.preventDefault();
22 | setIsLoading(true);
23 |
24 | try {
25 | const formData = new FormData(e.currentTarget);
26 | const result = await onSubmit(formData);
27 |
28 | if (result.error) {
29 | throw new Error(result.error);
30 | }
31 |
32 | setSuccess(true);
33 | } catch (err: any) {
34 | console.error(err.message || "An error occurred");
35 | } finally {
36 | setIsLoading(false);
37 | }
38 | };
39 |
40 | if (success) {
41 | return (
42 |
43 |
Check Your Email
44 |
45 | If an account exists with that email address, we've sent
46 | instructions to reset your password.
47 |
48 |
49 | Return to login
50 |
51 |
52 | );
53 | }
54 |
55 | return (
56 |
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/cosmic/blocks/events/EventCard.tsx:
--------------------------------------------------------------------------------
1 | import { cn, getFormattedDate } from "@/cosmic/utils";
2 | import { Calendar, Clock, Pin } from "lucide-react";
3 | import Link from "next/link";
4 |
5 | export type EventCardType = {
6 | title: string;
7 | slug: string;
8 | metadata: {
9 | description: string;
10 | location: string;
11 | start_date: string;
12 | start_time: string;
13 | end_date: string;
14 | end_time: string;
15 | image: {
16 | imgix_url: string;
17 | };
18 | };
19 | };
20 |
21 | export function EventCard({
22 | event,
23 | className,
24 | }: {
25 | event: EventCardType;
26 | className?: string;
27 | }) {
28 | return (
29 |
33 |
34 |

39 |
40 |
41 |
42 | {event.title}
43 |
44 |
45 |
49 |
50 |
51 |
52 | {getFormattedDate(event.metadata.start_date)}
53 |
54 |
55 |
56 | From
57 | {event.metadata.start_time}
58 | until
59 | {event.metadata.start_date !== event.metadata.end_date && (
60 | {getFormattedDate(event.metadata.end_date)}
61 | )}
62 | {event.metadata.end_time}
63 |
64 |
65 |
66 |
{event.metadata.location}
67 |
68 |
69 |
70 |
71 |
72 | );
73 | }
74 |
--------------------------------------------------------------------------------
/app/work/[slug]/page.tsx:
--------------------------------------------------------------------------------
1 | // app/projects/[slug]/page.tsx
2 | import { cosmic } from "@/cosmic/client";
3 | import { ArrowLeftIcon } from "lucide-react";
4 | import Link from "next/link";
5 |
6 | export const revalidate = 60;
7 |
8 | export async function generateStaticParams() {
9 | const { objects: projects } = await cosmic.objects.find({
10 | type: "projects",
11 | });
12 | return projects.map((project: { slug: string }) => ({
13 | slug: project.slug,
14 | }));
15 | }
16 |
17 | export default async function SingleProjectsPage({
18 | params,
19 | }: {
20 | params: { slug: string };
21 | }) {
22 | const { object: project } = await cosmic.objects
23 | .findOne({
24 | type: "projects",
25 | slug: params.slug,
26 | })
27 | .props("id,slug,title,metadata")
28 | .depth(1);
29 |
30 | return (
31 |
32 |
33 | {project.metadata.image?.imgix_url && (
34 |

39 | )}
40 |
41 |
42 |
43 |
44 | {project.title}
45 |
46 |
47 |

52 |
53 |
54 | {project.metadata.client.title}
55 |
56 |
57 | {project.metadata.year}
58 |
59 |
60 |
61 |
65 |
66 |
67 |
Back to projects
68 |
69 |
70 |
71 |
72 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/cosmic/blocks/user-management/DashboardClient.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 | import { useRouter } from "next/navigation";
5 | import { getUserData } from "@/cosmic/blocks/user-management/actions";
6 | import { useAuth } from "@/cosmic/blocks/user-management/AuthContext";
7 | import { UserProfileForm } from "@/cosmic/blocks/user-management/UserProfileForm";
8 | import { Loader2 } from "lucide-react";
9 |
10 | export default function DashboardClient() {
11 | const { user, isLoading, logout } = useAuth();
12 | const [userData, setUserData] = useState(null);
13 | const [error, setError] = useState(null);
14 | const router = useRouter();
15 |
16 | useEffect(() => {
17 | let isMounted = true;
18 |
19 | const checkUserAndFetchData = async () => {
20 | if (isLoading) return;
21 |
22 | if (!user) {
23 | router.push("/login");
24 | return;
25 | }
26 |
27 | try {
28 | const { data, error } = await getUserData(user.id);
29 |
30 | if (!isMounted) return;
31 |
32 | if (error) {
33 | if (error === "Account is not active") {
34 | logout();
35 | router.push("/login?error=Your account is no longer active");
36 | return;
37 | }
38 | setError(error);
39 | } else {
40 | setUserData(data);
41 | }
42 | } catch (err) {
43 | if (!isMounted) return;
44 | setError("Failed to fetch user data");
45 | }
46 | };
47 |
48 | checkUserAndFetchData();
49 |
50 | return () => {
51 | isMounted = false;
52 | };
53 | }, [user, isLoading, logout, router]);
54 |
55 | if (isLoading) {
56 | return (
57 |
58 |
59 |
60 | );
61 | }
62 |
63 | if (!user) {
64 | return null;
65 | }
66 |
67 | if (error === "Account is not active") {
68 | return null; // Don't show anything while redirecting
69 | }
70 |
71 | if (error) {
72 | return (
73 |
76 | );
77 | }
78 |
79 | if (!userData) {
80 | return (
81 |
82 |
83 |
84 | );
85 | }
86 |
87 | return (
88 |
89 |
90 | Welcome, {userData.metadata.first_name}!
91 |
92 |
93 |
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/cosmic/blocks/ecommerce/AddToCart.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { useContext } from "react";
3 | import { Button } from "@/cosmic/elements/Button";
4 | import { useState } from "react";
5 | import { Loader2, XIcon } from "lucide-react";
6 | import { CartContext } from "@/cosmic/blocks/ecommerce/CartProvider";
7 |
8 | export type ProductType = {
9 | title: string;
10 | id: string;
11 | slug: string;
12 | metadata: {
13 | stripe_product_id: string;
14 | image: {
15 | imgix_url: string;
16 | };
17 | price: number;
18 | recurring: any;
19 | is_recurring: boolean;
20 | };
21 | };
22 |
23 | export function AddToCart({
24 | product,
25 | className,
26 | }: {
27 | product: ProductType;
28 | className?: string;
29 | }) {
30 | const { setCart, setCartOpen } = useContext(CartContext);
31 | let cart: ProductType[] = [];
32 | if (typeof window !== "undefined") {
33 | cart = JSON.parse(localStorage.getItem("cart") || "[]");
34 | }
35 | function addProduct(cartItem: ProductType) {
36 | cart.push(cartItem);
37 | localStorage.setItem("cart", JSON.stringify(cart));
38 | setTimeout(() => setCartOpen(true), 500);
39 | }
40 |
41 | function removeProduct(productId: string) {
42 | if (!cart) return;
43 | let newCart = cart.filter(
44 | (product: ProductType) => product.id !== productId
45 | );
46 | localStorage.setItem("cart", JSON.stringify(newCart));
47 | return newCart;
48 | }
49 |
50 | function productInCart(product: ProductType) {
51 | let productInCart = cart.filter(
52 | (productLoop: ProductType) => productLoop.id === product.id
53 | )[0];
54 | return productInCart;
55 | }
56 | const [submitting, setSubmitting] = useState(false);
57 | async function handleSubmit() {
58 | setSubmitting(true);
59 | if (productInCart(product)) {
60 | removeProduct(product.id);
61 | } else {
62 | addProduct(product);
63 | }
64 | await setTimeout(() => setSubmitting(false), 500);
65 | setCart(JSON.parse(localStorage.getItem("cart") || "[]"));
66 | }
67 | const inCart = productInCart(product);
68 | return (
69 |
70 | {inCart ? (
71 |
87 | ) : (
88 |
97 | )}
98 |
99 | );
100 | }
101 |
--------------------------------------------------------------------------------
/cosmic/blocks/user-management/ResetPasswordForm.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useState } from "react"
4 | import { Button } from "@/cosmic/elements/Button"
5 | import { Loader2 } from "lucide-react"
6 | import { useRouter } from "next/navigation"
7 | import { Input } from "@/cosmic/elements/Input"
8 | import { Label } from "@/cosmic/elements/Label"
9 |
10 | interface ResetPasswordFormProps {
11 | token: string
12 | onSubmit: (token: string, formData: FormData) => Promise
13 | }
14 |
15 | export default function ResetPasswordForm({
16 | token,
17 | onSubmit,
18 | }: ResetPasswordFormProps) {
19 | const [isLoading, setIsLoading] = useState(false)
20 | const [error, setError] = useState("")
21 | const router = useRouter()
22 |
23 | const handleSubmit = async (e: React.FormEvent) => {
24 | e.preventDefault()
25 | setIsLoading(true)
26 | setError("")
27 |
28 | try {
29 | const formData = new FormData(e.currentTarget)
30 | const password = formData.get("password") as string
31 | const confirmPassword = formData.get("confirmPassword") as string
32 |
33 | if (password !== confirmPassword) {
34 | throw new Error("Passwords do not match")
35 | }
36 |
37 | const result = await onSubmit(token, formData)
38 |
39 | if (result.error) {
40 | throw new Error(result.error)
41 | }
42 |
43 | // Redirect to login with success message
44 | router.push(
45 | "/login?success=Password reset successful. Please login with your new password."
46 | )
47 | } catch (err: any) {
48 | setError(err.message || "An error occurred")
49 | } finally {
50 | setIsLoading(false)
51 | }
52 | }
53 |
54 | return (
55 |
100 | )
101 | }
102 |
--------------------------------------------------------------------------------
/cosmic/blocks/team/TeamCard.tsx:
--------------------------------------------------------------------------------
1 | // components/team-card.tsx
2 | import { cn } from "@/cosmic/utils";
3 |
4 | export type MemberType = {
5 | title: string;
6 | slug: string;
7 | metadata: {
8 | image: {
9 | imgix_url: string;
10 | };
11 | position: string;
12 | bio: string;
13 | links: {
14 | x: string;
15 | linkedin: string;
16 | };
17 | };
18 | };
19 | export function TeamCard({
20 | member,
21 | className,
22 | }: {
23 | member: MemberType;
24 | className?: string;
25 | }) {
26 | return (
27 |
33 |
34 |

39 |
40 |
41 |
42 | {member.title}
43 |
44 |
45 | {member.metadata.position}
46 |
47 |
48 | {member.metadata.bio}
49 |
50 |
93 |
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Agency Template, powered by Blocks
2 |
3 | 
4 |
5 | A website template built using Cosmic's React components, [Blocks](https://blocks.cosmicjs.com). Use this template to get familiar with Blocks and how they can be used to build common content-powered features for your websites and apps, or simply use it as a starting point for your next project. Save development time and learn content modeling best practices.
6 |
7 | ## Links
8 |
9 | [Install this template](https://www.cosmicjs.com/marketplace/templates/agency)
10 |
11 | [View the live demo](https://cosmic-agency-template.vercel.app/)
12 |
13 | ## Features
14 |
15 | ✨ NEW: 👤 User management powered by the User Management Block
16 |
17 | ✨ NEW: ✉️ Contact form powered by the Contact Form Block
18 |
19 | 🛒 Product management and checkout using Stripe
20 |
21 | ⚡️ Performance optimized
22 |
23 | 👁 Draft preview ready
24 |
25 | 📱 Mobile responsive
26 |
27 | 🌎 Localization ready
28 |
29 | 🌓 Dark mode ready
30 |
31 | 🔧 Customizable
32 |
33 | ♿️ Accessible
34 |
35 | 🦺 Type safe
36 |
37 | 
38 | 
39 |
40 | ## Getting started
41 |
42 | 1. [Install this template in Cosmic](https://www.cosmicjs.com/marketplace/templates/agency).
43 |
44 | 2. Download this code repository and install the dependencies.
45 |
46 | ```bash
47 | git clone https://github.com/cosmicjs/agency-template
48 | cd agency-template
49 | bun install
50 | ```
51 |
52 | 3. Create a `.env.local` file with your Cosmic API keys. Find these after logging in to the Cosmic dashboard in [Project > API keys](https://app.cosmicjs.com/?redirect_to=?highlight=api-keys).
53 |
54 | ```
55 | cp .env.example .env.local
56 | ```
57 |
58 | It will look like this. To use the User Management and Contact Form features you will need to have a [Resend](https://resend.com/) account and add your API key.
59 |
60 | ```
61 | # .env.local
62 | COSMIC_BUCKET_SLUG=change_to_your_bucket_slug
63 | COSMIC_READ_KEY=change_to_your_bucket_read_key
64 | COSMIC_WRITE_KEY=change_to_your_bucket_write_key
65 |
66 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=change_to_your_stripe_public_key
67 | STRIPE_SECRET_KEY=change_to_your_stripe_secret_key
68 |
69 | RESEND_API_KEY=change_to_your_resend_api_key
70 | NEXT_PUBLIC_APP_URL=change_to_your_app_url
71 | NEXT_PUBLIC_APP_NAME="Change to your app name"
72 | SUPPORT_EMAIL=change_to_your_support_email
73 | CONTACT_EMAIL=change_to_your_contact_email
74 | ```
75 |
76 | 4. Run the template.
77 |
78 | ```
79 | bun dev
80 | ```
81 |
82 | Open [http://localhost:3000](http://localhost:3000).
83 |
84 | ## Deploy to Vercel
85 |
86 | Use the following button to deploy to Vercel. You will be asked to add API accesss keys as environment variables.
87 |
88 |
89 |
90 | ## License
91 |
92 | Licensed under the [MIT license](https://github.com/cosmicjs/agency-template/blob/main/LICENSE).
93 |
--------------------------------------------------------------------------------
/cosmic/blocks/blog/BlogCard.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { getFormattedDate } from "@/cosmic/utils";
3 |
4 | export type PostType = {
5 | id: string;
6 | title: string;
7 | slug: string;
8 | metadata: {
9 | categories: any;
10 | image: {
11 | imgix_url: string;
12 | };
13 | content: string;
14 | author: {
15 | title: string;
16 | metadata: {
17 | image: {
18 | imgix_url: string;
19 | };
20 | };
21 | };
22 | published_date: string;
23 | };
24 | };
25 |
26 | export function BlogCard({
27 | post,
28 | className,
29 | }: {
30 | post: PostType;
31 | className?: string;
32 | }) {
33 | return (
34 |
35 |
40 |
41 |

46 |
47 |
51 |
52 | {post.metadata.categories.map((category: any) => {
53 | return (
54 |
58 | {category.title}
59 |
60 | );
61 | })}
62 |
63 | {post.title}
64 |
65 |
66 | {post.metadata.content.slice(0, 200)}...
67 |
68 |
69 |
73 |
74 |
75 |
79 |

84 |
85 |
86 |
87 |
88 | {post.metadata.author.title}
89 |
90 |
91 | {getFormattedDate(post.metadata.published_date)}
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/cosmic/blocks/pages/Page.tsx:
--------------------------------------------------------------------------------
1 | // app/page.tsx
2 | import { cn } from "@/cosmic/utils";
3 | import { buttonVariants } from "@/cosmic/elements/Button";
4 | import { Section } from "./PageSection";
5 | import { cosmic } from "@/cosmic/client";
6 | import Link from "next/link";
7 | import { notFound } from "next/navigation";
8 |
9 | export async function Page({
10 | query,
11 | className,
12 | status,
13 | }: {
14 | query: any;
15 | className?: string;
16 | status?: "draft" | "published" | "any";
17 | }) {
18 | try {
19 | const { object: page } = await cosmic.objects
20 | .findOne(query)
21 | .props("slug,title,metadata")
22 | .depth(1)
23 | .status(status ? status : "published");
24 |
25 | return (
26 |
27 |
28 |
29 |
30 |
31 | {page.metadata.h1}
32 |
33 |
34 |
35 |
36 | {page.metadata.subheadline}
37 |
38 |
39 |
40 |
41 |
50 | Get started free
51 |
52 |
61 | Contact us
62 |
63 |
64 |
65 |
66 |
67 |

72 |

77 |
78 |
79 |
80 |
81 |
82 | {page.metadata.section_title}
83 |
84 |
88 |
89 | {page.metadata.sections.map((section: any) => {
90 | return ;
91 | })}
92 |
93 |
94 |
95 |
96 | );
97 | } catch (e: any) {
98 | if (e.status === 404) return notFound();
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/cosmic/blocks/pages/PageSection.tsx:
--------------------------------------------------------------------------------
1 | // cosmic/blocks/pages/PageSection.tsx
2 | import { cn } from "@/cosmic/utils";
3 | import { buttonVariants } from "@/cosmic/elements/Button";
4 | import Link from "next/link";
5 |
6 | type SectionType = {
7 | heading: string;
8 | layout: {
9 | key: string;
10 | value: string;
11 | };
12 | image: {
13 | url: string;
14 | imgix_url: string;
15 | };
16 | content: string;
17 | cta_link: string;
18 | cta_text: string;
19 | };
20 |
21 | export function Section({
22 | section,
23 | className,
24 | }: {
25 | section: SectionType;
26 | className?: string;
27 | }) {
28 | return (
29 |
30 | {section.layout.key === "1-column-center" && (
31 |
32 |
33 |
{section.heading}
34 |
38 |
39 |
48 | {section.cta_text}
49 |
50 |
51 |
52 |
53 |

58 |
59 |
60 | )}
61 | {section.layout.key === "2-column-image-content" && (
62 |
63 |
64 |

69 |
70 |
71 |
{section.heading}
72 |
76 |
77 |
86 | {section.cta_text}
87 |
88 |
89 |
90 |
91 | )}
92 | {section.layout.key === "2-column-content-image" && (
93 |
94 |
95 |
{section.heading}
96 |
100 |
101 |
110 | {section.cta_text}
111 |
112 |
113 |
114 |
115 |

120 |
121 |
122 | )}
123 |
124 | );
125 | }
126 |
--------------------------------------------------------------------------------
/cosmic/blocks/blog/SingleBlog.tsx:
--------------------------------------------------------------------------------
1 | // app/blog/[slug]/page.tsx
2 | import { cosmic } from "@/cosmic/client";
3 | import Markdown from "react-markdown";
4 | import { ArrowLeftIcon } from "lucide-react";
5 | import Link from "next/link";
6 | import { notFound } from "next/navigation";
7 | import { Comments } from "@/cosmic/blocks/comments/Comments";
8 | import { getFormattedDate } from "@/cosmic/utils";
9 |
10 | export async function SingleBlog({
11 | query,
12 | className,
13 | status,
14 | }: {
15 | query: any;
16 | className?: string;
17 | status?: "draft" | "published" | "any";
18 | }) {
19 | try {
20 | const { object: blog } = await cosmic.objects
21 | .findOne(query)
22 | .props("id,slug,title,metadata")
23 | .depth(1)
24 | .status(status ? status : "published");
25 |
26 | const date = getFormattedDate(blog.metadata.published_date);
27 |
28 | return (
29 |
30 |
31 |
32 |
33 |
37 |
Back to blog
38 |
39 |
40 |
41 | {blog.title}
42 |
43 |
44 |

49 |
50 |
51 |

56 |
57 |
58 | {blog.metadata.author.title}
59 |
60 | {date}
61 |
62 |
63 | {blog.metadata.categories.map((category: any) => {
64 | const categoryBackgroundColor = `${category.metadata.color}22`;
65 | return (
66 |
74 | {category.title}
75 |
76 | );
77 | })}
78 |
79 |
80 |
81 | {blog.metadata.content}
82 |
83 |
91 |
92 |
96 |
Back to blog
97 |
98 |
99 |
100 |
101 |
102 | );
103 | } catch (e: any) {
104 | if (e.status === 404) return notFound();
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/cosmic/blocks/comments/CommentForm.tsx:
--------------------------------------------------------------------------------
1 | // components/comment-form.tsx
2 | "use client";
3 |
4 | import { useState } from "react";
5 | import { CheckCircle, Loader2, XCircle } from "lucide-react";
6 | import { cn } from "@/cosmic/utils";
7 |
8 | import { Button } from "@/cosmic/elements/Button";
9 | import { Input } from "@/cosmic/elements/Input";
10 | import { Label } from "@/cosmic/elements/Label";
11 | import { Textarea } from "@/cosmic/elements/TextArea";
12 |
13 | export function CommentForm({
14 | resourceId,
15 | className,
16 | }: {
17 | resourceId: string;
18 | className?: string;
19 | }) {
20 | const [name, setName] = useState("");
21 | const [email, setEmail] = useState("");
22 | const [comment, setComment] = useState("");
23 | const [submitting, setSubmitting] = useState(false);
24 | const [sumbitted, setSubmitted] = useState(false);
25 | const [error, setError] = useState(false);
26 | async function handleSubmitComment(e: React.SyntheticEvent) {
27 | setError(false);
28 | setSubmitting(true);
29 | if (!name.trim() || !email.trim() || !comment.trim()) {
30 | setSubmitting(false);
31 | setError(true);
32 | return;
33 | }
34 | const newComment = {
35 | type: "comments",
36 | title: name,
37 | metadata: {
38 | email,
39 | comment,
40 | resource: resourceId, // Add resource id here such as blog post or product id
41 | },
42 | };
43 | try {
44 | await fetch("/api/comments", {
45 | method: "POST",
46 | body: JSON.stringify({ comment: newComment }),
47 | });
48 | } catch (err) {
49 | setSubmitting(false);
50 | setError(true);
51 | return;
52 | }
53 | setSubmitting(false);
54 | setSubmitted(true);
55 | setTimeout(() => {
56 | setSubmitted(false);
57 | setName("");
58 | setEmail("");
59 | setComment("");
60 | }, 3000);
61 | }
62 | function handleChangeName(e: React.SyntheticEvent) {
63 | const target = e.target as HTMLInputElement;
64 | setName(target.value);
65 | }
66 | function handleChangeEmail(e: React.SyntheticEvent) {
67 | const target = e.target as HTMLInputElement;
68 | setEmail(target.value);
69 | }
70 | function handleChangeComment(e: React.SyntheticEvent) {
71 | const target = e.target as HTMLInputElement;
72 | setComment(target.value);
73 | }
74 | return (
75 |
76 |
Add a new comment
77 | {error && (
78 |
79 |
80 | There was an error with your request. Make sure all fields are valid.
81 |
82 | )}
83 | {sumbitted ? (
84 |
85 |
86 | Comment submitted for approval.
87 |
88 | ) : (
89 | <>
90 |
91 |
92 |
98 |
99 |
100 |
101 |
107 |
108 |
109 |
110 |
116 |
117 |
118 |
132 |
133 | >
134 | )}
135 |
136 | );
137 | }
138 |
--------------------------------------------------------------------------------
/cosmic/blocks/user-management/AuthForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import Link from "next/link";
5 | import { useRouter } from "next/navigation";
6 | import { useAuth } from "@/cosmic/blocks/user-management/AuthContext";
7 | import { Button } from "@/cosmic/elements/Button";
8 | import { Input } from "@/cosmic/elements/Input";
9 | import { Label } from "@/cosmic/elements/Label";
10 | import { Loader2 } from "lucide-react";
11 |
12 | interface AuthFormProps {
13 | type: "login" | "signup";
14 | onSubmit?: (data: FormData) => Promise;
15 | }
16 |
17 | export default function AuthForm({ type, onSubmit }: AuthFormProps) {
18 | const [isLoading, setIsLoading] = useState(false);
19 | const [error, setError] = useState("");
20 | const router = useRouter();
21 | const { login: authLogin } = useAuth();
22 |
23 | const handleSubmit = async (e: React.FormEvent) => {
24 | e.preventDefault();
25 | setIsLoading(true);
26 | setError("");
27 |
28 | try {
29 | const formData = new FormData(e.currentTarget);
30 |
31 | if (onSubmit) {
32 | const result = await onSubmit(formData);
33 |
34 | if (result.error) {
35 | setError(result.error);
36 | return;
37 | }
38 |
39 | if (type === "login" && result.user) {
40 | authLogin(result.user);
41 | router.push("/dashboard");
42 | router.refresh();
43 | }
44 | }
45 | } catch (err: any) {
46 | setError(err.message || "An error occurred");
47 | } finally {
48 | setIsLoading(false);
49 | }
50 | };
51 |
52 | return (
53 |
151 | );
152 | }
153 |
--------------------------------------------------------------------------------
/cosmic/blocks/contact-form/ContactForm.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useState } from "react"
4 | import { CheckCircle, Loader2, XCircle } from "lucide-react"
5 | import { cn } from "@/cosmic/utils"
6 |
7 | import { Button } from "@/cosmic/elements/Button"
8 | import { Input } from "@/cosmic/elements/Input"
9 | import { Label } from "@/cosmic/elements/Label"
10 | import { Textarea } from "@/cosmic/elements/TextArea"
11 | import {
12 | addSubmission,
13 | AddSubmissionType,
14 | } from "@/cosmic/blocks/contact-form/actions"
15 |
16 | export function ContactForm({ className }: { className?: string }) {
17 | const [name, setName] = useState("")
18 | const [email, setEmail] = useState("")
19 | const [company, setCompany] = useState("")
20 | const [message, setMessage] = useState("")
21 | const [submitting, setSubmitting] = useState(false)
22 | const [sumbitted, setSubmitted] = useState(false)
23 | const [error, setError] = useState(false)
24 | async function handleSubmitComment(e: React.SyntheticEvent) {
25 | setError(false)
26 | setSubmitting(true)
27 | if (!name.trim() || !email.trim() || !message.trim()) {
28 | setSubmitting(false)
29 | setError(true)
30 | return
31 | }
32 | const newSubmission: AddSubmissionType = {
33 | type: "form-submissions",
34 | title: name,
35 | metadata: {
36 | email,
37 | company,
38 | message,
39 | },
40 | }
41 | try {
42 | const res = await addSubmission(newSubmission)
43 | if (!res.object) {
44 | setSubmitting(false)
45 | setError(true)
46 | return
47 | } else {
48 | setSubmitting(false)
49 | setSubmitted(true)
50 | setTimeout(() => {
51 | setSubmitted(false)
52 | setName("")
53 | setEmail("")
54 | setCompany("")
55 | setMessage("")
56 | }, 3000)
57 | }
58 | } catch (err) {
59 | setSubmitting(false)
60 | setError(true)
61 | return
62 | }
63 | }
64 | function handleChangeName(e: React.SyntheticEvent) {
65 | const target = e.target as HTMLInputElement
66 | setName(target.value)
67 | }
68 | function handleChangeEmail(e: React.SyntheticEvent) {
69 | const target = e.target as HTMLInputElement
70 | setEmail(target.value)
71 | }
72 | function handleChangeCompany(e: React.SyntheticEvent) {
73 | const target = e.target as HTMLInputElement
74 | setCompany(target.value)
75 | }
76 | function handleChangeMessage(e: React.SyntheticEvent) {
77 | const target = e.target as HTMLInputElement
78 | setMessage(target.value)
79 | }
80 | return (
81 |
82 |
Contact us
83 | {error && (
84 |
85 |
86 | There was an error with your request. Make sure all fields are valid.
87 |
88 | )}
89 | {sumbitted ? (
90 |
91 |
92 | Message submitted.
93 |
94 | ) : (
95 | <>
96 |
97 |
98 |
104 |
105 |
106 |
107 |
113 |
114 |
115 |
116 |
122 |
123 |
124 |
125 |
131 |
132 |
133 |
147 |
148 | >
149 | )}
150 |
151 | )
152 | }
153 |
--------------------------------------------------------------------------------
/components/Banner.tsx:
--------------------------------------------------------------------------------
1 | export default function Banner(): JSX.Element {
2 | return (
3 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/cosmic/blocks/user-management/UserProfileForm.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | "use client";
3 |
4 | import { useState } from "react";
5 | import { useAuth } from "@/cosmic/blocks/user-management/AuthContext";
6 | import { Button } from "@/cosmic/elements/Button";
7 | import { updateUserProfile } from "./actions";
8 | import { Loader2 } from "lucide-react";
9 | import { useFormStatus } from "react-dom";
10 | import { Input } from "@/cosmic/elements/Input";
11 | import { Label } from "@/cosmic/elements/Label";
12 |
13 | interface UserProfileFormProps {
14 | user: {
15 | id: string;
16 | metadata: {
17 | first_name: string;
18 | last_name: string;
19 | email: string;
20 | email_verified: boolean;
21 | avatar?: {
22 | imgix_url: string;
23 | };
24 | };
25 | };
26 | }
27 |
28 | function SubmitButton() {
29 | const { pending } = useFormStatus();
30 |
31 | return (
32 |
39 | );
40 | }
41 |
42 | export function UserProfileForm({ user }: UserProfileFormProps) {
43 | const [isAvatarUploading, setIsAvatarUploading] = useState(false);
44 | const [message, setMessage] = useState("");
45 | const { login } = useAuth();
46 |
47 | const handleSubmit = async (formData: FormData) => {
48 | setMessage("");
49 |
50 | try {
51 | const result = await updateUserProfile(user.id, formData);
52 |
53 | if (result.success) {
54 | login({
55 | id: result.data.id,
56 | name: result.data.title,
57 | email: result.data.metadata.email,
58 | image: result.data.metadata.avatar?.imgix_url,
59 | });
60 |
61 | setMessage("Profile updated successfully!");
62 | } else {
63 | setMessage(result.error || "Error updating profile");
64 | }
65 | } catch (error) {
66 | setMessage("Error updating profile");
67 | }
68 | };
69 |
70 | const [avatarPreview, setAvatarPreview] = useState(
71 | user.metadata.avatar?.imgix_url || null
72 | );
73 |
74 | const handleAvatarChange = async (
75 | event: React.ChangeEvent
76 | ) => {
77 | const file = event.target.files?.[0];
78 | if (file) {
79 | setIsAvatarUploading(true);
80 | const url = URL.createObjectURL(file);
81 | setAvatarPreview(url);
82 | setIsAvatarUploading(false);
83 | }
84 | };
85 |
86 | return (
87 |
172 | );
173 | }
174 |
--------------------------------------------------------------------------------
/cosmic/blocks/ecommerce/SingleProduct.tsx:
--------------------------------------------------------------------------------
1 | // app/shop/[slug]/page.tsx
2 | import { cosmic } from "@/cosmic/client";
3 | import Link from "next/link";
4 | import { ImageGallery } from "@/cosmic/blocks/image-gallery/ImageGallery";
5 | import { cn } from "@/cosmic/utils";
6 | import { notFound } from "next/navigation";
7 | import { CheckCircleIcon, XCircleIcon } from "lucide-react";
8 | import { Button } from "@/cosmic/elements/Button";
9 | import { AddToCart } from "@/cosmic/blocks/ecommerce/AddToCart";
10 |
11 | export async function SingleProduct({
12 | query,
13 | className,
14 | status,
15 | purchased,
16 | }: {
17 | query: any;
18 | className?: string;
19 | status?: "draft" | "published" | "any";
20 | purchased?: boolean;
21 | }) {
22 | try {
23 | const { object: product } = await cosmic.objects
24 | .findOne(query)
25 | .props("id,slug,title,metadata")
26 | .depth(1)
27 | .status(status ? status : "published");
28 |
29 | return (
30 |
31 |
32 | {purchased && (
33 |
34 |
35 |
36 | Purchase complete. Thank you for your order, we will be in touch
37 | with your order details!
38 |
39 |
40 | )}
41 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | {product.title}
75 |
76 |
77 | ${product.metadata.price.toLocaleString("en-US")}
78 | {product.metadata.recurring.is_recurring && (
79 |
80 | {" "}
81 | /{" "}
82 | {product.metadata.recurring.interval_count &&
83 | product.metadata.recurring.interval_count !== 1
84 | ? product.metadata.recurring.interval_count
85 | : ""}{" "}
86 | {product.metadata.recurring.interval.value}
87 | {product.metadata.recurring.interval_count &&
88 | product.metadata.recurring.interval_count !== 1
89 | ? "s"
90 | : ""}
91 |
92 | )}
93 |
94 |
95 | {!product.metadata.quantity ? (
96 |
103 | ) : (
104 | <>
105 | {product.metadata.stripe_product_id ? (
106 | <>
107 |
108 | >
109 | ) : (
110 |
111 |
112 | Product not available for purchase
113 |
114 | )}
115 | >
116 | )}
117 |
118 |
119 | Details
120 |
121 |
127 |
128 |
129 |
130 |
131 | );
132 | } catch (e: any) {
133 | if (e.status === 404) return notFound();
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/cosmic/blocks/events/SingleEvent.tsx:
--------------------------------------------------------------------------------
1 | // app/events/[slug]/page.tsx
2 | import { cosmic } from "@/cosmic/client";
3 | import Link from "next/link";
4 | import { Button } from "@/cosmic/elements/Button";
5 | import { cn, getFormattedDate } from "@/cosmic/utils";
6 | import { notFound } from "next/navigation";
7 |
8 | export async function SingleEvent({
9 | query,
10 | className,
11 | status,
12 | }: {
13 | query: any;
14 | className?: string;
15 | status?: "draft" | "published" | "any";
16 | }) {
17 | try {
18 | const { object: event } = await cosmic.objects
19 | .findOne(query)
20 | .props("id,slug,title,metadata")
21 | .depth(1)
22 | .status(status ? status : "published");
23 |
24 | return (
25 |
26 |
27 |
54 |
55 |
56 |

61 |
62 |
63 |
64 | {event.title}
65 |
66 |
67 |
68 |
69 |
70 | Date and Time
71 |
72 |
73 | {getFormattedDate(event.metadata.start_date)}
74 | from
75 | {event.metadata.start_time}
76 | until
77 | {event.metadata.start_date !== event.metadata.end_date && (
78 | {getFormattedDate(event.metadata.end_date)}
79 | )}
80 | {event.metadata.end_time}
81 |
82 |
83 | Details
84 |
85 |
91 |
92 |
93 | Location
94 |
95 |
101 |
102 |
103 |
104 | Agenda
105 |
106 |
107 |
108 | {event.metadata.agenda.map((item: any) => (
109 | -
113 |
114 | {item.time}
115 |
116 | {item.item}
117 |
118 | ))}
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 | );
127 | } catch (e: any) {
128 | if (e.status === 404) return notFound();
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/cosmic/blocks/ecommerce/CheckOut.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | "use client";
3 | import React from "react";
4 | import { loadStripe } from "@stripe/stripe-js";
5 | import { Button } from "@/cosmic/elements/Button";
6 | import { ShoppingCartIcon, XIcon, Trash2Icon } from "lucide-react";
7 | import { useState, useContext } from "react";
8 | import { CartContext } from "@/cosmic/blocks/ecommerce/CartProvider";
9 | import { ProductType } from "@/cosmic/blocks/ecommerce/AddToCart";
10 | import Link from "next/link";
11 | import { cn } from "@/cosmic/utils";
12 | import { createCheckoutSession } from "@/app/actions/checkout";
13 |
14 | const stripePromise = loadStripe(
15 | process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || ""
16 | );
17 |
18 | function cartTotal(cart: ProductType[]) {
19 | let total = 0;
20 | for (const item of cart) {
21 | total += item.metadata.price;
22 | }
23 | return total;
24 | }
25 |
26 | export function CheckOut({
27 | className,
28 | productPath,
29 | }: {
30 | className?: string;
31 | productPath: string;
32 | }) {
33 | const [submitting, setSubmitting] = useState(false);
34 | const { cart, setCart, cartOpen, setCartOpen } = useContext(CartContext);
35 | const [error, setError] = useState();
36 |
37 | function removeItem(item: ProductType) {
38 | const newCart = cart.filter(
39 | (product: ProductType) => product.id !== item.id
40 | );
41 | setCart(newCart);
42 | localStorage.setItem("cart", JSON.stringify(newCart));
43 | }
44 |
45 | function CartItem({
46 | item,
47 | removeItem,
48 | }: {
49 | item: ProductType;
50 | removeItem: any;
51 | }) {
52 | return (
53 |
54 |
55 |
56 |

61 |
62 |
63 |
64 |
65 | {item.title}
66 |
67 |
68 | ${item.metadata.price.toLocaleString("en-US")}
69 | {item.metadata.recurring.is_recurring && (
70 |
71 | {" "}
72 | /{" "}
73 | {item.metadata.recurring.interval_count &&
74 | item.metadata.recurring.interval_count !== 1
75 | ? item.metadata.recurring.interval_count
76 | : ""}{" "}
77 | {item.metadata.recurring.interval.value}
78 | {item.metadata.recurring.interval_count &&
79 | item.metadata.recurring.interval_count !== 1
80 | ? "s"
81 | : ""}
82 |
83 | )}
84 |
85 |
removeItem(item)}>
86 | {" "}
87 | Remove
88 |
89 |
90 |
91 | );
92 | }
93 | async function handleSubmit() {
94 | setSubmitting(true);
95 | try {
96 | const stripe_product_ids = cart.map((product: any) => {
97 | return product.metadata.stripe_product_id;
98 | });
99 | const result = await createCheckoutSession(
100 | stripe_product_ids,
101 | window.location.href.split("?")[0]
102 | );
103 | if (result.url) window.location = result.url;
104 | } catch (err: any) {
105 | setError(err.message);
106 | setSubmitting(false);
107 | }
108 | }
109 | return (
110 |
111 | {cart.length ? (
112 | <>
113 |
119 | {cartOpen ? (
120 |
121 |
Your Cart
122 |
123 | {cart.map((item: ProductType) => {
124 | return (
125 |
130 | );
131 | })}
132 |
133 |
148 |
setCartOpen(false)}
150 | className="mt-4 text-center cursor-pointer underline"
151 | >
152 | Continue shopping
153 |
154 |
155 | ) : (
156 | ""
157 | )}
158 | {error && (
159 |
160 | setError(false)}
163 | />
164 | There was an error from the API:
165 |
166 | {error}
167 |
168 | )}
169 | >
170 | ) : (
171 | ""
172 | )}
173 |
174 | );
175 | }
176 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
5 | import { Check, ChevronRight, Circle } from "lucide-react";
6 |
7 | import { cn } from "@/cosmic/utils";
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root;
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group;
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub;
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean;
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ));
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName;
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ));
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName;
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
73 |
74 | ));
75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
76 |
77 | const DropdownMenuItem = React.forwardRef<
78 | React.ElementRef,
79 | React.ComponentPropsWithoutRef & {
80 | inset?: boolean;
81 | }
82 | >(({ className, inset, ...props }, ref) => (
83 |
92 | ));
93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
94 |
95 | const DropdownMenuCheckboxItem = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, children, checked, ...props }, ref) => (
99 |
108 |
109 |
110 |
111 |
112 |
113 | {children}
114 |
115 | ));
116 | DropdownMenuCheckboxItem.displayName =
117 | DropdownMenuPrimitive.CheckboxItem.displayName;
118 |
119 | const DropdownMenuRadioItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ));
139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
140 |
141 | const DropdownMenuLabel = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef & {
144 | inset?: boolean;
145 | }
146 | >(({ className, inset, ...props }, ref) => (
147 |
156 | ));
157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
158 |
159 | const DropdownMenuSeparator = React.forwardRef<
160 | React.ElementRef,
161 | React.ComponentPropsWithoutRef
162 | >(({ className, ...props }, ref) => (
163 |
168 | ));
169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
170 |
171 | const DropdownMenuShortcut = ({
172 | className,
173 | ...props
174 | }: React.HTMLAttributes) => {
175 | return (
176 |
180 | );
181 | };
182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
183 |
184 | export {
185 | DropdownMenu,
186 | DropdownMenuTrigger,
187 | DropdownMenuContent,
188 | DropdownMenuItem,
189 | DropdownMenuCheckboxItem,
190 | DropdownMenuRadioItem,
191 | DropdownMenuLabel,
192 | DropdownMenuSeparator,
193 | DropdownMenuShortcut,
194 | DropdownMenuGroup,
195 | DropdownMenuPortal,
196 | DropdownMenuSub,
197 | DropdownMenuSubContent,
198 | DropdownMenuSubTrigger,
199 | DropdownMenuRadioGroup,
200 | };
201 |
--------------------------------------------------------------------------------
/cosmic/blocks/user-management/actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import crypto from "crypto";
4 | import { cookies } from "next/headers";
5 | import { cosmic } from "@/cosmic/client";
6 | import bcrypt from "bcryptjs";
7 | import { Resend } from "resend";
8 |
9 | const resend = new Resend(process.env.RESEND_API_KEY);
10 |
11 | function isValidPassword(password: string): boolean {
12 | return (
13 | password.length >= 8 && /[A-Za-z]/.test(password) && /[0-9]/.test(password)
14 | );
15 | }
16 |
17 | export async function signUp(formData: FormData) {
18 | try {
19 | const email = (formData.get("email") as string).toLowerCase();
20 | const password = formData.get("password") as string;
21 | const firstName = formData.get("firstName") as string;
22 | const lastName = formData.get("lastName") as string;
23 |
24 | // Add password validation
25 | if (!isValidPassword(password)) {
26 | return {
27 | success: false,
28 | error:
29 | "Password must be at least 8 characters long and contain both letters and numbers",
30 | };
31 | }
32 |
33 | // Check if user already exists
34 | let existingUser;
35 | try {
36 | existingUser = await cosmic.objects
37 | .findOne({
38 | type: "users",
39 | "metadata.email": email,
40 | })
41 | .props(["metadata"])
42 | .depth(0);
43 | } catch (err) {
44 | // User does not exist
45 | }
46 |
47 | if (existingUser) {
48 | return {
49 | success: false,
50 | error: "An account with this email already exists",
51 | };
52 | }
53 |
54 | // Generate verification code
55 | const verificationCode = crypto.randomBytes(32).toString("hex");
56 | const verificationExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
57 |
58 | // Hash password
59 | const hashedPassword = await bcrypt.hash(password, 10);
60 |
61 | // Create new user
62 | await cosmic.objects.insertOne({
63 | title: `${firstName} ${lastName}`,
64 | type: "users",
65 | metadata: {
66 | first_name: firstName,
67 | last_name: lastName,
68 | email: email,
69 | password: hashedPassword,
70 | active_status: true,
71 | email_verified: false,
72 | verification_code: verificationCode,
73 | verification_expiry: verificationExpiry,
74 | },
75 | });
76 |
77 | // Send verification email
78 | const verificationUrl = `${process.env.NEXT_PUBLIC_APP_URL}/verify?code=${verificationCode}`;
79 |
80 | try {
81 | await resend.emails.send({
82 | from: `${process.env.NEXT_PUBLIC_APP_NAME} Support <${process.env.SUPPORT_EMAIL}>`,
83 | to: email,
84 | subject: "Verify your email address",
85 | html: `
86 | Welcome to ${process.env.NEXT_PUBLIC_APP_NAME}!
87 | Please click the link below to verify your email address:
88 | Verify Email
89 | This link will expire in 24 hours.
90 | `,
91 | });
92 | console.log(`Verification email sent to ${email}`);
93 | } catch (error) {
94 | console.error("Error sending verification email:", error);
95 | return {
96 | success: false,
97 | error: "Failed to send verification email. Please try again.",
98 | };
99 | }
100 |
101 | return { success: true };
102 | } catch (error) {
103 | console.error("Signup error:", error);
104 | return {
105 | success: false,
106 | error: "Failed to create account. Please try again.",
107 | };
108 | }
109 | }
110 |
111 | export async function login(formData: FormData) {
112 | const email = (formData.get("email") as string).toLowerCase();
113 | const password = formData.get("password") as string;
114 |
115 | try {
116 | const result = await cosmic.objects
117 | .findOne({
118 | type: "users",
119 | "metadata.email": email,
120 | "metadata.email_verified": true,
121 | "metadata.active_status": true,
122 | })
123 | .props(["id", "title", "metadata"])
124 | .depth(0);
125 |
126 | if (!result.object) {
127 | return { error: "Invalid email or password" };
128 | }
129 |
130 | const isValid = await bcrypt.compare(
131 | password,
132 | result.object.metadata.password
133 | );
134 |
135 | if (!isValid) {
136 | return { error: "Invalid email or password" };
137 | }
138 |
139 | const user = {
140 | id: result.object.id,
141 | name: result.object.title,
142 | email: result.object.metadata.email,
143 | image: result.object.metadata.avatar?.imgix_url,
144 | };
145 |
146 | // Set the user_id cookie
147 | (await cookies()).set("user_id", result.object.id, {
148 | httpOnly: true,
149 | secure: process.env.NODE_ENV === "production",
150 | sameSite: "lax",
151 | path: "/",
152 | });
153 |
154 | return { user };
155 | } catch (error) {
156 | console.error("Login error:", error);
157 | return { error: "Invalid email or password" };
158 | }
159 | }
160 |
161 | export async function getUserData(userId: string) {
162 | try {
163 | const { object } = await cosmic.objects
164 | .findOne({
165 | id: userId,
166 | type: "users",
167 | })
168 | .props("id,title,metadata")
169 | .depth(0);
170 |
171 | if (!object) {
172 | return { data: null, error: "User not found" };
173 | }
174 |
175 | // Check active status after finding the user
176 | if (!object.metadata.active_status) {
177 | return { data: null, error: "Account is not active" };
178 | }
179 |
180 | return { data: object, error: null };
181 | } catch (error) {
182 | console.error("Error fetching user data:", error);
183 | return { data: null, error: "Failed to fetch user data" };
184 | }
185 | }
186 |
187 | export async function getUserFromCookie() {
188 | const cookieStore = await cookies();
189 | const userId = cookieStore.get("user_id");
190 | if (!userId) {
191 | return null;
192 | }
193 |
194 | try {
195 | const result = await cosmic.objects
196 | .findOne({
197 | type: "users",
198 | id: userId.value,
199 | "metadata.active_status": true,
200 | })
201 | .props(["id", "metadata.name", "metadata.email", "metadata.image"])
202 | .depth(0);
203 |
204 | if (!result?.object) {
205 | return null;
206 | }
207 |
208 | return {
209 | id: result.object.id,
210 | name: result.object.metadata.name,
211 | email: result.object.metadata.email,
212 | image: result.object.metadata.image,
213 | };
214 | } catch (error) {
215 | console.error("Error fetching user:", error);
216 | return null;
217 | }
218 | }
219 |
220 | async function uploadFile(file: File) {
221 | const arrayBuffer = await file.arrayBuffer();
222 | const buffer = Buffer.from(arrayBuffer);
223 | const media = { originalname: file.name, buffer };
224 | return await cosmic.media.insertOne({
225 | media,
226 | });
227 | }
228 |
229 | export async function updateUserProfile(userId: string, formData: FormData) {
230 | try {
231 | const firstName = formData.get("firstName") as string;
232 | const lastName = formData.get("lastName") as string;
233 | const email = (formData.get("email") as string).toLowerCase();
234 | const avatar = formData.get("avatar") as File;
235 |
236 | // Get current user data to check if email has changed
237 | const { object: currentUser } = await cosmic.objects
238 | .findOne({ id: userId })
239 | .props(["metadata"])
240 | .depth(0);
241 |
242 | const metadata: any = {
243 | first_name: firstName,
244 | last_name: lastName,
245 | email: email,
246 | };
247 |
248 | // If email has changed, generate new verification
249 | if (email !== currentUser.metadata.email) {
250 | // Check if new email already exists
251 | const existingUser = await cosmic.objects
252 | .findOne({
253 | type: "users",
254 | "metadata.email": email,
255 | })
256 | .props(["id"])
257 | .depth(0);
258 |
259 | if (existingUser.object) {
260 | return {
261 | success: false,
262 | error: "An account with this email already exists",
263 | };
264 | }
265 |
266 | const verificationCode = crypto.randomBytes(32).toString("hex");
267 | const verificationExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
268 |
269 | metadata.email_verified = false;
270 | metadata.verification_code = verificationCode;
271 | metadata.verification_expiry = verificationExpiry;
272 |
273 | // Send new verification email
274 | const verificationUrl = `${process.env.NEXT_PUBLIC_APP_URL}/verify?code=${verificationCode}`;
275 | await resend.emails.send({
276 | from: `${process.env.NEXT_PUBLIC_APP_NAME} Support <${process.env.SUPPORT_EMAIL}>`,
277 | to: email,
278 | subject: "Verify your new email address",
279 | html: `
280 | Verify Your New Email Address
281 | Please click the link below to verify your new email address:
282 | Verify Email
283 | This link will expire in 24 hours.
284 | `,
285 | });
286 | }
287 |
288 | const updates: {
289 | title: string;
290 | metadata: any;
291 | thumbnail?: string;
292 | } = {
293 | title: `${firstName} ${lastName}`,
294 | metadata,
295 | };
296 |
297 | // Handle avatar upload if provided
298 | if (avatar && avatar.size > 0) {
299 | const { media } = await uploadFile(avatar);
300 | metadata.avatar = media.name;
301 | updates.thumbnail = media.name;
302 | }
303 |
304 | const { object } = await cosmic.objects.updateOne(userId, updates);
305 |
306 | return { success: true, data: object };
307 | } catch (error) {
308 | console.error("Error updating profile:", error);
309 | return { success: false, error: "Failed to update profile" };
310 | }
311 | }
312 |
313 | // Add new verification function
314 | export async function verifyEmail(code: string) {
315 | try {
316 | const { object } = await cosmic.objects
317 | .findOne({
318 | type: "users",
319 | "metadata.verification_code": code,
320 | })
321 | .props(["id", "metadata"])
322 | .depth(0);
323 |
324 | if (!object) {
325 | throw new Error("Invalid verification code");
326 | }
327 |
328 | const verificationExpiry = new Date(object.metadata.verification_expiry);
329 | if (verificationExpiry < new Date()) {
330 | throw new Error("Verification code has expired");
331 | }
332 |
333 | await cosmic.objects.updateOne(object.id, {
334 | metadata: {
335 | email_verified: true,
336 | verification_code: "",
337 | verification_expiry: "",
338 | },
339 | });
340 |
341 | return { success: true };
342 | } catch (error) {
343 | console.error("Error verifying email:", error);
344 | throw new Error("Email verification failed");
345 | }
346 | }
347 |
348 | export async function forgotPassword(formData: FormData) {
349 | try {
350 | const email = (formData.get("email") as string).toLowerCase();
351 |
352 | // Check if user exists
353 | const existingUser = await cosmic.objects
354 | .findOne({
355 | type: "users",
356 | "metadata.email": email,
357 | })
358 | .props(["id", "metadata"])
359 | .depth(0);
360 |
361 | if (!existingUser.object) {
362 | return {
363 | success: false,
364 | error: "No account found with this email address",
365 | };
366 | }
367 |
368 | // Generate reset token and expiry
369 | const resetToken = crypto.randomBytes(32).toString("hex");
370 | const resetExpiry = new Date(Date.now() + 1 * 60 * 60 * 1000); // 1 hour
371 |
372 | // Update user with reset token
373 | await cosmic.objects.updateOne(existingUser.object.id, {
374 | metadata: {
375 | reset_password_token: resetToken,
376 | reset_password_expiry: resetExpiry,
377 | },
378 | });
379 |
380 | // Send reset email
381 | const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password?token=${resetToken}`;
382 |
383 | await resend.emails.send({
384 | from: `${process.env.NEXT_PUBLIC_APP_NAME} Support <${process.env.SUPPORT_EMAIL}>`,
385 | to: email,
386 | subject: "Reset your password",
387 | html: `
388 | Reset Your Password
389 | Click the link below to reset your password:
390 | Reset Password
391 | This link will expire in 1 hour.
392 | `,
393 | });
394 |
395 | return { success: true };
396 | } catch (error) {
397 | console.error("Forgot password error:", error);
398 | return {
399 | success: false,
400 | error: "Failed to process request. Please try again.",
401 | };
402 | }
403 | }
404 |
405 | export async function resetPassword(token: string, formData: FormData) {
406 | try {
407 | const password = formData.get("password") as string;
408 |
409 | // Add password validation
410 | if (!isValidPassword(password)) {
411 | return {
412 | success: false,
413 | error:
414 | "Password must be at least 8 characters long and contain both letters and numbers",
415 | };
416 | }
417 |
418 | // Find user with reset token
419 | const existingUser = await cosmic.objects
420 | .findOne({
421 | type: "users",
422 | "metadata.reset_password_token": token,
423 | })
424 | .props(["id", "metadata"])
425 | .depth(0);
426 |
427 | if (!existingUser.object) {
428 | return {
429 | success: false,
430 | error: "Invalid or expired reset token",
431 | };
432 | }
433 |
434 | const resetExpiry = new Date(
435 | existingUser.object.metadata.reset_password_expiry
436 | );
437 | if (resetExpiry < new Date()) {
438 | return {
439 | success: false,
440 | error: "Reset token has expired",
441 | };
442 | }
443 |
444 | // Hash new password
445 | const hashedPassword = await bcrypt.hash(password, 10);
446 |
447 | // Update user password and clear reset token
448 | await cosmic.objects.updateOne(existingUser.object.id, {
449 | metadata: {
450 | password: hashedPassword,
451 | reset_password_token: "",
452 | reset_password_expiry: "",
453 | },
454 | });
455 |
456 | return { success: true };
457 | } catch (error) {
458 | console.error("Reset password error:", error);
459 | return {
460 | success: false,
461 | error: "Failed to reset password. Please try again.",
462 | };
463 | }
464 | }
465 |
466 | export async function getAuthUser() {
467 | "use server";
468 | return await getUserFromCookie();
469 | }
470 |
471 | export async function logoutUser() {
472 | "use server";
473 | (await cookies()).delete("user_id");
474 | return { success: true };
475 | }
476 |
--------------------------------------------------------------------------------