From 219bf9680422f4f184b97f5c1ad2ec73545bd110 Mon Sep 17 00:00:00 2001 From: Mario Reder Date: Wed, 7 Jan 2026 09:52:48 +0100 Subject: [PATCH 1/5] wip --- README.md | 23 + api/.env.example | 3 + api/package.json | 5 +- .../migration.sql | 26 + api/prisma/schema.prisma | 21 + api/src/index.ts | 3 + api/src/lib/github.ts | 176 ++++ api/src/lib/landingPageIdentifier.ts | 11 + api/src/lib/mastra.ts | 22 + api/src/lib/types.ts | 27 + api/src/mastra/agents/landingPageAgent.ts | 9 + api/src/models/landingPage.ts | 498 ++++++++++ api/src/routes/landingPage.ts | 417 ++++++++ app/app/context/LandingPageContext.tsx | 103 ++ app/app/routes/_layout.dex_.page.tsx | 276 ++++++ app/app/routes/_layout.dex_.page_.config.tsx | 434 +++++++++ app/app/routes/_layout.tsx | 13 +- app/app/types/landingPage.ts | 38 + yarn.lock | 906 +++++++++++++++++- 19 files changed, 2973 insertions(+), 38 deletions(-) create mode 100644 api/prisma/migrations/20260107082918_add_landing_page/migration.sql create mode 100644 api/src/lib/landingPageIdentifier.ts create mode 100644 api/src/lib/mastra.ts create mode 100644 api/src/mastra/agents/landingPageAgent.ts create mode 100644 api/src/models/landingPage.ts create mode 100644 api/src/routes/landingPage.ts create mode 100644 app/app/context/LandingPageContext.tsx create mode 100644 app/app/routes/_layout.dex_.page.tsx create mode 100644 app/app/routes/_layout.dex_.page_.config.tsx create mode 100644 app/app/types/landingPage.ts diff --git a/README.md b/README.md index 6326794d..eb60906f 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,28 @@ yarn api orderly:generate yarn api sv:generate ``` +#### Mastra PostgreSQL Database (For AI Memory/Storage) + +Mastra requires a separate PostgreSQL database for storing agent memory, conversation history, and workflow state. This keeps AI-related data isolated from your main application database. + +```bash +# Start a PostgreSQL container for Mastra +docker run --name dex-creator-agents \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_DB=mastra \ + -p 54321:5432 \ + -d postgres:16 +``` + +Add the Mastra database connection string to your `api/.env` file: + +```bash +MASTRA_DATABASE_URL=postgresql://postgres:postgres@localhost:54321/mastra?schema=mastra +``` + +Mastra will automatically create the necessary tables in the `mastra` schema when first used. + #### Orderly MySQL Database (For Graduation Testing) For testing the DEX graduation system with Orderly database integration: @@ -211,6 +233,7 @@ The application requires several environment variables for proper operation. Bel | `PORT` | API server port | 3001 | No | | `NODE_ENV` | Environment (development/production) | development | No | | `DATABASE_URL` | PostgreSQL connection string | - | Yes | +| `MASTRA_DATABASE_URL` | PostgreSQL connection string for Mastra AI memory/storage | - | Yes | #### GitHub Integration diff --git a/api/.env.example b/api/.env.example index baeec032..e47abdc7 100644 --- a/api/.env.example +++ b/api/.env.example @@ -38,3 +38,6 @@ GRADUATION_USDC_AMOUNT=1000 GRADUATION_ORDER_REQUIRED_PRICE=750 GRADUATION_ORDER_MINIMUM_PRICE=725 ORDER_RECEIVER_ADDRESS=0x + +# Mastra AI agent database and configuration +MASTRA_DATABASE_URL="postgresql://postgres:postgres@localhost:54321/mastra?schema=mastra" diff --git a/api/package.json b/api/package.json index 02a3a841..33554816 100644 --- a/api/package.json +++ b/api/package.json @@ -32,6 +32,9 @@ "@google-cloud/secret-manager": "^6.1.0", "@hono/node-server": "^1.8.1", "@hono/zod-validator": "^0.4.3", + "@mastra/core": "beta", + "@mastra/hono": "beta", + "@mastra/pg": "beta", "@noble/ed25519": "^2.0.0", "@octokit/core": "^6.1.5", "@octokit/plugin-rest-endpoint-methods": "^14.0.0", @@ -68,4 +71,4 @@ "node": ">=22.0.0" }, "sideEffects": false -} +} \ No newline at end of file diff --git a/api/prisma/migrations/20260107082918_add_landing_page/migration.sql b/api/prisma/migrations/20260107082918_add_landing_page/migration.sql new file mode 100644 index 00000000..37fd57b6 --- /dev/null +++ b/api/prisma/migrations/20260107082918_add_landing_page/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE "LandingPage" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "repoIdentifier" TEXT NOT NULL, + "repoUrl" TEXT, + "customDomain" TEXT, + "htmlContent" TEXT, + "config" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LandingPage_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "LandingPage_userId_key" ON "LandingPage"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "LandingPage_repoIdentifier_key" ON "LandingPage"("repoIdentifier"); + +-- CreateIndex +CREATE INDEX "LandingPage_userId_idx" ON "LandingPage"("userId"); + +-- AddForeignKey +ALTER TABLE "LandingPage" ADD CONSTRAINT "LandingPage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 171c166f..8f2e139e 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -29,6 +29,9 @@ model User { // User can have only one DEX dex Dex? + // User can have only one LandingPage + landingPage LandingPage? + @@index([address]) } @@ -137,3 +140,21 @@ model BrokerIndex { @@index([brokerIndex]) @@index([brokerId]) } + +// LandingPage model for storing user landing page configurations +model LandingPage { + id String @id @default(uuid()) + userId String @unique + repoIdentifier String @unique + repoUrl String? + customDomain String? + htmlContent String? + config Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relation to User + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) +} \ No newline at end of file diff --git a/api/src/index.ts b/api/src/index.ts index 7eb4acdf..36eff6ca 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -7,6 +7,7 @@ import authRoutes from "./routes/auth"; import adminRoutes from "./routes/admin"; import themeRoutes from "./routes/theme"; import graduationRoutes from "./routes/graduation"; +import landingPageRoutes from "./routes/landingPage"; import { leaderboard } from "./routes/leaderboard"; import { stats } from "./routes/stats"; import { leaderboardService } from "./services/leaderboardService"; @@ -39,6 +40,7 @@ app.use("*", errorLoggerMiddleware); app.use("/api/dex/*", authMiddleware); app.use("/api/theme/*", authMiddleware); app.use("/api/graduation/*", authMiddleware); +app.use("/api/landing-page/*", authMiddleware); app.use("/api/admin/*", async (c, next) => { if (c.req.path === "/api/admin/check") { @@ -54,6 +56,7 @@ app.route("/api/auth", authRoutes); app.route("/api/admin", adminRoutes); app.route("/api/theme", themeRoutes); app.route("/api/graduation", graduationRoutes); +app.route("/api/landing-page", landingPageRoutes); app.route("/api/leaderboard", leaderboard); app.route("/api/stats", stats); diff --git a/api/src/lib/github.ts b/api/src/lib/github.ts index b5f7a23e..15f2dd31 100644 --- a/api/src/lib/github.ts +++ b/api/src/lib/github.ts @@ -405,6 +405,182 @@ export async function forkTemplateRepository( } } +/** + * Creates an empty repository for a landing page + * @param repoIdentifier The identifier for the new repository + * @returns The URL of the created repository + */ +export async function createLandingPageRepository( + repoIdentifier: string +): Promise> { + try { + if (!repoIdentifier || repoIdentifier.trim() === "") { + return { + success: false, + error: { + type: GitHubErrorType.REPOSITORY_NAME_EMPTY, + message: "Repository identifier cannot be empty", + }, + }; + } + + if (!/^[a-z0-9-]+$/i.test(repoIdentifier)) { + return { + success: false, + error: { + type: GitHubErrorType.REPOSITORY_NAME_INVALID, + message: + "Repository identifier can only contain alphanumeric characters and hyphens", + }, + }; + } + + if (repoIdentifier.length > 100) { + return { + success: false, + error: { + type: GitHubErrorType.REPOSITORY_NAME_TOO_LONG, + message: + "Repository identifier exceeds GitHub's maximum length of 100 characters", + }, + }; + } + + const repoName = `landing-page-${repoIdentifier}`; + const orgName = "OrderlyNetworkDexCreator"; + + console.log(`Creating landing page repository ${orgName}/${repoName}...`); + + const octokit = await getOctokit(); + const response = await octokit.rest.repos.createInOrg({ + org: orgName, + name: repoName, + private: false, + auto_init: false, + }); + + const repoUrl = response.data.html_url; + console.log(`Successfully created repository: ${repoUrl}`); + + await enableRepositoryActions(orgName, repoName); + + const deploymentToken = await getSecret("templatePat"); + try { + await addSecretToRepository( + orgName, + repoName, + "TEMPLATE_PAT", + deploymentToken + ); + console.log(`Added TEMPLATE_PAT secret to ${orgName}/${repoName}`); + } catch (secretError) { + console.error( + "Error adding GitHub Pages deployment token secret:", + secretError + ); + } + + try { + await enableGitHubPages(orgName, repoName); + console.log(`Enabled GitHub Pages for ${orgName}/${repoName}`); + } catch (pagesError) { + console.error("Error enabling GitHub Pages:", pagesError); + } + + return { + success: true, + data: repoUrl, + }; + } catch (error: unknown) { + console.error("Error creating landing page repository:", error); + return { + success: false, + error: handleGitHubError(error, "create landing page repository"), + }; + } +} + +/** + * Setup landing page repository with HTML content and GitHub Pages workflow + */ +export async function setupLandingPageRepository( + owner: string, + repo: string, + htmlContent: string, + customDomain: string | null +): Promise { + console.log(`Setting up landing page repository ${owner}/${repo}...`); + + try { + const landingPageWorkflow = `name: Deploy Landing Page to GitHub Pages + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + deploy: + environment: + name: github-pages + url: \${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: \${{ secrets.TEMPLATE_PAT }} + + - name: Setup Pages + uses: actions/configure-pages@v4 + with: + token: \${{ secrets.TEMPLATE_PAT }} + enablement: true + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "." + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4`; + + const fileContents = new Map(); + fileContents.set("index.html", htmlContent); + fileContents.set(".github/workflows/deploy.yml", landingPageWorkflow); + + if (customDomain) { + fileContents.set("CNAME", customDomain); + } + + await createSingleCommit( + owner, + repo, + fileContents, + new Map(), + [], + "Setup landing page with HTML content" + ); + } catch (error) { + console.error( + `Error setting up landing page repository ${owner}/${repo}:`, + error + ); + throw error; + } +} + /** * Enables GitHub Actions on a repository * This is necessary because GitHub disables Actions by default on forked repositories diff --git a/api/src/lib/landingPageIdentifier.ts b/api/src/lib/landingPageIdentifier.ts new file mode 100644 index 00000000..80c2d31c --- /dev/null +++ b/api/src/lib/landingPageIdentifier.ts @@ -0,0 +1,11 @@ +export function generateLandingPageIdentifier(): string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789-"; + const length = 14; + let result = ""; + + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + return result; +} diff --git a/api/src/lib/mastra.ts b/api/src/lib/mastra.ts new file mode 100644 index 00000000..bf436795 --- /dev/null +++ b/api/src/lib/mastra.ts @@ -0,0 +1,22 @@ +import { Mastra } from "@mastra/core"; +import { PostgresStore } from "@mastra/pg"; +import { landingPageAgent } from "../mastra/agents/landingPageAgent"; + +if (!process.env.MASTRA_DATABASE_URL) { + throw new Error("MASTRA_DATABASE_URL environment variable is required"); +} + +const postgresStore = new PostgresStore({ + id: "mastra-storage", + connectionString: process.env.MASTRA_DATABASE_URL, + schemaName: "mastra", +}); + +const mastra = new Mastra({ + storage: postgresStore, + agents: { + landingPageAgent, + }, +}); + +export { mastra }; diff --git a/api/src/lib/types.ts b/api/src/lib/types.ts index c33c867c..67ba4af0 100644 --- a/api/src/lib/types.ts +++ b/api/src/lib/types.ts @@ -42,6 +42,19 @@ export enum DexErrorType { VALIDATION_ERROR = "VALIDATION_ERROR", } +/** + * Error types for Landing Page operations + */ +export enum LandingPageErrorType { + USER_ALREADY_HAS_LANDING_PAGE = "USER_ALREADY_HAS_LANDING_PAGE", + USER_NOT_FOUND = "USER_NOT_FOUND", + LANDING_PAGE_NOT_FOUND = "LANDING_PAGE_NOT_FOUND", + USER_NOT_AUTHORIZED = "USER_NOT_AUTHORIZED", + REPOSITORY_CREATION_FAILED = "REPOSITORY_CREATION_FAILED", + DATABASE_ERROR = "DATABASE_ERROR", + VALIDATION_ERROR = "VALIDATION_ERROR", +} + /** * Structured error for GitHub operations */ @@ -60,6 +73,15 @@ export interface DexError { details?: unknown; } +/** + * Structured error for Landing Page operations + */ +export interface LandingPageError { + type: LandingPageErrorType; + message: string; + details?: unknown; +} + /** * Result type specifically for GitHub operations */ @@ -70,6 +92,11 @@ export type GitHubResult = Result; */ export type DexResult = Result; +/** + * Result type specifically for Landing Page operations + */ +export type LandingPageResult = Result; + /** * DEX configuration shared across all GitHub operations */ diff --git a/api/src/mastra/agents/landingPageAgent.ts b/api/src/mastra/agents/landingPageAgent.ts new file mode 100644 index 00000000..7f076b25 --- /dev/null +++ b/api/src/mastra/agents/landingPageAgent.ts @@ -0,0 +1,9 @@ +import { Agent } from "@mastra/core/agent"; + +export const landingPageAgent = new Agent({ + id: "landing-page-agent", + name: "Landing Page Agent", + instructions: + "You are a helpful assistant for landing page creation and optimization. Help users create compelling landing pages with effective copy, design suggestions, and conversion optimization strategies.", + model: "qwen-3-32b", +}); \ No newline at end of file diff --git a/api/src/models/landingPage.ts b/api/src/models/landingPage.ts new file mode 100644 index 00000000..e0a8167d --- /dev/null +++ b/api/src/models/landingPage.ts @@ -0,0 +1,498 @@ +import { getPrisma } from "../lib/prisma"; +import type { Prisma, LandingPage } from "@prisma/client"; +import type { LandingPageResult } from "../lib/types"; +import { LandingPageErrorType, GitHubErrorType } from "../lib/types"; +import { + createLandingPageRepository, + setupLandingPageRepository, + deleteRepository, + setCustomDomain, + removeCustomDomain, +} from "../lib/github"; +import { generateLandingPageIdentifier } from "../lib/landingPageIdentifier"; + +function extractRepoInfoFromUrl( + repoUrl: string +): { owner: string; repo: string } | null { + if (!repoUrl) return null; + + try { + const repoPath = repoUrl.split("github.com/")[1]; + if (!repoPath) return null; + + const [owner, repo] = repoPath.split("/"); + if (!owner || !repo) return null; + + return { owner, repo }; + } catch (error) { + console.error("Error extracting repo info from URL:", error); + return null; + } +} + +export async function getUserLandingPage( + userId: string +): Promise { + const prismaClient = await getPrisma(); + return prismaClient.landingPage.findUnique({ + where: { + userId, + }, + }); +} + +export async function getLandingPageById( + id: string +): Promise { + const prismaClient = await getPrisma(); + return prismaClient.landingPage.findUnique({ + where: { + id, + }, + }); +} + +export async function createLandingPage( + userId: string, + config: Prisma.InputJsonValue +): Promise> { + const existingLandingPage = await getUserLandingPage(userId); + + if (existingLandingPage) { + return { + success: false, + error: { + type: LandingPageErrorType.USER_ALREADY_HAS_LANDING_PAGE, + message: + "User already has a landing page. Only one landing page per user is allowed.", + }, + }; + } + + const prismaClient = await getPrisma(); + const user = await prismaClient.user.findUnique({ + where: { id: userId }, + select: { address: true }, + }); + + if (!user) { + return { + success: false, + error: { + type: LandingPageErrorType.USER_NOT_FOUND, + message: "User not found", + }, + }; + } + + const identifier = generateLandingPageIdentifier(); + + let repoUrl: string | null = null; + + try { + console.log( + "Creating landing page repository in OrderlyNetworkDexCreator organization..." + ); + const repoResult = await createLandingPageRepository(identifier); + if (!repoResult.success) { + switch (repoResult.error.type) { + case GitHubErrorType.REPOSITORY_NAME_EMPTY: + case GitHubErrorType.REPOSITORY_NAME_INVALID: + case GitHubErrorType.REPOSITORY_NAME_TOO_LONG: + return { + success: false, + error: { + type: LandingPageErrorType.VALIDATION_ERROR, + message: repoResult.error.message, + }, + }; + default: + return { + success: false, + error: { + type: LandingPageErrorType.REPOSITORY_CREATION_FAILED, + message: repoResult.error.message, + }, + }; + } + } + + repoUrl = repoResult.data; + console.log(`Successfully forked landing page repository: ${repoUrl}`); + + const repoInfo = extractRepoInfoFromUrl(repoUrl); + if (!repoInfo) { + return { + success: false, + error: { + type: LandingPageErrorType.REPOSITORY_CREATION_FAILED, + message: `Failed to extract repository information from URL: ${repoUrl}`, + }, + }; + } + + await setupLandingPageRepository( + repoInfo.owner, + repoInfo.repo, + "Landing Page

Landing Page

", + null + ); + console.log( + `Successfully set up landing page repository for ${identifier}` + ); + } catch (error) { + console.error("Error setting up landing page repository:", error); + return { + success: false, + error: { + type: LandingPageErrorType.REPOSITORY_CREATION_FAILED, + message: `Repository setup failed: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + }; + } + + try { + const prismaClient = await getPrisma(); + const landingPage = await prismaClient.landingPage.create({ + data: { + userId, + repoIdentifier: identifier, + config, + repoUrl: repoUrl, + }, + }); + + return { + success: true, + data: landingPage, + }; + } catch (dbError) { + console.error("Error creating landing page in database:", dbError); + + try { + const repoInfo = extractRepoInfoFromUrl(repoUrl); + if (repoInfo) { + await deleteRepository(repoInfo.owner, repoInfo.repo); + console.log( + `Cleaned up landing page repository after database creation failure` + ); + } + } catch (cleanupError) { + console.error( + "Failed to clean up landing page repository:", + cleanupError + ); + } + + return { + success: false, + error: { + type: LandingPageErrorType.DATABASE_ERROR, + message: `Failed to create landing page in database: ${ + dbError instanceof Error ? dbError.message : String(dbError) + }`, + }, + }; + } +} + +export async function updateLandingPage( + id: string, + userId: string, + data: { + htmlContent?: string; + config?: any; + repoUrl?: string; + customDomain?: string; + } +): Promise> { + const landingPage = await getLandingPageById(id); + + if (!landingPage) { + return { + success: false, + error: { + type: LandingPageErrorType.LANDING_PAGE_NOT_FOUND, + message: "Landing page not found", + }, + }; + } + + if (landingPage.userId !== userId) { + return { + success: false, + error: { + type: LandingPageErrorType.USER_NOT_AUTHORIZED, + message: "User is not authorized to update this landing page", + }, + }; + } + + const updateData: Prisma.LandingPageUpdateInput = {}; + + if ("htmlContent" in data && data.htmlContent !== undefined) { + updateData.htmlContent = data.htmlContent; + } + if ("config" in data && data.config !== undefined) { + updateData.config = data.config; + } + if ("repoUrl" in data && data.repoUrl !== undefined) { + updateData.repoUrl = data.repoUrl; + } + if ("customDomain" in data && data.customDomain !== undefined) { + updateData.customDomain = data.customDomain; + } + + try { + const prismaClient = await getPrisma(); + const updatedLandingPage = await prismaClient.landingPage.update({ + where: { + id, + }, + data: updateData, + }); + + return { + success: true, + data: updatedLandingPage, + }; + } catch (error) { + return { + success: false, + error: { + type: LandingPageErrorType.DATABASE_ERROR, + message: `Failed to update landing page: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + }; + } +} + +export async function deleteLandingPage( + id: string, + userId: string +): Promise> { + const landingPage = await getLandingPageById(id); + + if (!landingPage) { + return { + success: false, + error: { + type: LandingPageErrorType.LANDING_PAGE_NOT_FOUND, + message: "Landing page not found", + }, + }; + } + + if (landingPage.userId !== userId) { + return { + success: false, + error: { + type: LandingPageErrorType.USER_NOT_AUTHORIZED, + message: "User is not authorized to delete this landing page", + }, + }; + } + + if (landingPage.repoUrl) { + try { + const repoInfo = extractRepoInfoFromUrl(landingPage.repoUrl); + if (repoInfo) { + await deleteRepository(repoInfo.owner, repoInfo.repo); + } + } catch (error) { + console.error("Error deleting landing page GitHub repository:", error); + } + } + + try { + const prismaClient = await getPrisma(); + const deletedLandingPage = await prismaClient.landingPage.delete({ + where: { + id, + }, + }); + + return { + success: true, + data: deletedLandingPage, + }; + } catch (error) { + return { + success: false, + error: { + type: LandingPageErrorType.DATABASE_ERROR, + message: `Failed to delete landing page: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + }; + } +} + +export async function updateLandingPageCustomDomain( + id: string, + domain: string, + userId: string +): Promise> { + const prismaClient = await getPrisma(); + const landingPage = await prismaClient.landingPage.findUnique({ + where: { id }, + }); + + if (!landingPage) { + return { + success: false, + error: { + type: LandingPageErrorType.LANDING_PAGE_NOT_FOUND, + message: "Landing page not found", + }, + }; + } + + if (landingPage.userId !== userId) { + return { + success: false, + error: { + type: LandingPageErrorType.USER_NOT_AUTHORIZED, + message: "You are not authorized to update this landing page", + }, + }; + } + + if (!landingPage.repoUrl) { + return { + success: false, + error: { + type: LandingPageErrorType.REPOSITORY_CREATION_FAILED, + message: "This landing page doesn't have a repository configured", + }, + }; + } + + const repoInfo = extractRepoInfoFromUrl(landingPage.repoUrl); + if (!repoInfo) { + return { + success: false, + error: { + type: LandingPageErrorType.REPOSITORY_CREATION_FAILED, + message: "Invalid repository URL format", + }, + }; + } + + try { + await setCustomDomain(repoInfo.owner, repoInfo.repo, domain); + } catch (error) { + return { + success: false, + error: { + type: LandingPageErrorType.REPOSITORY_CREATION_FAILED, + message: `Failed to configure domain with GitHub Pages: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + }; + } + + const updatedLandingPage = await prismaClient.landingPage.update({ + where: { id }, + data: { + customDomain: domain, + updatedAt: new Date(), + }, + }); + + return { success: true, data: updatedLandingPage }; +} + +export async function removeLandingPageCustomDomain( + id: string, + userId: string +): Promise> { + const prismaClient = await getPrisma(); + const landingPage = await prismaClient.landingPage.findUnique({ + where: { id }, + }); + + if (!landingPage) { + return { + success: false, + error: { + type: LandingPageErrorType.LANDING_PAGE_NOT_FOUND, + message: "Landing page not found", + }, + }; + } + + if (landingPage.userId !== userId) { + return { + success: false, + error: { + type: LandingPageErrorType.USER_NOT_AUTHORIZED, + message: "User is not authorized to update this landing page", + }, + }; + } + + if (!landingPage.customDomain) { + return { + success: false, + error: { + type: LandingPageErrorType.VALIDATION_ERROR, + message: "This landing page doesn't have a custom domain configured", + }, + }; + } + + if (!landingPage.repoUrl) { + return { + success: false, + error: { + type: LandingPageErrorType.REPOSITORY_CREATION_FAILED, + message: "This landing page doesn't have a repository URL", + }, + }; + } + + const repoInfo = extractRepoInfoFromUrl(landingPage.repoUrl); + if (!repoInfo) { + return { + success: false, + error: { + type: LandingPageErrorType.REPOSITORY_CREATION_FAILED, + message: "Invalid repository URL", + }, + }; + } + + try { + await removeCustomDomain(repoInfo.owner, repoInfo.repo); + console.log( + `Successfully removed custom domain for ${repoInfo.owner}/${repoInfo.repo}` + ); + } catch (error) { + return { + success: false, + error: { + type: LandingPageErrorType.REPOSITORY_CREATION_FAILED, + message: `Failed to remove custom domain in GitHub: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + }; + } + + const updatedLandingPage = await prismaClient.landingPage.update({ + where: { id }, + data: { + customDomain: null, + updatedAt: new Date(), + }, + }); + + return { success: true, data: updatedLandingPage }; +} diff --git a/api/src/routes/landingPage.ts b/api/src/routes/landingPage.ts new file mode 100644 index 00000000..e43a0cbe --- /dev/null +++ b/api/src/routes/landingPage.ts @@ -0,0 +1,417 @@ +import { Hono } from "hono"; +import { zValidator } from "@hono/zod-validator"; +import { z } from "zod"; +import { getPrisma } from "../lib/prisma"; +import { + getUserLandingPage, + getLandingPageById, + createLandingPage, + updateLandingPage, + deleteLandingPage, + updateLandingPageCustomDomain, + removeLandingPageCustomDomain, +} from "../models/landingPage"; +import { LandingPageErrorType } from "../lib/types"; +import { landingPageAgent } from "../mastra/agents/landingPageAgent"; + +const landingPageRoutes = new Hono(); + +const landingPageConfigSchema = z.object({ + title: z + .string() + .min(1, "Title is required") + .max(200, "Title cannot exceed 200 characters"), + subtitle: z + .string() + .max(500, "Subtitle cannot exceed 500 characters") + .optional(), + theme: z.enum(["light", "dark"]).default("light"), + primaryColor: z + .string() + .regex(/^#[0-9A-Fa-f]{6}$/, "Invalid hex color format") + .default("#000000"), + secondaryColor: z + .string() + .regex(/^#[0-9A-Fa-f]{6}$/, "Invalid hex color format") + .default("#ffffff"), + fontFamily: z + .string() + .max(100, "Font family cannot exceed 100 characters") + .default("sans-serif"), + sections: z + .array( + z.object({ + type: z.enum(["hero", "features", "about", "contact", "custom"]), + content: z.record(z.any()), + order: z.number().min(0), + }) + ) + .default([]), + metadata: z + .object({ + description: z + .string() + .max(300, "Description cannot exceed 300 characters") + .optional(), + keywords: z.array(z.string()).default([]), + favicon: z.string().url().optional(), + }) + .optional(), +}); + +const generatePromptSchema = z.object({ + prompt: z + .string() + .min(10, "Prompt must be at least 10 characters") + .max(2000, "Prompt cannot exceed 2000 characters"), +}); + +const customDomainSchema = z.object({ + domain: z + .string() + .min(1, "Domain is required") + .max(253, "Domain cannot exceed 253 characters") + .transform(val => val.trim().toLowerCase()) + .refine(domain => { + return domain.length > 0; + }, "Domain cannot be empty") + .refine(domain => { + return ( + !domain.includes("..") && + !domain.startsWith(".") && + !domain.endsWith(".") + ); + }, "Domain cannot have consecutive dots or start/end with a dot") + .refine(domain => { + const domainRegex = + /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*\.[a-z]{2,}$/; + return domainRegex.test(domain); + }, "Invalid domain format. Use a valid domain like 'example.com' or 'subdomain.example.com'") + .refine(domain => { + const ipRegex = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; + return !ipRegex.test(domain); + }, "IP addresses are not allowed. Please use a domain name") + .refine(domain => { + return domain.includes("."); + }, "Domain must include a top-level domain (e.g., '.com', '.org')") + .refine(domain => { + const labels = domain.split("."); + return labels.every(label => label.length <= 63 && label.length > 0); + }, "Each part of the domain must be 1-63 characters long") + .refine(domain => { + const tld = domain.split(".").pop(); + return tld && tld.length >= 2 && /^[a-z]+$/.test(tld); + }, "Domain must have a valid top-level domain (e.g., '.com', '.org')") + .refine(domain => { + const labels = domain.split("."); + return labels.every( + label => !label.startsWith("-") && !label.endsWith("-") + ); + }, "Domain labels cannot start or end with hyphens"), +}); + +const updateLandingPageSchema = z.object({ + htmlContent: z.string().optional(), + config: landingPageConfigSchema.optional(), +}); + +// Get the current user's landing page +landingPageRoutes.get("/", async c => { + try { + const userId = c.get("userId"); + const landingPage = await getUserLandingPage(userId); + + if (!landingPage) { + return c.json({ exists: false }, { status: 200 }); + } + + return c.json(landingPage, { status: 200 }); + } catch (error) { + console.error("Error getting landing page:", error); + return c.json( + { error: "Failed to get landing page information" }, + { status: 500 } + ); + } +}); + +// Get a specific landing page by ID +landingPageRoutes.get("/:id", async c => { + const id = c.req.param("id"); + const userId = c.get("userId"); + + try { + const landingPage = await getLandingPageById(id); + + if (!landingPage) { + return c.json({ message: "Landing page not found" }, 404); + } + + if (landingPage.userId !== userId) { + return c.json( + { message: "Unauthorized to access this landing page" }, + 403 + ); + } + + return c.json(landingPage); + } catch (error) { + console.error("Error fetching landing page:", error); + return c.json( + { message: "Error fetching landing page", error: String(error) }, + 500 + ); + } +}); + +// Create a new landing page +landingPageRoutes.post( + "/", + zValidator("json", landingPageConfigSchema), + async c => { + try { + const userId = c.get("userId"); + const config = c.req.valid("json"); + + const result = await createLandingPage(userId, config); + + if (!result.success) { + switch (result.error.type) { + case LandingPageErrorType.USER_ALREADY_HAS_LANDING_PAGE: + return c.json({ error: result.error.message }, { status: 409 }); + case LandingPageErrorType.USER_NOT_FOUND: + return c.json({ error: result.error.message }, { status: 404 }); + case LandingPageErrorType.REPOSITORY_CREATION_FAILED: + case LandingPageErrorType.DATABASE_ERROR: + return c.json({ error: result.error.message }, { status: 500 }); + default: + return c.json({ error: result.error.message }, { status: 500 }); + } + } + + return c.json(result.data, { status: 201 }); + } catch (error) { + console.error("Error creating landing page:", error); + return c.json({ error: "Internal server error" }, { status: 500 }); + } + } +); + +// Update an existing landing page +landingPageRoutes.put( + "/:id", + zValidator("json", updateLandingPageSchema), + async c => { + const id = c.req.param("id"); + const userId = c.get("userId"); + const updateData = c.req.valid("json"); + + try { + const result = await updateLandingPage(id, userId, updateData); + + if (!result.success) { + switch (result.error.type) { + case LandingPageErrorType.LANDING_PAGE_NOT_FOUND: + return c.json({ message: result.error.message }, { status: 404 }); + case LandingPageErrorType.USER_NOT_AUTHORIZED: + return c.json({ message: result.error.message }, { status: 403 }); + case LandingPageErrorType.DATABASE_ERROR: + return c.json({ error: result.error.message }, { status: 500 }); + default: + return c.json({ error: result.error.message }, { status: 500 }); + } + } + + return c.json(result.data); + } catch (error) { + console.error("Error updating landing page:", error); + return c.json( + { message: "Error updating landing page", error: String(error) }, + 500 + ); + } + } +); + +// Delete a landing page +landingPageRoutes.delete("/:id", async c => { + const id = c.req.param("id"); + const userId = c.get("userId"); + + try { + const result = await deleteLandingPage(id, userId); + + if (!result.success) { + switch (result.error.type) { + case LandingPageErrorType.LANDING_PAGE_NOT_FOUND: + return c.json({ message: result.error.message }, { status: 404 }); + case LandingPageErrorType.USER_NOT_AUTHORIZED: + return c.json({ message: result.error.message }, { status: 403 }); + case LandingPageErrorType.DATABASE_ERROR: + return c.json( + { + message: "Error deleting landing page", + error: result.error.message, + }, + { status: 500 } + ); + default: + return c.json( + { + message: "Error deleting landing page", + error: result.error.message, + }, + { status: 500 } + ); + } + } + + return c.json({ + message: "Landing page deleted successfully", + landingPage: result.data, + }); + } catch (error) { + console.error("Error deleting landing page:", error); + return c.json( + { message: "Internal server error", error: String(error) }, + 500 + ); + } +}); + +// Generate landing page HTML using Mastra agent +landingPageRoutes.post( + "/:id/generate", + zValidator("json", generatePromptSchema), + async c => { + const id = c.req.param("id"); + const userId = c.get("userId"); + const { prompt } = c.req.valid("json"); + + try { + const prisma = await getPrisma(); + + const existingPage = await prisma.landingPage.findUnique({ + where: { id }, + }); + + if (!existingPage) { + return c.json({ message: "Landing page not found" }, 404); + } + + if (existingPage.userId !== userId) { + return c.json( + { message: "Unauthorized to generate content for this landing page" }, + 403 + ); + } + + const config = existingPage.config as Record | null; + const configPrompt = config + ? `Based on this configuration: ${JSON.stringify(config)}. ` + : ""; + const fullPrompt = `${configPrompt}${prompt}`; + + const threadId = `landing-page-${id}-${userId}`; + const response = await landingPageAgent.generate({ + messages: [ + { + role: "user", + content: fullPrompt, + }, + ], + threadId, + }); + + const generatedHtml = response.text; + + const updateResult = await updateLandingPage(id, userId, { + htmlContent: generatedHtml, + }); + + if (!updateResult.success) { + return c.json({ message: updateResult.error.message }, { status: 500 }); + } + + return c.json({ + message: "Landing page generated successfully", + landingPage: updateResult.data, + }); + } catch (error) { + console.error("Error generating landing page:", error); + return c.json( + { message: "Error generating landing page", error: String(error) }, + 500 + ); + } + } +); + +// Set a custom domain for a landing page +landingPageRoutes.post( + "/:id/custom-domain", + zValidator("json", customDomainSchema), + async c => { + const id = c.req.param("id"); + const userId = c.get("userId"); + const { domain } = c.req.valid("json"); + + try { + const result = await updateLandingPageCustomDomain(id, domain, userId); + + if (!result.success) { + return c.json({ message: result.error.message }, { status: 400 }); + } + + return c.json( + { + message: "Custom domain set successfully", + landingPage: result.data, + }, + { status: 200 } + ); + } catch (error) { + console.error("Error setting custom domain:", error); + return c.json( + { message: "Error setting custom domain", error: String(error) }, + 500 + ); + } + } +); + +// Remove custom domain from a landing page +landingPageRoutes.delete("/:id/custom-domain", async c => { + const id = c.req.param("id"); + const userId = c.get("userId"); + + try { + const result = await removeLandingPageCustomDomain(id, userId); + + if (!result.success) { + if (result.error.type === LandingPageErrorType.LANDING_PAGE_NOT_FOUND) { + return c.json({ message: result.error.message }, { status: 404 }); + } + if (result.error.type === LandingPageErrorType.USER_NOT_AUTHORIZED) { + return c.json({ message: result.error.message }, { status: 403 }); + } + return c.json({ message: result.error.message }, { status: 400 }); + } + + return c.json( + { + message: "Custom domain removed successfully", + landingPage: result.data, + }, + { status: 200 } + ); + } catch (error) { + console.error("Error removing custom domain:", error); + return c.json( + { message: "Error removing custom domain", error: String(error) }, + 500 + ); + } +}); + +export default landingPageRoutes; diff --git a/app/app/context/LandingPageContext.tsx b/app/app/context/LandingPageContext.tsx new file mode 100644 index 00000000..87ecd9fd --- /dev/null +++ b/app/app/context/LandingPageContext.tsx @@ -0,0 +1,103 @@ +import { + createContext, + useContext, + useState, + useCallback, + ReactNode, + useEffect, +} from "react"; +import { useAuth } from "./useAuth"; +import { get } from "../utils/apiClient"; +import { LandingPage } from "../types/landingPage"; + +interface LandingPageContextType { + landingPageData: LandingPage | null; + isLoading: boolean; + error: string | null; + hasLandingPage: boolean; + refreshLandingPageData: () => Promise; + updateLandingPageData: (newData: Partial) => void; + clearLandingPageData: () => void; +} + +const LandingPageContext = createContext(undefined); + +export { LandingPageContext }; + +export function LandingPageProvider({ children }: { children: ReactNode }) { + const { isAuthenticated, token } = useAuth(); + const [landingPageData, setLandingPageData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const refreshLandingPageData = useCallback(async () => { + if (!isAuthenticated || !token) { + setLandingPageData(null); + return; + } + + setIsLoading(true); + setError(null); + + try { + const response = await get("api/landing-page", token); + + if (response && "exists" in response && response.exists === false) { + setLandingPageData(null); + } else if (response && "id" in response) { + setLandingPageData(response); + } else { + setLandingPageData(null); + } + } catch (err) { + console.error("Failed to fetch landing page data", err); + setError(err instanceof Error ? err.message : "Failed to fetch landing page data"); + setLandingPageData(null); + } finally { + setIsLoading(false); + } + }, [isAuthenticated, token]); + + const updateLandingPageData = useCallback((newData: Partial) => { + setLandingPageData(prev => (prev ? { ...prev, ...newData } : null)); + }, []); + + const clearLandingPageData = useCallback(() => { + setLandingPageData(null); + setError(null); + }, []); + + useEffect(() => { + if (isAuthenticated && token) { + refreshLandingPageData(); + } else { + clearLandingPageData(); + } + }, [isAuthenticated, token, refreshLandingPageData, clearLandingPageData]); + + const hasLandingPage = Boolean(landingPageData); + + return ( + + {children} + + ); +} + +export function useLandingPage() { + const context = useContext(LandingPageContext); + if (context === undefined) { + throw new Error("useLandingPage must be used within a LandingPageProvider"); + } + return context; +} \ No newline at end of file diff --git a/app/app/routes/_layout.dex_.page.tsx b/app/app/routes/_layout.dex_.page.tsx new file mode 100644 index 00000000..3f8ba20b --- /dev/null +++ b/app/app/routes/_layout.dex_.page.tsx @@ -0,0 +1,276 @@ +import { useState, useEffect } from "react"; +import type { MetaFunction } from "@remix-run/node"; +import { toast } from "react-toastify"; +import { useAuth } from "../context/useAuth"; +import { useDex } from "../context/DexContext"; +import { useLandingPage } from "../context/LandingPageContext"; +import { useModal } from "../context/ModalContext"; +import { del } from "../utils/apiClient"; +import WalletConnect from "../components/WalletConnect"; +import { Button } from "../components/Button"; +import { Card } from "../components/Card"; +import { useNavigate } from "@remix-run/react"; + +export const meta: MetaFunction = () => [ + { title: "Landing Page - Orderly One" }, + { + name: "description", + content: + "Create and customize your DEX landing page. Configure branding, content, and settings for your landing page.", + }, +]; + +export default function LandingPageRoute() { + const { isAuthenticated, token, isLoading } = useAuth(); + const { dexData } = useDex(); + const { + landingPageData, + isLoading: isLandingPageLoading, + refreshLandingPageData, + clearLandingPageData, + hasLandingPage, + } = useLandingPage(); + const { openModal } = useModal(); + const navigate = useNavigate(); + + const [isDeleting, setIsDeleting] = useState(false); + + useEffect(() => { + if (isAuthenticated && token) { + refreshLandingPageData(); + } + }, [isAuthenticated, token, refreshLandingPageData]); + + const handleDelete = async () => { + if (!landingPageData || !landingPageData.id || !token) { + toast.error("Landing page information is not available"); + return; + } + + setIsDeleting(true); + + try { + await del<{ message: string }>(`api/landing-page/${landingPageData.id}`, null, token); + toast.success("Landing page deleted successfully!"); + + clearLandingPageData(); + navigate("/dex"); + } catch (error) { + console.error("Error deleting landing page:", error); + toast.error("Failed to delete the landing page. Please try again later."); + } finally { + setIsDeleting(false); + } + }; + + const handleShowDeleteConfirm = () => { + openModal("deleteConfirm", { + onConfirm: handleDelete, + entityName: "landing page", + }); + }; + + + if (isLoading || isLandingPageLoading) { + return ( +
+
+
+
Loading your landing page
+
+ Please wait while we fetch your configuration +
+
+
+ ); + } + + if (!isAuthenticated) { + return ( +
+
+

+ Create Your Landing Page +

+

+ Build a beautiful landing page for your DEX +

+
+

Create a professional landing page to showcase your DEX.

+

Customize branding, content, and settings to attract traders.

+
+ + +

+ Connect your wallet to get started +

+

+ Authentication required. Please connect your wallet and login to + create and manage your landing page +

+
+ +
+
+
+
+ ); + } + + if (!dexData) { + return ( +
+ +
+
+

+ DEX Required +

+

+ You need to create a DEX first before you can set up a landing page. +

+ +
+
+
+ ); + } + + if (!hasLandingPage) { + return ( +
+
+

+ Create Your Landing Page +

+

+ Build a beautiful landing page for your DEX +

+
+

Create a professional landing page to showcase your DEX.

+

Customize branding, content, and settings to attract traders.

+
+ + +

+ Get Started +

+

+ Create your landing page to start attracting traders to your DEX +

+
+ +
+
+
+
+ ); + } + + return ( +
+
+

+ Manage Your Landing Page +

+
+ +
+ {landingPageData && ( + +
+
+
+
+
+
+

+ Configure Your Landing Page +

+

+ Customize branding, content, sections, and advanced settings for your landing page. +

+
+
+ +
+
+ )} + + {landingPageData && landingPageData.repoUrl && ( + +
+
+
+
+
+
+

+ Landing Page Published +

+

+ Your landing page is live and accessible to visitors. +

+ + View Landing Page → + +
+
+ +
+
+ )} + + +

+ Danger Zone +

+
+
+
+

+ Delete Landing Page +

+

+ Permanently delete your landing page configuration. This action cannot be undone. +

+
+ +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/app/routes/_layout.dex_.page_.config.tsx b/app/app/routes/_layout.dex_.page_.config.tsx new file mode 100644 index 00000000..b529a492 --- /dev/null +++ b/app/app/routes/_layout.dex_.page_.config.tsx @@ -0,0 +1,434 @@ +import { useState, useEffect, FormEvent } from "react"; +import type { MetaFunction } from "@remix-run/node"; +import { toast } from "react-toastify"; +import { useAuth } from "../context/useAuth"; +import { useLandingPage } from "../context/LandingPageContext"; +import { useDex } from "../context/DexContext"; +import { post, put } from "../utils/apiClient"; +import WalletConnect from "../components/WalletConnect"; +import { Button } from "../components/Button"; +import { Card } from "../components/Card"; +import { useNavigate, Link } from "@remix-run/react"; +import Form from "../components/Form"; + +export const meta: MetaFunction = () => [ + { title: "Configure Landing Page - Orderly One" }, + { + name: "description", + content: + "Configure your landing page. Set up branding, content, and design for your DEX landing page.", + }, +]; + +interface LandingPageConfigForm { + title: string; + subtitle?: string; + theme: "light" | "dark"; + primaryColor: string; + secondaryColor: string; + fontFamily: string; + sections: Array<{ + type: "hero" | "features" | "about" | "contact" | "custom"; + content: Record; + order: number; + }>; + metadata?: { + description?: string; + keywords?: string[]; + favicon?: string; + }; +} + +export default function LandingPageConfigRoute() { + const { isAuthenticated, token, isLoading } = useAuth(); + const { dexData } = useDex(); + const { + landingPageData, + isLoading: isLandingPageLoading, + refreshLandingPageData, + updateLandingPageData, + } = useLandingPage(); + const navigate = useNavigate(); + + const [isSaving, setIsSaving] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); + const [formData, setFormData] = useState({ + title: "", + subtitle: "", + theme: "light", + primaryColor: "#000000", + secondaryColor: "#ffffff", + fontFamily: "sans-serif", + sections: [], + }); + const [previewHtml, setPreviewHtml] = useState(null); + const [chatMode, setChatMode] = useState(false); + const [chatPrompt, setChatPrompt] = useState(""); + const [showWarning, setShowWarning] = useState(false); + + useEffect(() => { + if (!isAuthenticated || !token) return; + + if (landingPageData) { + setChatMode(true); + if (landingPageData.config) { + const config = landingPageData.config as Partial; + setFormData(prev => ({ + ...prev, + ...config, + })); + } + if (landingPageData.htmlContent) { + setPreviewHtml(landingPageData.htmlContent); + } + } + }, [isAuthenticated, token, landingPageData]); + + useEffect(() => { + if (!dexData) { + navigate("/dex/page"); + } + }, [dexData, navigate]); + + const handleInputChange = ( + field: keyof LandingPageConfigForm, + value: unknown + ) => { + setFormData(prev => ({ + ...prev, + [field]: value, + })); + if (landingPageData) { + setShowWarning(true); + } + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!token) { + toast.error("Authentication required"); + return; + } + + setIsSaving(true); + + try { + if (landingPageData) { + // Update existing landing page + const result = await put<{ id: string }>( + `api/landing-page/${landingPageData.id}`, + { config: formData }, + token + ); + + if (result) { + toast.success("Landing page configuration updated!"); + refreshLandingPageData(); + setShowWarning(false); + } + } else { + // Create new landing page + const result = await post<{ id: string }>( + "api/landing-page", + formData, + token + ); + + if (result) { + toast.success("Landing page created successfully!"); + refreshLandingPageData(); + navigate("/dex/page"); + } + } + } catch (error) { + console.error("Error saving landing page:", error); + toast.error("Failed to save landing page configuration"); + } finally { + setIsSaving(false); + } + }; + + const handleGenerate = async () => { + if (!landingPageData || !token) { + toast.error("Landing page must be created first"); + return; + } + + if (!chatPrompt.trim()) { + toast.error("Please enter a prompt"); + return; + } + + setIsGenerating(true); + + try { + const result = await post<{ landingPage: { htmlContent: string } }>( + `api/landing-page/${landingPageData.id}/generate`, + { prompt: chatPrompt }, + token + ); + + if (result && result.landingPage) { + setPreviewHtml(result.landingPage.htmlContent); + updateLandingPageData({ htmlContent: result.landingPage.htmlContent }); + toast.success("Landing page generated successfully!"); + setChatPrompt(""); + } + } catch (error) { + console.error("Error generating landing page:", error); + toast.error("Failed to generate landing page"); + } finally { + setIsGenerating(false); + } + }; + + if (isLoading || isLandingPageLoading) { + return ( +
+
+
+
Loading configuration
+
+
+ ); + } + + if (!isAuthenticated) { + return ( +
+
+

+ Configure Landing Page +

+ +

+ Authentication Required +

+

+ Please connect your wallet and login to configure your landing page. +

+
+ +
+
+
+
+ ); + } + + if (!dexData) { + return ( +
+ +
+
+

+ DEX Required +

+

+ You need to create a DEX first before configuring a landing page. +

+ +
+
+
+ ); + } + + return ( +
+
+
+ +
+ Back to Landing Page + +

+ {chatMode ? "Fine-tune Landing Page" : "Configure Landing Page"} +

+
+
+ + {showWarning && ( + +
+
+
+

+ Configuration Change Warning +

+

+ Changing these settings will regenerate your landing page. The + current content will be replaced. +

+
+
+
+ )} + +
+
+ {chatMode ? ( + +

Chat Mode

+

+ Fine-tune your landing page by describing the changes you want. +

+
+
+ +