Updated app
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,21 @@
|
||||
# **App Name**: EstimateFlow
|
||||
|
||||
## Core Features:
|
||||
|
||||
- Cost Input Form: Interactive form for clients to input project requirements.
|
||||
- Admin Configuration: Admin panel for configuring pricing parameters and viewing client inputs.
|
||||
- WordPress Integration: Ability to embed the cost calculator form directly into WordPress websites.
|
||||
- AI-Assisted Scope Definition: AI-powered suggestions to guide clients in defining project scope and features by acting like a 'tool' offering project management advise
|
||||
- Dynamic Cost Calculation: Real-time display of cost estimates based on input parameters.
|
||||
- Custom Calculation Formulas: Options for agencies to customize the cost calculation formula via the admin panel
|
||||
- AI Complexity Classification: Automatically classify feature complexity from project descriptions by using a 'tool' powered by LLMs, categorizing them as simple, medium, or complex to refine cost estimates
|
||||
|
||||
## Style Guidelines:
|
||||
|
||||
- Primary color: Strong blue (#2979FF) for a modern and trustworthy feel, drawing inspiration from technology and professionalism.
|
||||
- Background color: Light grayish-blue (#E0E7FF) to provide a clean, unobtrusive backdrop that complements the primary color.
|
||||
- Accent color: Purple (#794BC4), an analogous color to blue, to add a touch of sophistication and highlight key actions.
|
||||
- Font Pairing: 'Space Grotesk' (sans-serif) for headings and 'Inter' (sans-serif) for body text.
|
||||
- Clean, intuitive layout with a focus on user experience. Form elements should be logically grouped and easy to understand. Model initial layout after provided screenshot.
|
||||
- Use simple, geometric icons to represent different features and project types.
|
||||
- Subtle animations to provide feedback and guide the user through the cost estimation process. For instance, highlighting a section of the form when it becomes active, and adding transitions between each step of the process
|
||||
Generated
+43
@@ -38,6 +38,7 @@
|
||||
"dotenv": "^16.5.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"firebase": "^11.9.1",
|
||||
"framer-motion": "^11.3.12",
|
||||
"genkit": "^1.14.1",
|
||||
"lucide-react": "^0.475.0",
|
||||
"next": "15.3.3",
|
||||
@@ -6213,6 +6214,33 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "11.18.2",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
|
||||
"integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^11.18.1",
|
||||
"motion-utils": "^11.18.1",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
@@ -7537,6 +7565,21 @@
|
||||
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
|
||||
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "11.18.1",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
||||
"integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^11.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "11.18.1",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz",
|
||||
"integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"dotenv": "^16.5.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"firebase": "^11.9.1",
|
||||
"framer-motion": "^11.3.12",
|
||||
"genkit": "^1.14.1",
|
||||
"lucide-react": "^0.475.0",
|
||||
"next": "15.3.3",
|
||||
|
||||
+5
-1
@@ -1 +1,5 @@
|
||||
// Flows will be imported for their side effects in this file.
|
||||
import { config } from 'dotenv';
|
||||
config();
|
||||
|
||||
import '@/ai/flows/suggest-features.ts';
|
||||
import '@/ai/flows/classify-complexity.ts';
|
||||
@@ -0,0 +1,52 @@
|
||||
// This is an autogenerated file from Firebase Studio.
|
||||
'use server';
|
||||
|
||||
/**
|
||||
* @fileOverview This file defines a Genkit flow for classifying the complexity of project features.
|
||||
*
|
||||
* - classifyComplexity - An async function that takes a feature description and returns its complexity classification.
|
||||
* - ClassifyComplexityInput - The input type for the classifyComplexity function.
|
||||
* - ClassifyComplexityOutput - The output type for the classifyComplexity function.
|
||||
*/
|
||||
|
||||
import {ai} from '@/ai/genkit';
|
||||
import {z} from 'genkit';
|
||||
|
||||
const ClassifyComplexityInputSchema = z.object({
|
||||
featureDescription: z
|
||||
.string()
|
||||
.describe('A detailed description of the feature to be classified.'),
|
||||
});
|
||||
export type ClassifyComplexityInput = z.infer<typeof ClassifyComplexityInputSchema>;
|
||||
|
||||
const ClassifyComplexityOutputSchema = z.object({
|
||||
complexity: z
|
||||
.enum(['simple', 'medium', 'complex'])
|
||||
.describe('The complexity classification of the feature.'),
|
||||
});
|
||||
export type ClassifyComplexityOutput = z.infer<typeof ClassifyComplexityOutputSchema>;
|
||||
|
||||
export async function classifyComplexity(
|
||||
input: ClassifyComplexityInput
|
||||
): Promise<ClassifyComplexityOutput> {
|
||||
return classifyComplexityFlow(input);
|
||||
}
|
||||
|
||||
const prompt = ai.definePrompt({
|
||||
name: 'classifyComplexityPrompt',
|
||||
input: {schema: ClassifyComplexityInputSchema},
|
||||
output: {schema: ClassifyComplexityOutputSchema},
|
||||
prompt: `You are an expert project manager classifying the complexity of software features. Classify the following feature description as either 'simple', 'medium', or 'complex'.\n\nFeature Description: {{{featureDescription}}}\n\nComplexity:`,
|
||||
});
|
||||
|
||||
const classifyComplexityFlow = ai.defineFlow(
|
||||
{
|
||||
name: 'classifyComplexityFlow',
|
||||
inputSchema: ClassifyComplexityInputSchema,
|
||||
outputSchema: ClassifyComplexityOutputSchema,
|
||||
},
|
||||
async input => {
|
||||
const {output} = await prompt(input);
|
||||
return output!;
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
'use server';
|
||||
|
||||
/**
|
||||
* @fileOverview AI-powered feature suggestion flow based on project description.
|
||||
*
|
||||
* - suggestFeatures - A function that suggests features for a project based on its description.
|
||||
* - SuggestFeaturesInput - The input type for the suggestFeatures function.
|
||||
* - SuggestFeaturesOutput - The return type for the suggestFeatures function.
|
||||
*/
|
||||
|
||||
import {ai} from '@/ai/genkit';
|
||||
import {z} from 'genkit';
|
||||
|
||||
const SuggestFeaturesInputSchema = z.object({
|
||||
projectDescription: z
|
||||
.string()
|
||||
.describe('A brief description of the digital product or project.'),
|
||||
});
|
||||
export type SuggestFeaturesInput = z.infer<typeof SuggestFeaturesInputSchema>;
|
||||
|
||||
const SuggestFeaturesOutputSchema = z.object({
|
||||
suggestedFeatures: z
|
||||
.array(z.string())
|
||||
.describe('An array of suggested features for the project.'),
|
||||
scopeDefinitions: z
|
||||
.string()
|
||||
.describe('A paragraph defining the overall scope of the project.'),
|
||||
});
|
||||
export type SuggestFeaturesOutput = z.infer<typeof SuggestFeaturesOutputSchema>;
|
||||
|
||||
export async function suggestFeatures(input: SuggestFeaturesInput): Promise<SuggestFeaturesOutput> {
|
||||
return suggestFeaturesFlow(input);
|
||||
}
|
||||
|
||||
const prompt = ai.definePrompt({
|
||||
name: 'suggestFeaturesPrompt',
|
||||
input: {schema: SuggestFeaturesInputSchema},
|
||||
output: {schema: SuggestFeaturesOutputSchema},
|
||||
prompt: `You are an AI assistant helping to define the scope and features of digital projects.
|
||||
|
||||
Based on the following project description, suggest a list of potential features and a paragraph defining the project's scope.
|
||||
|
||||
Project Description: {{{projectDescription}}}
|
||||
|
||||
Format the features as a bulleted list and provide a concise scope definition.
|
||||
`,
|
||||
});
|
||||
|
||||
const suggestFeaturesFlow = ai.defineFlow(
|
||||
{
|
||||
name: 'suggestFeaturesFlow',
|
||||
inputSchema: SuggestFeaturesInputSchema,
|
||||
outputSchema: SuggestFeaturesOutputSchema,
|
||||
},
|
||||
async input => {
|
||||
const {output} = await prompt(input);
|
||||
return output!;
|
||||
}
|
||||
);
|
||||
+26
-55
@@ -2,79 +2,50 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--background: 226 100% 94.1%;
|
||||
--foreground: 222 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--card-foreground: 222 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--popover-foreground: 222 84% 4.9%;
|
||||
--primary: 221 100% 58%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--secondary: 226 40% 90%;
|
||||
--secondary-foreground: 222 84% 4.9%;
|
||||
--muted: 226 40% 90%;
|
||||
--muted-foreground: 222 20% 46.1%;
|
||||
--accent: 263 47% 53%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--border: 226 30% 85%;
|
||||
--input: 226 30% 85%;
|
||||
--ring: 221 100% 58%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--background: 222 47% 11%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card: 222 47% 11%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover: 222 47% 11%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary: 221 100% 58%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary: 222 25% 20%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--muted: 222 25% 20%;
|
||||
--muted-foreground: 222 10% 63.9%;
|
||||
--accent: 263 47% 53%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--border: 222 25% 20%;
|
||||
--input: 222 25% 20%;
|
||||
--ring: 221 100% 58%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-6
@@ -1,9 +1,10 @@
|
||||
import type {Metadata} from 'next';
|
||||
import './globals.css';
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Firebase Studio App',
|
||||
description: 'Generated by Firebase Studio',
|
||||
title: 'EstimateFlow',
|
||||
description: 'Estimate development costs for your next digital product.',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -14,11 +15,14 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet"></link>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body className="font-body antialiased">{children}</body>
|
||||
<body className="font-body antialiased">
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+15
-1
@@ -1,3 +1,17 @@
|
||||
import { CostEstimatorForm } from '@/components/cost-estimator/cost-estimator-form';
|
||||
|
||||
export default function Home() {
|
||||
return <></>;
|
||||
return (
|
||||
<main className="flex min-h-screen w-full flex-col items-center justify-center bg-background p-4 sm:p-8 md:p-12 lg:p-24">
|
||||
<div className="w-full max-w-4xl text-center">
|
||||
<h1 className="font-headline text-4xl font-bold tracking-tighter text-foreground sm:text-5xl md:text-6xl">
|
||||
EstimateFlow
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-[700px] text-muted-foreground md:text-xl">
|
||||
Answer a few questions to get a real-time estimate for your digital project. Let's build something amazing together.
|
||||
</p>
|
||||
</div>
|
||||
<CostEstimatorForm />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useTransition } from 'react';
|
||||
import { Step1ProjectType } from './step-1-project-type';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
export type FormData = {
|
||||
projectType: 'website' | 'mobile-app' | 'platform' | null;
|
||||
// Add more fields for subsequent steps
|
||||
};
|
||||
|
||||
export function CostEstimatorForm() {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
projectType: null,
|
||||
});
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleNextStep = () => {
|
||||
startTransition(() => {
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateFormData = (newData: Partial<FormData>) => {
|
||||
setFormData((prev) => ({ ...prev, ...newData }));
|
||||
};
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<Step1ProjectType
|
||||
onNext={handleNextStep}
|
||||
onUpdateData={handleUpdateFormData}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h2 className="font-headline text-3xl font-bold tracking-tight">Step 2: Define Your Project</h2>
|
||||
<p className="mt-2 text-muted-foreground">This is where AI will help you define scope.</p>
|
||||
<p className="mt-4 text-sm font-medium">You selected: <span className="text-primary">{formData.projectType}</span></p>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<Step1ProjectType
|
||||
onNext={handleNextStep}
|
||||
onUpdateData={handleUpdateFormData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="mt-8 w-full max-w-4xl shadow-2xl overflow-hidden">
|
||||
<CardContent className="p-4 sm:p-8">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -50 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{renderStep()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import type { FormData } from './cost-estimator-form';
|
||||
import { Monitor, Smartphone, Layers } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import React from 'react';
|
||||
|
||||
type Step1Props = {
|
||||
onNext: () => void;
|
||||
onUpdateData: (data: Partial<FormData>) => void;
|
||||
};
|
||||
|
||||
const projectTypes = [
|
||||
{
|
||||
id: 'website',
|
||||
title: 'Website',
|
||||
description: 'A responsive and engaging online presence.',
|
||||
icon: <Monitor className="h-8 w-8 text-primary" />,
|
||||
},
|
||||
{
|
||||
id: 'mobile-app',
|
||||
title: 'Mobile App',
|
||||
description: 'An iOS or Android application for users on the go.',
|
||||
icon: <Smartphone className="h-8 w-8 text-primary" />,
|
||||
},
|
||||
{
|
||||
id: 'platform',
|
||||
title: 'Platform',
|
||||
description: 'A complex system with multiple user types.',
|
||||
icon: <Layers className="h-8 w-8 text-primary" />,
|
||||
},
|
||||
];
|
||||
|
||||
export function Step1ProjectType({ onNext, onUpdateData }: Step1Props) {
|
||||
const handleSelect = (projectType: 'website' | 'mobile-app' | 'platform') => {
|
||||
onUpdateData({ projectType });
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<h2 className="font-headline text-3xl font-bold tracking-tight">What are you looking to build?</h2>
|
||||
<p className="mt-2 text-muted-foreground">Select a project type to get started.</p>
|
||||
<div className="mt-8 grid w-full grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{projectTypes.map((type) => (
|
||||
<Card
|
||||
key={type.id}
|
||||
onClick={() => handleSelect(type.id as any)}
|
||||
className="cursor-pointer transition-all duration-300 ease-in-out hover:shadow-accent/20 hover:shadow-lg hover:-translate-y-1 hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleSelect(type.id as any);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardHeader className="items-center">
|
||||
<div className="rounded-full bg-primary/10 p-4 mb-4">{type.icon}</div>
|
||||
<CardTitle className="font-headline text-xl">{type.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{type.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ export default {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
body: ['Inter', 'sans-serif'],
|
||||
headline: ['Inter', 'sans-serif'],
|
||||
headline: ['"Space Grotesk"', 'sans-serif'],
|
||||
code: ['monospace'],
|
||||
},
|
||||
colors: {
|
||||
|
||||
Reference in New Issue
Block a user