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
32 changes: 32 additions & 0 deletions apps/api/src/api/controllers/contact.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { SubmitContactErrorResponse, SubmitContactResponse } from "@vortexfi/shared";
import type { Request, Response } from "express";
import { config } from "../../config";
import { storeDataInGoogleSpreadsheet } from "./googleSpreadSheet.controller";

enum ContactSheetHeaders {
Timestamp = "timestamp",
FullName = "fullName",
Email = "email",
ProjectName = "projectName",
Inquiry = "inquiry"
}

const CONTACT_SHEET_HEADER_VALUES = [
ContactSheetHeaders.Timestamp,
ContactSheetHeaders.FullName,
ContactSheetHeaders.Email,
ContactSheetHeaders.ProjectName,
ContactSheetHeaders.Inquiry
];

export { CONTACT_SHEET_HEADER_VALUES };

export const submitContact = async (
req: Request,
res: Response<SubmitContactResponse | SubmitContactErrorResponse>
): Promise<void> => {
if (!config.spreadsheet.contactSheetId) {
throw new Error("Contact sheet ID is not configured");
}
await storeDataInGoogleSpreadsheet(req, res, config.spreadsheet.contactSheetId, CONTACT_SHEET_HEADER_VALUES);
};
2 changes: 2 additions & 0 deletions apps/api/src/api/middlewares/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "@vortexfi/shared";
import { RequestHandler } from "express";
import httpStatus from "http-status";
import { CONTACT_SHEET_HEADER_VALUES } from "../controllers/contact.controller";
import { EMAIL_SHEET_HEADER_VALUES } from "../controllers/email.controller";
import { RATING_SHEET_HEADER_VALUES } from "../controllers/rating.controller";
import { FLOW_HEADERS } from "../controllers/storage.controller";
Expand Down Expand Up @@ -261,6 +262,7 @@ const validateRequestBodyValues =
};

export const validateStorageInput = validateRequestBodyValuesForTransactionStore();
export const validateContactInput = validateRequestBodyValues(CONTACT_SHEET_HEADER_VALUES);
export const validateEmailInput = validateRequestBodyValues(EMAIL_SHEET_HEADER_VALUES);
export const validateRatingInput = validateRequestBodyValues(RATING_SHEET_HEADER_VALUES);
export const validateExecuteXCM = validateRequestBodyValues(["id", "payload"]);
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/api/routes/v1/contact.route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Router } from "express";
import * as contactController from "../../controllers/contact.controller";
import { validateContactInput } from "../../middlewares/validators";

const router: Router = Router({ mergeParams: true });

router.route("/submit").post(validateContactInput, contactController.submitContact);

export default router;
6 changes: 6 additions & 0 deletions apps/api/src/api/routes/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { sendStatusWithPk as sendPendulumStatusWithPk } from "../../controllers/
import { sendStatusWithPk as sendStellarStatusWithPk } from "../../controllers/stellar.controller";
import partnerApiKeysRoutes from "./admin/partner-api-keys.route";
import brlaRoutes from "./brla.route";
import contactRoutes from "./contact.route";
import countriesRoutes from "./countries.route";
import cryptocurrenciesRoutes from "./cryptocurrencies.route";
import emailRoutes from "./email.route";
Expand Down Expand Up @@ -85,6 +86,11 @@ router.use("/pendulum", pendulumRoutes);
*/
router.use("/storage", storageRoutes);

/**
* POST v1/contact
*/
router.use("/contact", contactRoutes);

/**
* POST v1/email
*/
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/config/vars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface SpreadsheetConfig {
googleCredentials: GoogleCredentials;
storageSheetId: string | undefined;
emailSheetId: string | undefined;
contactSheetId: string | undefined;
ratingSheetId: string | undefined;
}

Expand Down Expand Up @@ -98,6 +99,7 @@ export const config: Config = {
rateLimitNumberOfProxies: process.env.RATE_LIMIT_NUMBER_OF_PROXIES || 1,
rateLimitWindowMinutes: process.env.RATE_LIMIT_WINDOW_MINUTES || 1,
spreadsheet: {
contactSheetId: process.env.GOOGLE_CONTACT_SPREADSHEET_ID,
emailSheetId: process.env.GOOGLE_EMAIL_SPREADSHEET_ID,
googleCredentials: {
email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
Expand Down
25 changes: 25 additions & 0 deletions apps/frontend/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@

.btn-vortex-primary {
@apply bg-blue-700 text-white rounded-[var(--radius-field)] border border-blue-700 cursor-pointer;
transition: scale 0.1s ease-in-out;
}

.btn-vortex-primary:active {
scale: 0.98;
}

.btn-vortex-primary:hover {
Expand All @@ -134,6 +139,11 @@
@apply border;
@apply border-gray-300;
@apply duration-200;
transition: scale 0.1s ease-in-out;
}

.btn-vortex-accent:active {
scale: 0.98;
}

.btn-vortex-accent:hover {
Expand All @@ -149,6 +159,11 @@
@apply border;
@apply border-blue-700;
@apply cursor-pointer;
transition: scale 0.1s ease-in-out;
}

.btn-vortex-primary-inverse:active {
scale: 0.98;
}

.btn-vortex-primary-inverse:hover {
Expand All @@ -175,6 +190,11 @@
@apply bg-pink-600;
@apply border-pink-600;
@apply shadow-none;
transition: scale 0.1s ease-in-out;
}

.btn-vortex-secondary:active {
scale: 0.98;
}

.btn-vortex-secondary:hover {
Expand All @@ -191,6 +211,11 @@
@apply border;
@apply border-red-600;
@apply shadow-none;
transition: scale 0.1s ease-in-out;
}

.btn-vortex-danger:active {
scale: 0.98;
}

.btn-vortex-danger:hover {
Expand Down
39 changes: 21 additions & 18 deletions apps/frontend/src/components/CallToActionSection/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PlayCircleIcon } from "@heroicons/react/20/solid";
import { Link } from "@tanstack/react-router";
import { motion } from "motion/react";
import { ReactNode } from "react";
import PLANET from "../../assets/planet.svg";
Expand All @@ -8,22 +9,24 @@ interface CallToActionSectionProps {
description: string;
buttonText: string;
buttonUrl?: string;
isExternal?: boolean;
}

/**
* CallToActionSection - Reusable CTA section with animated planet background
* Features:
* - Animated planet image with hover effects
* - Flexible title (string or ReactNode for custom styling)
* - Responsive layout
* - Configurable button text and URL
*/
export const CallToActionSection = ({
title,
description,
buttonText,
buttonUrl = "https://forms.gle/dKh8ckXheRPdRa398"
buttonUrl = "",
isExternal = true
}: CallToActionSectionProps) => {
const buttonClassName = "btn btn-vortex-secondary mx-auto flex items-center gap-2 rounded-3xl px-6 md:mx-0";
const buttonContent = (
<>
<span>{buttonText}</span>
<PlayCircleIcon aria-hidden="true" className="w-5 group-hover:text-pink-600" />
</>
);

return (
<section className="overflow-hidden bg-blue-900 px-4 py-32 text-white md:px-10">
<div className="relative mx-auto flex flex-col justify-between sm:container md:flex-row">
Expand All @@ -45,15 +48,15 @@ export const CallToActionSection = ({
</div>
<div className="z-10 flex flex-col justify-center md:w-1/2 md:items-end">
<p className="mt-3 mb-4 text-center text-body-lg md:mt-0 md:text-end">{description}</p>
<a
className="btn btn-vortex-secondary mx-auto flex items-center gap-2 rounded-3xl px-6 md:mx-0"
href={buttonUrl}
rel="noopener noreferrer"
target="_blank"
>
<span>{buttonText}</span>
<PlayCircleIcon aria-hidden="true" className="w-5 group-hover:text-pink-600" />
</a>
{isExternal ? (
<a className={buttonClassName} href={buttonUrl} rel="noopener noreferrer" target="_blank">
{buttonContent}
</a>
) : (
<Link className={buttonClassName} to={buttonUrl}>
{buttonContent}
</Link>
)}
</div>
</div>
</section>
Expand Down
59 changes: 59 additions & 0 deletions apps/frontend/src/components/ContactForm/ContactInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { useTranslation } from "react-i18next";

export function ContactInfo() {
const { t } = useTranslation();

return (
<div className="rounded-xl bg-gray-50 p-6 md:p-8">
<h2 className="mb-6 font-bold text-xl text-gray-900" style={{ textWrap: "balance" }}>
{t("pages.contact.info.title")}
</h2>

<ul className="mb-8 space-y-3 text-gray-700">
<li className="flex items-center gap-3">
<CheckIcon />
<span>{t("pages.contact.info.requestDemo")}</span>
</li>
<li className="flex items-center gap-3">
<CheckIcon />
<span>{t("pages.contact.info.onboardingHelp")}</span>
</li>
<li className="flex items-center gap-3">
<CheckIcon />
<span>{t("pages.contact.info.integrationHelp")}</span>
</li>
</ul>

<div className="border-gray-200 border-t pt-6">
<p className="mb-3 text-gray-600 text-sm">{t("pages.contact.info.technicalQuestions")}</p>
<a
className="group inline-flex min-h-[44px] items-center gap-2 text-blue-600 transition-colors duration-150 ease-out hover:text-blue-700"
href="https://t.me/vortex_fi"
rel="noopener noreferrer"
target="_blank"
>
<span className="font-medium">{t("pages.contact.info.supportLink")}</span>
<ChevronRightIcon
aria-hidden="true"
className="h-4 w-4 transition-transform duration-150 ease-out group-hover:translate-x-0.5"
/>
</a>
</div>
</div>
);
}

function CheckIcon() {
return (
<svg
aria-hidden="true"
className="h-5 w-5 flex-shrink-0 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
);
}
Loading