Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"cSpell.words": ["httpx", "nextra", "toolkits"],
"cSpell.words": ["httpx", "nextra", "posthog", "toolkits"],
"editor.wordWrap": "bounded",
"editor.wordWrapColumn": 120,
"editor.formatOnSave": false,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"homepage": "https://arcade.dev/",
"dependencies": {
"@icons-pack/react-simple-icons": "^10.2.0",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-slot": "^1.1.1",
"@uidotdev/usehooks": "^2.4.1",
"class-variance-authority": "^0.7.1",
Expand Down
396 changes: 396 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions src/components/custom/Toolkits/ComingSoonContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React, { createContext, useContext, useState } from "react";

interface ComingSoonContextType {
email: string;
// eslint-disable-next-line no-unused-vars
setEmail: (email: string) => void;
}

const ComingSoonContext = createContext<ComingSoonContextType>({
email: "",
setEmail: () => {},
});

export const ComingSoonProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [email, setEmail] = useState("");

return (
<ComingSoonContext.Provider value={{ email, setEmail }}>
{children}
</ComingSoonContext.Provider>
);
};

export const useComingSoon = () => useContext(ComingSoonContext);
139 changes: 139 additions & 0 deletions src/components/custom/Toolkits/ComingSoonModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"use client";
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { usePostHog } from "posthog-js/react";
import { useComingSoon } from "./ComingSoonContext";

interface ComingSoonModalProps {
isOpen: boolean;
onClose: () => void;
toolkitName: string;
}

export function ComingSoonModal({
isOpen,
onClose,
toolkitName,
}: ComingSoonModalProps) {
const { email, setEmail } = useComingSoon();
const [isSubmitted, setIsSubmitted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
const posthog = usePostHog();

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email?.includes("@")) {
setError("Please enter a valid email address");
return;
}

setIsSubmitting(true);
setError("");

try {
posthog?.capture("Notify me clicked", {
toolkit_name: toolkitName,
notify_email: email,
});
setIsSubmitted(true);
} catch {
setError("Failed to submit. Please try again.");
} finally {
setIsSubmitting(false);
}
};

const handleClose = () => {
setIsSubmitted(false);
setError("");
onClose();
};

return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="border-gray-800 bg-gray-900 sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="text-gray-100">
{toolkitName} is coming soon
</DialogTitle>
{!isSubmitted && (
<DialogDescription className="pt-6 text-gray-400">
This toolkit is coming to Arcade soon. Sign up to be notified when
it's available.
</DialogDescription>
)}
</DialogHeader>

{!isSubmitted ? (
<form onSubmit={handleSubmit} className="space-y-4 py-2">
<div className="space-y-2">
<Input
id="email"
placeholder="Enter your email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-gray-800/50 text-white"
required
disabled={isSubmitting}
/>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Notify me"}
</Button>
</form>
) : (
<SuccessMessage toolkitName={toolkitName} handleClose={handleClose} />
)}
</DialogContent>
</Dialog>
);
}

const SuccessMessage = ({
toolkitName,
handleClose,
}: {
toolkitName: string;
handleClose: () => void;
}) => {
return (
<div className="flex flex-col items-center justify-center pb-2 pt-6 text-center">
<div className="mb-4 rounded-full bg-green-500/10 p-3">
<svg
className="h-6 w-6 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h3 className="mb-1 text-lg font-medium text-gray-100">
Thanks for your interest!
</h3>
<p className="text-sm text-gray-400">
We'll notify you when the {toolkitName} toolkit becomes available.
</p>
<Button onClick={handleClose} className="mt-6 w-full" variant="default">
Close
</Button>
</div>
);
};
162 changes: 133 additions & 29 deletions src/components/custom/Toolkits/ToolCard.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Card,
CardContent as CardContentUI,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { BadgeCheck, CheckCircle, Key, Users } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import React from "react";
import React, { useState } from "react";
import { ComingSoonModal } from "./ComingSoonModal";

type ToolkitType = "arcade" | "verified" | "community" | "auth";

Expand All @@ -13,6 +20,7 @@ interface ToolCardProps {
summary: string;
link: string;
type: ToolkitType;
isComingSoon?: boolean;
}

const typeConfig: Record<
Expand Down Expand Up @@ -55,45 +63,141 @@ export const ToolCard: React.FC<ToolCardProps> = ({
summary,
link,
type,
isComingSoon = false,
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [imageError, setImageError] = useState(false);
const { className, label, icon: Icon, color } = typeConfig[type];

return (
<Link href={link}>
<Card
className={cn(
"flex h-full flex-col transition-all duration-300",
"border hover:shadow-lg",
"bg-gray-900/80 backdrop-blur-sm",
className,
)}
>
<CardHeader className="flex-grow space-y-0 p-4">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-5">
<div className="relative h-10 w-10 overflow-hidden rounded-lg">
const handleCardClick = (e: React.MouseEvent) => {
if (isComingSoon) {
e.preventDefault();
setIsModalOpen(true);
}
};

const handleModalClose = () => {
setIsModalOpen(false);
};

const handleImageError = () => {
setImageError(true);
};

// Generate a consistent color based on the tool name
const getColorFromName = (name: string) => {
// Predefined attractive colors (tailwind colors at 500-600 level)
const colors = [
"bg-red-500",
"bg-orange-500",
"bg-amber-500",
"bg-yellow-500",
"bg-lime-500",
"bg-green-500",
"bg-emerald-500",
"bg-teal-500",
"bg-cyan-500",
"bg-sky-500",
"bg-blue-500",
"bg-indigo-500",
"bg-violet-500",
"bg-purple-500",
"bg-fuchsia-500",
"bg-pink-500",
"bg-rose-500",
];

// Simple hash function to get a number from the name
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash << 5) - hash + name.charCodeAt(i);
hash = hash & hash; // Convert to 32bit integer
}

// Use absolute value to ensure positive index
const index = Math.abs(hash) % colors.length;
return colors[index];
};

// Get the first two letters of the name
const firstTwoChars = name.substring(0, 2).toUpperCase();

// Get color based on the name
const bgColor = getColorFromName(name);

const cardContent = (
<Card
className={cn(
"flex h-full flex-col transition-all duration-300",
"border hover:shadow-lg",
"bg-gray-900/80 backdrop-blur-sm",
className,
isComingSoon && "relative",
)}
>
<CardHeader className="flex-grow space-y-0 p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex items-center space-x-5">
<div className="relative h-10 w-10 overflow-hidden rounded-lg">
{!image || imageError ? (
<div
className={`flex h-full w-full items-center justify-center ${bgColor} font-medium text-white`}
>
{firstTwoChars}
</div>
) : (
<Image
src={`/images/icons/${image}.png`}
alt={`${name} logo`}
width={40}
height={40}
priority
className="object-cover"
onError={handleImageError}
/>
</div>
<div>
<CardTitle className="text-base text-gray-50">{name}</CardTitle>
<div className="flex items-center text-xs text-gray-400">
<Icon className={`h-3 w-3 ${color} mr-1`} />
{label}
</div>
)}
</div>
<div>
<CardTitle className="text-base text-gray-50">{name}</CardTitle>
<div className="flex items-center text-xs text-gray-400">
<Icon className={`h-3 w-3 ${color} mr-1`} />
{label}
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-2 p-4 pt-0">
<div className="text-xs leading-relaxed text-gray-300">{summary}</div>
</CardContent>
</Card>
</Link>
{isComingSoon && (
<Badge
variant="outline"
className="shrink-0 whitespace-nowrap border-gray-700 bg-gray-800/70 text-gray-300"
>
Coming Soon
</Badge>
)}
</div>
</CardHeader>
<CardContentUI className="space-y-2 p-4 pt-0">
<div className="text-xs leading-relaxed text-gray-300">{summary}</div>
</CardContentUI>
</Card>
);

return (
<>
{isComingSoon ? (
<div onClick={handleCardClick} className="cursor-pointer">
{cardContent}
</div>
) : (
<Link href={link}>{cardContent}</Link>
)}

{isComingSoon && (
<ComingSoonModal
isOpen={isModalOpen}
onClose={handleModalClose}
toolkitName={name}
/>
)}
</>
);
};
Loading