diff --git a/apps/api/.env.example b/apps/api/.env.example index 40c352f56..7cd26ef98 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -10,6 +10,11 @@ SANDBOX_ENABLED=false # Example: openssl rand -base64 32 ADMIN_SECRET=your-secure-admin-secret-here +# Supabase Configuration +SUPABASE_URL=https://your-project-id.supabase.co +SUPABASE_ANON_KEY=your-anon-key-here +SUPABASE_SERVICE_KEY=your-service-role-key-here + # Database DB_HOST=localhost DB_PORT=5432 diff --git a/apps/api/package.json b/apps/api/package.json index 2c786c074..43e2381dd 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,7 +11,7 @@ "@polkadot/util": "catalog:", "@polkadot/util-crypto": "catalog:", "@scure/bip39": "^1.5.4", - "@supabase/supabase-js": "^2.87.1", + "@supabase/supabase-js": "catalog:", "@vortexfi/shared": "workspace:*", "@wagmi/core": "catalog:", "axios": "catalog:", diff --git a/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts b/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts index 173216a87..9da563920 100644 --- a/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts +++ b/apps/api/src/api/controllers/admin/partnerApiKeys.controller.ts @@ -12,7 +12,7 @@ import { generateApiKey, getKeyPrefix, hashApiKey } from "../../middlewares/apiK */ export async function createApiKey(req: Request<{ partnerName: string }>, res: Response): Promise { try { - const partnerName = req.params.partnerName as string; + const partnerName = req.params.partnerName; const { name, expiresAt } = req.body; // Verify at least one partner with this name exists and is active @@ -78,7 +78,7 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R expiresAt: expirationDate, isActive: true, partnerCount: partners.length, - partnerName: partnerName, + partnerName, publicKey: { id: publicKeyRecord.id, key: publicKey, // Can be shown anytime (it's public) @@ -112,7 +112,7 @@ export async function createApiKey(req: Request<{ partnerName: string }>, res: R */ export async function listApiKeys(req: Request<{ partnerName: string }>, res: Response): Promise { try { - const partnerName = req.params.partnerName as string; + const partnerName = req.params.partnerName; // Verify partner exists const partners = await Partner.findAll({ diff --git a/apps/api/src/api/controllers/auth.controller.ts b/apps/api/src/api/controllers/auth.controller.ts new file mode 100644 index 000000000..14cdb6650 --- /dev/null +++ b/apps/api/src/api/controllers/auth.controller.ts @@ -0,0 +1,168 @@ +import { Request, Response } from "express"; +import User from "../../models/user.model"; +import { SupabaseAuthService } from "../services/auth"; + +export class AuthController { + /** + * Check if email is registered + * GET /api/v1/auth/check-email?email=user@example.com + */ + static async checkEmail(req: Request, res: Response) { + try { + const { email } = req.query; + + if (!email || typeof email !== "string") { + return res.status(400).json({ + error: "Email is required" + }); + } + + const exists = await SupabaseAuthService.checkUserExists(email); + + return res.json({ + action: exists ? "signin" : "signup", + exists + }); + } catch (error) { + console.error("Error in checkEmail:", error); + return res.status(500).json({ + error: "Failed to check email" + }); + } + } + + /** + * Request OTP + * POST /api/v1/auth/request-otp + */ + static async requestOTP(req: Request, res: Response) { + try { + const { email, locale } = req.body; + + if (!email) { + return res.status(400).json({ + error: "Email is required" + }); + } + + if (locale !== undefined && typeof locale !== "string") { + return res.status(400).json({ + error: "Locale must be a string" + }); + } + + await SupabaseAuthService.sendOTP(email, locale); + + return res.json({ + message: "OTP sent to email", + success: true + }); + } catch (error) { + console.error("Error in requestOTP:", error); + return res.status(500).json({ + error: "Failed to send OTP" + }); + } + } + + /** + * Verify OTP + * POST /api/v1/auth/verify-otp + */ + static async verifyOTP(req: Request, res: Response) { + try { + const { email, token } = req.body; + + if (!email || !token) { + return res.status(400).json({ + error: "Email and token are required" + }); + } + + const result = await SupabaseAuthService.verifyOTP(email, token); + + // Sync user to local database (upsert) + await User.upsert({ + email: email, + id: result.user_id + }); + + return res.json({ + access_token: result.access_token, + refresh_token: result.refresh_token, + success: true, + user_id: result.user_id + }); + } catch (error) { + console.error("Error in verifyOTP:", error); + return res.status(400).json({ + error: "Invalid OTP or OTP expired" + }); + } + } + + /** + * Refresh token + * POST /api/v1/auth/refresh + */ + static async refreshToken(req: Request, res: Response) { + try { + const { refresh_token } = req.body; + + if (!refresh_token) { + return res.status(400).json({ + error: "Refresh token is required" + }); + } + + const result = await SupabaseAuthService.refreshToken(refresh_token); + + return res.json({ + access_token: result.access_token, + refresh_token: result.refresh_token, + success: true + }); + } catch (error) { + console.error("Error in refreshToken:", error); + return res.status(401).json({ + error: "Invalid refresh token" + }); + } + } + + /** + * Verify token + * POST /api/v1/auth/verify + */ + static async verifyToken(req: Request, res: Response) { + try { + const { access_token } = req.body; + + if (!access_token) { + return res.status(400).json({ + error: "Access token is required" + }); + } + + const result = await SupabaseAuthService.verifyToken(access_token); + + if (!result.valid) { + return res.status(401).json({ + error: "Invalid token", + valid: false + }); + } + + return res.json({ + user_id: result.user_id, + valid: true + }); + } catch (error) { + console.error("Error in verifyToken:", error); + return res.status(401).json({ + error: "Token verification failed", + valid: false + }); + } + } +} diff --git a/apps/api/src/api/controllers/brla.controller.ts b/apps/api/src/api/controllers/brla.controller.ts index bb893107e..8b6cefe59 100644 --- a/apps/api/src/api/controllers/brla.controller.ts +++ b/apps/api/src/api/controllers/brla.controller.ts @@ -203,12 +203,14 @@ export const recordInitialKycAttempt = async ( taxId } }); + if (!taxIdRecord) { const accountType = isValidCnpj(taxId) ? AveniaAccountType.COMPANY : isValidCpf(taxId) ? AveniaAccountType.INDIVIDUAL : undefined; + // Create the entry only if a valid taxId is provided. Otherwise we ignore the request. if (accountType) { await TaxId.create({ @@ -217,7 +219,8 @@ export const recordInitialKycAttempt = async ( initialSessionId: sessionId ?? null, internalStatus: TaxIdInternalStatus.Consulted, subAccountId: "", - taxId + taxId, + userId: req.userId ?? null }); } } @@ -317,6 +320,7 @@ export const createSubaccount = async ( } else { // The entry should have been created the very first a new cpf/cnpj is consulted. // We leave this as is for now to avoid breaking changes. + await TaxId.create({ accountType, initialQuoteId: quoteId, @@ -324,7 +328,8 @@ export const createSubaccount = async ( internalStatus: TaxIdInternalStatus.Requested, requestedDate: new Date(), subAccountId: id, - taxId: taxId + taxId: taxId, + userId: req.userId ?? null }); } diff --git a/apps/api/src/api/controllers/contact.controller.ts b/apps/api/src/api/controllers/contact.controller.ts new file mode 100644 index 000000000..083b369dc --- /dev/null +++ b/apps/api/src/api/controllers/contact.controller.ts @@ -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 +): Promise => { + if (!config.spreadsheet.contactSheetId) { + throw new Error("Contact sheet ID is not configured"); + } + await storeDataInGoogleSpreadsheet(req, res, config.spreadsheet.contactSheetId, CONTACT_SHEET_HEADER_VALUES); +}; diff --git a/apps/api/src/api/controllers/maintenance.controller.ts b/apps/api/src/api/controllers/maintenance.controller.ts index 0f39744a8..a1ca17a27 100644 --- a/apps/api/src/api/controllers/maintenance.controller.ts +++ b/apps/api/src/api/controllers/maintenance.controller.ts @@ -63,7 +63,7 @@ export const getAllMaintenanceSchedules: RequestHandler = async (_, res) => { */ export const updateScheduleActiveStatus: RequestHandler<{ id: string }> = async (req, res) => { try { - const id = req.params.id as string; + const id = req.params.id; const { isActive } = req.body; if (typeof isActive !== "boolean") { diff --git a/apps/api/src/api/controllers/metrics.controller.ts b/apps/api/src/api/controllers/metrics.controller.ts index b084712e3..76a7ce591 100644 --- a/apps/api/src/api/controllers/metrics.controller.ts +++ b/apps/api/src/api/controllers/metrics.controller.ts @@ -45,13 +45,13 @@ let supabaseClient: SupabaseClient | null = null; function getSupabaseClient() { if (!supabaseClient) { - if (!config.supabaseUrl) { + if (!config.supabase.url) { throw new Error("Missing Supabase URL in configuration."); } - if (!config.supabaseKey) { + if (!config.supabase.anonKey) { throw new Error("Missing Supabase Key in configuration."); } - supabaseClient = createClient(config.supabaseUrl, config.supabaseKey); + supabaseClient = createClient(config.supabase.url, config.supabase.anonKey); } return supabaseClient; } diff --git a/apps/api/src/api/controllers/quote.controller.ts b/apps/api/src/api/controllers/quote.controller.ts index bdcd347c5..90e74df30 100644 --- a/apps/api/src/api/controllers/quote.controller.ts +++ b/apps/api/src/api/controllers/quote.controller.ts @@ -48,7 +48,8 @@ export const createQuote = async ( partnerId, partnerName: publicKeyPartnerName, rampType, - to + to, + userId: req.userId }); res.status(httpStatus.CREATED).json(quote); @@ -85,7 +86,8 @@ export const createBestQuote = async ( partnerId, partnerName: publicKeyPartnerName, rampType, - to + to, + userId: req.userId }); res.status(httpStatus.CREATED).json(quote); diff --git a/apps/api/src/api/controllers/ramp.controller.ts b/apps/api/src/api/controllers/ramp.controller.ts index a57a55014..29e1d53f5 100644 --- a/apps/api/src/api/controllers/ramp.controller.ts +++ b/apps/api/src/api/controllers/ramp.controller.ts @@ -37,7 +37,8 @@ export const registerRamp = async (req: Request, res: Response, nex const ramp = await rampService.registerRamp({ additionalData, quoteId, - signingAccounts + signingAccounts, + userId: req.userId }); res.status(httpStatus.CREATED).json(ramp); diff --git a/apps/api/src/api/middlewares/supabaseAuth.ts b/apps/api/src/api/middlewares/supabaseAuth.ts new file mode 100644 index 000000000..eb2479211 --- /dev/null +++ b/apps/api/src/api/middlewares/supabaseAuth.ts @@ -0,0 +1,76 @@ +import { NextFunction, Request, Response } from "express"; +import logger from "../../config/logger"; +import { SupabaseAuthService } from "../services/auth"; + +declare global { + namespace Express { + interface Request { + userId?: string; + } + } +} + +/** + * Middleware to verify Supabase auth token and attach userId to request + */ +export async function requireAuth(req: Request, res: Response, next: NextFunction) { + try { + const authHeader = req.headers.authorization; + + if (!authHeader?.startsWith("Bearer ")) { + return res.status(401).json({ + error: "Missing or invalid authorization header" + }); + } + + const token = authHeader.substring(7); + const result = await SupabaseAuthService.verifyToken(token); + + if (!result.valid) { + return res.status(401).json({ + error: "Invalid or expired token" + }); + } + + req.userId = result.user_id; + next(); + } catch (error) { + console.error("Auth middleware error:", error); + return res.status(401).json({ + error: "Authentication failed" + }); + } +} + +/** + * Optional auth - attaches userId if token present + */ +export async function optionalAuth(req: Request, res: Response, next: NextFunction) { + try { + const authHeader = req.headers.authorization; + + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.substring(7); + const result = await SupabaseAuthService.verifyToken(token); + + if (result.valid) { + req.userId = result.user_id; + } + } + + next(); + } catch (error) { + // Log truncated token for security - only show first/last few characters + const authHeader = req.headers.authorization; + const truncatedAuth = authHeader + ? `${authHeader.substring(0, 15)}...${authHeader.substring(authHeader.length - 4)}` + : undefined; + + logger.warn("optionalAuth middleware: authentication error", { + authorization: truncatedAuth, + error, + path: req.path + }); + next(); + } +} diff --git a/apps/api/src/api/middlewares/validators.ts b/apps/api/src/api/middlewares/validators.ts index 520530626..cecf9178e 100644 --- a/apps/api/src/api/middlewares/validators.ts +++ b/apps/api/src/api/middlewares/validators.ts @@ -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"; @@ -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"]); diff --git a/apps/api/src/api/routes/v1/auth.route.ts b/apps/api/src/api/routes/v1/auth.route.ts new file mode 100644 index 000000000..ace9e939b --- /dev/null +++ b/apps/api/src/api/routes/v1/auth.route.ts @@ -0,0 +1,12 @@ +import { Router } from "express"; +import { AuthController } from "../../controllers/auth.controller"; + +const router = Router(); + +router.get("/check-email", AuthController.checkEmail); +router.post("/request-otp", AuthController.requestOTP); +router.post("/verify-otp", AuthController.verifyOTP); +router.post("/refresh", AuthController.refreshToken); +router.post("/verify", AuthController.verifyToken); + +export default router; diff --git a/apps/api/src/api/routes/v1/brla.route.ts b/apps/api/src/api/routes/v1/brla.route.ts index ca844f36d..49e9210ba 100644 --- a/apps/api/src/api/routes/v1/brla.route.ts +++ b/apps/api/src/api/routes/v1/brla.route.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import * as brlaController from "../../controllers/brla.controller"; +import { optionalAuth } from "../../middlewares/supabaseAuth"; import { validateStartKyc2, validateSubaccountCreation } from "../../middlewares/validators"; const router: Router = Router({ mergeParams: true }); @@ -14,16 +15,16 @@ router.route("/getSelfieLivenessUrl").get(brlaController.getSelfieLivenessUrl); router.route("/validatePixKey").get(brlaController.validatePixKey); -router.route("/createSubaccount").post(validateSubaccountCreation, brlaController.createSubaccount); +router.route("/createSubaccount").post(validateSubaccountCreation, optionalAuth, brlaController.createSubaccount); -router.route("/getUploadUrls").post(validateStartKyc2, brlaController.getUploadUrls); +router.route("/getUploadUrls").post(validateStartKyc2, optionalAuth, brlaController.getUploadUrls); -router.route("/newKyc").post(brlaController.newKyc); +router.route("/newKyc").post(optionalAuth, brlaController.newKyc); -router.route("/kyb/new-level-1/web-sdk").post(brlaController.initiateKybLevel1); +router.route("/kyb/new-level-1/web-sdk").post(optionalAuth, brlaController.initiateKybLevel1); router.route("/kyb/attempt-status").get(brlaController.getKybAttemptStatus); -router.route("/kyc/record-attempt").post(brlaController.recordInitialKycAttempt); +router.route("/kyc/record-attempt").post(optionalAuth, brlaController.recordInitialKycAttempt); export default router; diff --git a/apps/api/src/api/routes/v1/contact.route.ts b/apps/api/src/api/routes/v1/contact.route.ts new file mode 100644 index 000000000..e5883b19e --- /dev/null +++ b/apps/api/src/api/routes/v1/contact.route.ts @@ -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; diff --git a/apps/api/src/api/routes/v1/index.ts b/apps/api/src/api/routes/v1/index.ts index 021d3ce1f..576d8d8fd 100644 --- a/apps/api/src/api/routes/v1/index.ts +++ b/apps/api/src/api/routes/v1/index.ts @@ -3,7 +3,9 @@ import { sendStatusWithPk as sendMoonbeamStatusWithPk } from "../../controllers/ import { sendStatusWithPk as sendPendulumStatusWithPk } from "../../controllers/pendulum.controller"; import { sendStatusWithPk as sendStellarStatusWithPk } from "../../controllers/stellar.controller"; import partnerApiKeysRoutes from "./admin/partner-api-keys.route"; +import authRoutes from "./auth.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"; @@ -85,6 +87,11 @@ router.use("/pendulum", pendulumRoutes); */ router.use("/storage", storageRoutes); +/** + * POST v1/contact + */ +router.use("/contact", contactRoutes); + /** * POST v1/email */ @@ -146,6 +153,16 @@ router.use("/supported-fiat-currencies", fiatRoutes); */ router.use("/maintenance", maintenanceRoutes); +/** + * Auth routes for Supabase authentication + * GET /v1/auth/check-email + * POST /v1/auth/request-otp + * POST /v1/auth/verify-otp + * POST /v1/auth/refresh + * POST /v1/auth/verify + */ +router.use("/auth", authRoutes); + /** * GET v1/monerium */ diff --git a/apps/api/src/api/routes/v1/quote.route.ts b/apps/api/src/api/routes/v1/quote.route.ts index 9195fa321..14b9be6c9 100644 --- a/apps/api/src/api/routes/v1/quote.route.ts +++ b/apps/api/src/api/routes/v1/quote.route.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import { createBestQuote, createQuote, getQuote } from "../../controllers/quote.controller"; import { apiKeyAuth } from "../../middlewares/apiKeyAuth"; import { validatePublicKey } from "../../middlewares/publicKeyAuth"; +import { optionalAuth } from "../../middlewares/supabaseAuth"; import { validateCreateBestQuoteInput, validateCreateQuoteInput } from "../../middlewares/validators"; const router: Router = Router({ mergeParams: true }); @@ -42,6 +43,7 @@ const router: Router = Router({ mergeParams: true }); */ router.route("/").post( validateCreateQuoteInput, + optionalAuth, // Extract userId from Bearer token if provided (optional) validatePublicKey(), // Validate public key if provided (optional) apiKeyAuth({ required: false }), // Validate secret key if provided (optional) // enforcePartnerAuth(), // Enforce secret key auth if partnerId present // We don't enforce this for now and allow passing a partnerId without secret key @@ -100,6 +102,7 @@ router.route("/").post( */ router.route("/best").post( validateCreateBestQuoteInput, + optionalAuth, // Extract userId from Bearer token if provided (optional) validatePublicKey(), // Validate public key if provided (optional) apiKeyAuth({ required: false }), // Validate secret key if provided (optional) createBestQuote diff --git a/apps/api/src/api/routes/v1/ramp.route.ts b/apps/api/src/api/routes/v1/ramp.route.ts index 57962216d..341ab3c12 100644 --- a/apps/api/src/api/routes/v1/ramp.route.ts +++ b/apps/api/src/api/routes/v1/ramp.route.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import * as rampController from "../../controllers/ramp.controller"; +import { optionalAuth } from "../../middlewares/supabaseAuth"; const router = Router(); @@ -29,7 +30,7 @@ const router = Router(); * @apiError (Not Found 404) NotFound Quote does not exist */ -router.post("/register", rampController.registerRamp); +router.post("/register", optionalAuth, rampController.registerRamp); /** * @api {post} v1/ramp/update Update ramping process diff --git a/apps/api/src/api/services/auth/index.ts b/apps/api/src/api/services/auth/index.ts new file mode 100644 index 000000000..5f6ff90ae --- /dev/null +++ b/apps/api/src/api/services/auth/index.ts @@ -0,0 +1 @@ +export { SupabaseAuthService } from "./supabase.service"; diff --git a/apps/api/src/api/services/auth/supabase.service.ts b/apps/api/src/api/services/auth/supabase.service.ts new file mode 100644 index 000000000..1743ee606 --- /dev/null +++ b/apps/api/src/api/services/auth/supabase.service.ts @@ -0,0 +1,192 @@ +import logger from "../../../config/logger"; +import { supabase, supabaseAdmin } from "../../../config/supabase"; + +// Supported BCP 47 locale values and their canonical forms. +// The Supabase email templates branch on `.Data.locale` using these values. +const LOCALE_MAP: Record = { + en: "en-US", + "en-US": "en-US", + "en-us": "en-US", + pt: "pt-BR", + "pt-BR": "pt-BR", + "pt-br": "pt-BR" +}; + +const DEFAULT_LOCALE = "en-US"; + +/** + * Normalizes an incoming locale string to a canonical BCP 47 value. + * Unknown or missing values fall back to the default locale. + */ +function resolveLocale(locale?: string): { resolved: string; source: "request" | "default" } { + if (locale && LOCALE_MAP[locale]) { + return { resolved: LOCALE_MAP[locale], source: "request" }; + } + return { resolved: DEFAULT_LOCALE, source: "default" }; +} + +export class SupabaseAuthService { + /** + * Check if user exists by email + */ + static async checkUserExists(email: string): Promise { + try { + // Query the profiles table directly for better performance + const { data, error } = await supabaseAdmin.from("profiles").select("id").eq("email", email).single(); + + if (error) { + // If error is "PGRST116" (no rows returned), user doesn't exist + if (error.code === "PGRST116") { + return false; + } + throw error; + } + + return !!data; + } catch (error) { + logger.error("Error checking user existence:", error); + throw error; + } + } + + /** + * Send OTP to email + */ + static async sendOTP(email: string, locale?: string): Promise { + const { resolved: emailLocale, source: localeSource } = resolveLocale(locale); + + logger.debug(JSON.stringify({ emailLocale, event: "sendOTP", incomingLocale: locale ?? null, localeSource })); + + // Try updating the locale of existing users + const { data: profileData } = await supabaseAdmin.from("profiles").select("id").eq("email", email).single(); + + if (profileData?.id) { + const { error: updateError } = await supabaseAdmin.auth.admin.updateUserById(profileData.id, { + // Will overwrite only the `locale` field + user_metadata: { locale: emailLocale } + }); + + if (updateError) { + logger.error( + JSON.stringify({ + emailLocale, + error: updateError.message, + event: "sendOTP", + message: "failed to update user locale in metadata", + userId: profileData.id + }) + ); + } else { + logger.debug( + JSON.stringify({ + emailLocale, + event: "sendOTP", + message: "updated existing user locale in metadata", + userId: profileData.id + }) + ); + } + } + + const options = { + data: { + locale: emailLocale + }, + shouldCreateUser: true + }; + + const { error } = await supabase.auth.signInWithOtp({ + email, + options + }); + + if (error) { + throw error; + } + } + + /** + * Verify OTP + */ + static async verifyOTP( + email: string, + token: string + ): Promise<{ + access_token: string; + refresh_token: string; + user_id: string; + }> { + const { data, error } = await supabase.auth.verifyOtp({ + email, + token, + type: "email" + }); + + if (error) { + throw error; + } + + if (!data.session || !data.user) { + throw new Error("No session returned after OTP verification"); + } + + return { + access_token: data.session.access_token, + refresh_token: data.session.refresh_token, + user_id: data.user.id + }; + } + + /** + * Verify access token + */ + static async verifyToken(accessToken: string): Promise<{ + valid: boolean; + user_id?: string; + }> { + const { data, error } = await supabase.auth.getUser(accessToken); + + if (error || !data.user) { + return { valid: false }; + } + + return { + user_id: data.user.id, + valid: true + }; + } + + /** + * Refresh access token + */ + static async refreshToken(refreshToken: string): Promise<{ + access_token: string; + refresh_token: string; + }> { + const { data, error } = await supabase.auth.refreshSession({ + refresh_token: refreshToken + }); + + if (error || !data.session) { + throw new Error("Failed to refresh token"); + } + + return { + access_token: data.session.access_token, + refresh_token: data.session.refresh_token + }; + } + + /** + * Get user profile from Supabase + */ + static async getUserProfile(userId: string): Promise { + const { data, error } = await supabaseAdmin.auth.admin.getUserById(userId); + + if (error) { + throw error; + } + + return data.user; + } +} diff --git a/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts b/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts index 12f43ee3e..0b1f97b6d 100644 --- a/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts +++ b/apps/api/src/api/services/phases/handlers/moonbeam-to-pendulum-handler.ts @@ -39,7 +39,7 @@ export class MoonbeamToPendulumPhaseHandler extends BasePhaseHandler { const { substrateEphemeralAddress, moonbeamXcmTransactionHash, squidRouterReceiverId, squidRouterReceiverHash } = state.state as StateMetadata; - if (!substrateEphemeralAddress || !squidRouterReceiverId || !squidRouterReceiverId || !squidRouterReceiverHash) { + if (!substrateEphemeralAddress || !squidRouterReceiverId || !squidRouterReceiverHash) { throw new Error("MoonbeamToPendulumPhaseHandler: State metadata corrupted. This is a bug."); } diff --git a/apps/api/src/api/services/quote/core/squidrouter.ts b/apps/api/src/api/services/quote/core/squidrouter.ts index a9a6c98ea..ef74aad26 100644 --- a/apps/api/src/api/services/quote/core/squidrouter.ts +++ b/apps/api/src/api/services/quote/core/squidrouter.ts @@ -18,6 +18,7 @@ import { } from "@vortexfi/shared"; import { Big } from "big.js"; import httpStatus from "http-status"; +import { generatePrivateKey, privateKeyToAddress } from "viem/accounts"; import logger from "../../../../config/logger"; import { APIError } from "../../../errors/api-error"; import { multiplyByPowerOfTen } from "../../pendulum/helpers"; @@ -89,7 +90,7 @@ function prepareSquidrouterRouteParams(params: { }): RouteParams { const { rampType, amountRaw, fromToken, toToken, fromNetwork, toNetwork } = params; - const placeholderAddress = "0x30a300612ab372cc73e53ffe87fb73d62ed68da3"; + const placeholderAddress = privateKeyToAddress(generatePrivateKey()); const placeholderHash = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; return rampType === RampDirection.BUY diff --git a/apps/api/src/api/services/quote/core/types.ts b/apps/api/src/api/services/quote/core/types.ts index 768deda45..e57e35fbd 100644 --- a/apps/api/src/api/services/quote/core/types.ts +++ b/apps/api/src/api/services/quote/core/types.ts @@ -93,7 +93,7 @@ export interface IRouteStrategy { // Re-export here for convenience to avoid deep imports. export interface QuoteContext { // immutable request details - readonly request: CreateQuoteRequest; + readonly request: CreateQuoteRequest & { userId?: string }; readonly now: Date; // Partner info (if any) diff --git a/apps/api/src/api/services/quote/engines/fee/index.ts b/apps/api/src/api/services/quote/engines/fee/index.ts index 37ba1c250..2b3737600 100644 --- a/apps/api/src/api/services/quote/engines/fee/index.ts +++ b/apps/api/src/api/services/quote/engines/fee/index.ts @@ -25,6 +25,10 @@ export interface FeeConfig { export interface FeeComputation { anchor: FeeComponentInput; network: FeeComponentInput; + // Optional fees that may not be applicable to all engines, but can be included in the summary if present + // Override the vortex and partner markup fees from the fee components + forcedVortexFee?: FeeComponentInput; + forcedPartnerMarkupFee?: FeeComponentInput; } export abstract class BaseFeeEngine implements Stage { @@ -54,13 +58,13 @@ export abstract class BaseFeeEngine implements Stage { to: request.to }); - const { anchor, network } = await this.compute(ctx, anchorFee, feeCurrency); + const { anchor, network, forcedVortexFee, forcedPartnerMarkupFee } = await this.compute(ctx, anchorFee, feeCurrency); await assignFeeSummary(ctx, { anchor, network, - partnerMarkup: { amount: partnerMarkupFee, currency: feeCurrency }, - vortex: { amount: vortexFee, currency: feeCurrency } + partnerMarkup: forcedPartnerMarkupFee ? forcedPartnerMarkupFee : { amount: partnerMarkupFee, currency: feeCurrency }, + vortex: forcedVortexFee ? forcedVortexFee : { amount: vortexFee, currency: feeCurrency } }); } diff --git a/apps/api/src/api/services/quote/engines/fee/onramp-monerium-to-evm.ts b/apps/api/src/api/services/quote/engines/fee/onramp-monerium-to-evm.ts index 387abe70e..d3fe2f467 100644 --- a/apps/api/src/api/services/quote/engines/fee/onramp-monerium-to-evm.ts +++ b/apps/api/src/api/services/quote/engines/fee/onramp-monerium-to-evm.ts @@ -13,8 +13,11 @@ export class OnRampMoneriumToEvmFeeEngine extends BaseFeeEngine { } protected async compute(ctx: QuoteContext, anchorFee: string, feeCurrency: RampCurrency): Promise { + // For this specific engine, no fees are applied, so we return zero amounts for all fee components return { anchor: { amount: "0", currency: FiatToken.EURC as RampCurrency }, + forcedPartnerMarkupFee: { amount: "0", currency: feeCurrency }, + forcedVortexFee: { amount: "0", currency: feeCurrency }, network: { amount: "0", currency: EvmToken.USDC as RampCurrency } }; } diff --git a/apps/api/src/api/services/quote/engines/finalize/index.ts b/apps/api/src/api/services/quote/engines/finalize/index.ts index 98b91d3e4..b2344b26b 100644 --- a/apps/api/src/api/services/quote/engines/finalize/index.ts +++ b/apps/api/src/api/services/quote/engines/finalize/index.ts @@ -143,7 +143,8 @@ export abstract class BaseFinalizeEngine implements Stage { paymentMethod, rampType: request.rampType, status: "pending", - to: request.to + to: request.to, + userId: request.userId || null }); ctx.builtResponse = buildQuoteResponse(record); diff --git a/apps/api/src/api/services/quote/index.ts b/apps/api/src/api/services/quote/index.ts index 12e77efe1..bba4852b0 100644 --- a/apps/api/src/api/services/quote/index.ts +++ b/apps/api/src/api/services/quote/index.ts @@ -22,7 +22,7 @@ import { RouteResolver } from "./routes/route-resolver"; export class QuoteService extends BaseRampService { public async createQuote( - request: CreateQuoteRequest & { apiKey?: string | null; partnerName?: string | null } + request: CreateQuoteRequest & { apiKey?: string | null; partnerName?: string | null; userId?: string } ): Promise { return this.executeQuoteCalculation(request); } @@ -43,7 +43,7 @@ export class QuoteService extends BaseRampService { * @returns The best quote across all eligible networks */ public async createBestQuote( - request: CreateBestQuoteRequest & { apiKey?: string | null; partnerName?: string | null } + request: CreateBestQuoteRequest & { apiKey?: string | null; partnerName?: string | null; userId?: string } ): Promise { const { rampType, from, to } = request; @@ -126,7 +126,7 @@ export class QuoteService extends BaseRampService { * @returns The calculated quote */ private async executeQuoteCalculation( - request: CreateQuoteRequest & { apiKey?: string | null; partnerName?: string | null }, + request: CreateQuoteRequest & { apiKey?: string | null; partnerName?: string | null; userId?: string }, skipPersistence = false ): Promise { validateChainSupport(request.rampType, request.from, request.to); diff --git a/apps/api/src/api/services/ramp/ramp.service.ts b/apps/api/src/api/services/ramp/ramp.service.ts index 08bd1939e..31ab66e93 100644 --- a/apps/api/src/api/services/ramp/ramp.service.ts +++ b/apps/api/src/api/services/ramp/ramp.service.ts @@ -153,7 +153,8 @@ export class RampService extends BaseRampService { } as StateMetadata, to: quote.to, type: quote.rampType, - unsignedTxs + unsignedTxs, + userId: request.userId || quote.userId }); const response: RegisterRampResponse = { diff --git a/apps/api/src/config/supabase.ts b/apps/api/src/config/supabase.ts new file mode 100644 index 000000000..1f756bbb0 --- /dev/null +++ b/apps/api/src/config/supabase.ts @@ -0,0 +1,11 @@ +import { createClient } from "@supabase/supabase-js"; +import { config } from "./vars"; + +export const supabaseAdmin = createClient(config.supabase.url, config.supabase.serviceRoleKey, { + auth: { + autoRefreshToken: false, + persistSession: false + } +}); + +export const supabase = createClient(config.supabase.url, config.supabase.anonKey); diff --git a/apps/api/src/config/vars.ts b/apps/api/src/config/vars.ts index 68b5af9d1..3068349d9 100644 --- a/apps/api/src/config/vars.ts +++ b/apps/api/src/config/vars.ts @@ -18,6 +18,7 @@ interface SpreadsheetConfig { googleCredentials: GoogleCredentials; storageSheetId: string | undefined; emailSheetId: string | undefined; + contactSheetId: string | undefined; ratingSheetId: string | undefined; } @@ -31,6 +32,11 @@ interface Config { rateLimitNumberOfProxies: string | number; logs: string; adminSecret: string; + supabase: { + url: string; + anonKey: string; + serviceRoleKey: string; + }; priceProviders: { alchemyPay: PriceProvider; transak: PriceProvider; @@ -53,8 +59,6 @@ interface Config { discountStateTimeoutMinutes: number; deltaDBasisPoints: number; }; - supabaseKey: string | undefined; - supabaseUrl: string | undefined; subscanApiKey: string | undefined; vortexFeePenPercentage: number; } @@ -98,6 +102,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, @@ -107,8 +112,11 @@ export const config: Config = { storageSheetId: process.env.GOOGLE_SPREADSHEET_ID }, subscanApiKey: process.env.SUBSCAN_API_KEY, - supabaseKey: process.env.SUPABASE_SERVICE_KEY, - supabaseUrl: process.env.SUPABASE_URL, + supabase: { + anonKey: process.env.SUPABASE_ANON_KEY || "", + serviceRoleKey: process.env.SUPABASE_SERVICE_KEY || "", + url: process.env.SUPABASE_URL || "" + }, swap: { deadlineMinutes: 60 * 24 * 7 // 1 week }, diff --git a/apps/api/src/database/migrations/013-fix-tax-ids-table.ts b/apps/api/src/database/migrations/013-fix-tax-ids-table.ts index 665bc559f..9ab21e3c6 100644 --- a/apps/api/src/database/migrations/013-fix-tax-ids-table.ts +++ b/apps/api/src/database/migrations/013-fix-tax-ids-table.ts @@ -3,12 +3,27 @@ import { QueryInterface } from "sequelize"; export async function up(queryInterface: QueryInterface): Promise { // This migration alters the table to align with new requirements without dropping it. await queryInterface.sequelize.query(` - -- Rename the "taxId" column to "tax_id" to match naming conventions - ALTER TABLE "tax_ids" RENAME COLUMN "taxId" TO "tax_id"; - - -- Add the 'COMPANY' value to the existing enum type. - -- This is a non-destructive operation. - ALTER TYPE "enum_tax_ids_account_type" ADD VALUE 'COMPANY'; + DO $$ + BEGIN + -- Rename the "taxId" column to "tax_id" if it exists + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'tax_ids' AND column_name = 'taxId') THEN + ALTER TABLE "tax_ids" RENAME COLUMN "taxId" TO "tax_id"; + END IF; + END $$; + + -- Add the 'COMPANY' value to the existing enum type safely (compatible with PostgreSQL <12) + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_enum + WHERE enumlabel = 'COMPANY' + AND enumtypid = ( + SELECT oid FROM pg_type WHERE typname = 'enum_tax_ids_account_type' + ) + ) THEN + ALTER TYPE "enum_tax_ids_account_type" ADD VALUE 'COMPANY'; + END IF; + END $$; `); } diff --git a/apps/api/src/database/migrations/021-create-users-table.ts b/apps/api/src/database/migrations/021-create-users-table.ts new file mode 100644 index 000000000..be351032b --- /dev/null +++ b/apps/api/src/database/migrations/021-create-users-table.ts @@ -0,0 +1,39 @@ +import {DataTypes, QueryInterface} from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + // Create users table + await queryInterface.createTable("profiles", { + created_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + }, + email: { + allowNull: false, + type: DataTypes.STRING(255), + unique: true + }, + id: { + allowNull: false, + comment: "User ID from Supabase Auth (synced)", + primaryKey: true, + type: DataTypes.UUID + }, + updated_at: { + allowNull: false, + defaultValue: DataTypes.NOW, + type: DataTypes.DATE + } + }); + + // Add index on email for faster lookups + await queryInterface.addIndex("profiles", ["email"], { + name: "idx_profiles_email", + unique: true + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + await queryInterface.removeIndex("profiles", "idx_profiles_email"); + await queryInterface.dropTable("profiles"); +} diff --git a/apps/api/src/database/migrations/022-add-user-id-to-entities.ts b/apps/api/src/database/migrations/022-add-user-id-to-entities.ts new file mode 100644 index 000000000..576856ee7 --- /dev/null +++ b/apps/api/src/database/migrations/022-add-user-id-to-entities.ts @@ -0,0 +1,122 @@ +import { DataTypes, QueryInterface } from "sequelize"; + +export async function up(queryInterface: QueryInterface): Promise { + // Add user_id to kyc_level_2 + await queryInterface.addColumn("kyc_level_2", "user_id", { + allowNull: true, + type: DataTypes.UUID + }); + + await queryInterface.changeColumn("kyc_level_2", "user_id", { + allowNull: true, + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID + }); + + await queryInterface.addIndex("kyc_level_2", ["user_id"], { + name: "idx_kyc_level_2_user_id" + }); + + // Add user_id to quote_tickets + await queryInterface.addColumn("quote_tickets", "user_id", { + allowNull: true, + type: DataTypes.UUID + }); + + await queryInterface.changeColumn("quote_tickets", "user_id", { + allowNull: true, + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID + }); + + await queryInterface.addIndex("quote_tickets", ["user_id"], { + name: "idx_quote_tickets_user_id" + }); + + // Add user_id to ramp_states + await queryInterface.addColumn("ramp_states", "user_id", { + allowNull: true, + type: DataTypes.UUID + }); + + await queryInterface.changeColumn("ramp_states", "user_id", { + allowNull: true, + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID + }); + + await queryInterface.addIndex("ramp_states", ["user_id"], { + name: "idx_ramp_states_user_id" + }); + + // Add user_id to tax_ids + await queryInterface.addColumn("tax_ids", "user_id", { + allowNull: true, + type: DataTypes.UUID + }); + + await queryInterface.changeColumn("tax_ids", "user_id", { + allowNull: true, + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID + }); + + await queryInterface.addIndex("tax_ids", ["user_id"], { + name: "idx_tax_ids_user_id" + }); +} + +export async function down(queryInterface: QueryInterface): Promise { + const safeRemoveIndex = async (tableName: string, indexName: string) => { + try { + await queryInterface.removeIndex(tableName, indexName); + } catch (error: any) { + console.warn(`Failed to remove index ${indexName} from ${tableName}:`, error); + } + }; + + const safeRemoveColumn = async (tableName: string, columnName: string) => { + try { + await queryInterface.removeColumn(tableName, columnName); + } catch (error: any) { + // Ignore undefined_column error (code 42703) + if (error?.original?.code === "42703") { + console.warn(`Column ${columnName} does not exist in ${tableName}, skipping removal.`); + } else { + throw error; + } + } + }; + + // Remove indexes + await safeRemoveIndex("kyc_level_2", "idx_kyc_level_2_user_id"); + await safeRemoveIndex("quote_tickets", "idx_quote_tickets_user_id"); + await safeRemoveIndex("ramp_states", "idx_ramp_states_user_id"); + await safeRemoveIndex("tax_ids", "idx_tax_ids_user_id"); + + // Remove columns + await safeRemoveColumn("ramp_states", "user_id"); + await safeRemoveColumn("tax_ids", "user_id"); + await safeRemoveColumn("kyc_level_2", "user_id"); + await safeRemoveColumn("quote_tickets", "user_id"); +} diff --git a/apps/api/src/database/migrator.ts b/apps/api/src/database/migrator.ts index fa42f497a..ec719b136 100644 --- a/apps/api/src/database/migrator.ts +++ b/apps/api/src/database/migrator.ts @@ -6,7 +6,122 @@ import logger from "../config/logger"; // Create Umzug instance for migrations const umzug = new Umzug({ - context: sequelize.getQueryInterface(), + context: new Proxy(sequelize.getQueryInterface(), { + get(target, prop, receiver) { + if (prop === "addIndex") { + return async (...args: any[]) => { + try { + // @ts-ignore: dynamic args spreading + return await target.addIndex(...args); + } catch (error: any) { + if (error?.original?.code === "42P07") { + const indexName = args[2]?.name || "unknown"; + const tableName = args[0]; + logger.warn(`Index ${indexName} already exists on ${tableName}, skipping creation.`); + return; + } + throw error; + } + }; + } + if (prop === "bulkInsert") { + return async (...args: any[]) => { + try { + // @ts-ignore: dynamic args spreading + return await target.bulkInsert(...args); + } catch (error: any) { + // Swallow ALL bulkInsert errors to force migration forward in inconsistent environments + // This is critical to unblock 022 when 001/004 etc are re-running on existing data + const tableName = args[0]; + logger.warn(`Swallowing bulkInsert error on ${tableName}: ${error.message || error}`); + return 0; + } + }; + } + if (prop === "addColumn") { + return async (...args: any[]) => { + try { + // @ts-ignore: dynamic args spreading + return await target.addColumn(...args); + } catch (error: any) { + if (error?.original?.code === "42701") { + const columnName = args[1]; + const tableName = args[0]; + logger.warn(`Column ${columnName} already exists on ${tableName}, skipping creation.`); + return; + } + throw error; + } + }; + } + if (prop === "renameColumn") { + return async (...args: any[]) => { + try { + // @ts-ignore: dynamic args spreading + return await target.renameColumn(...args); + } catch (error: any) { + // 42701: duplicate_column (target column already exists) + // 42703: undefined_column (source column does not exist) + if (error?.original?.code === "42701" || error?.original?.code === "42703") { + const tableName = args[0]; + const oldName = args[1]; + const newName = args[2]; + logger.warn(`Rename column ${oldName} -> ${newName} on ${tableName} failed (exists/missing), skipping.`); + return; + } + throw error; + } + }; + } + if (prop === "changeColumn") { + return async (...args: any[]) => { + try { + // @ts-ignore: dynamic args spreading + return await target.changeColumn(...args); + } catch (error: any) { + // 42710: duplicate_object (constraint already exists) + // 42P07: duplicate_table (relation/constraint already exists) + if (error?.original?.code === "42710" || error?.original?.code === "42P07") { + const tableName = args[0]; + const columnName = args[1]; + logger.warn(`Change column ${columnName} on ${tableName} failed (likely constraint exists), skipping.`); + return; + } + throw error; + } + }; + } + if (prop === "sequelize") { + const originalSequelize = Reflect.get(target, prop, receiver); + return new Proxy(originalSequelize, { + get(seqTarget, seqProp, seqReceiver) { + if (seqProp === "query") { + return async (...args: any[]) => { + try { + // @ts-ignore: dynamic args spreading + return await seqTarget.query(...args); + } catch (error: any) { + // 42710: duplicate_object (trigger/function already exists) + // 42P07: duplicate_table (relation already exists) + if (error?.original?.code === "42710" || error?.original?.code === "42P07") { + const sql = args[0] as string; + // Try to extract object name from SQL for logging + const match = sql.match(/CREATE (?:OR REPLACE )?(?:TRIGGER|FUNCTION|TABLE) ["']?(\w+)["']?/i); + const objectName = match ? match[1] : "unknown object"; + logger.warn(`Query failed with "${error.message}" for ${objectName}, skipping.`); + return [[], 0]; + } + throw error; + } + }; + } + return Reflect.get(seqTarget, seqProp, seqReceiver); + } + }); + } + return Reflect.get(target, prop, receiver); + } + }), logger: { debug: (message: unknown) => logger.debug(message), error: (message: unknown) => logger.error(message), @@ -64,6 +179,29 @@ export const revertAllMigrations = async (): Promise => { } }; +// Revert specific migration +export const revertMigration = async (name: string): Promise => { + try { + const executed = await umzug.executed(); + const index = executed.findIndex(m => m.name === name); + + if (index === -1) { + throw new Error(`Migration ${name} not found in executed migrations`); + } + + // If it's the first migration, revert all (to 0) + // Otherwise, revert to the previous migration + const to = index === 0 ? 0 : executed[index - 1].name; + + logger.info(`Reverting to ${index === 0 ? "initial state" : to} (will revert ${name} and any subsequent migrations)`); + await umzug.down({ to }); + logger.info(`Migration ${name} reverted successfully`); + } catch (error) { + logger.error(`Error reverting migration ${name}:`, error); + throw error; + } +}; + // Get pending migrations export const getPendingMigrations = async (): Promise => { const pending = await umzug.pending(); @@ -84,9 +222,16 @@ if (require.main === module) { logger.info("Connection to the database has been established successfully"); // Check if the script is execute to run or revert migrations - if (process.argv[2] === "revert") { - await revertLastMigration(); - } else if (process.argv[2] === "revert-all") { + const command = process.argv[2]; + const arg = process.argv[3]; + + if (command === "revert") { + if (arg) { + await revertMigration(arg); + } else { + await revertLastMigration(); + } + } else if (command === "revert-all") { await revertAllMigrations(); } else { await runMigrations(); diff --git a/apps/api/src/models/index.ts b/apps/api/src/models/index.ts index b60298931..797670417 100644 --- a/apps/api/src/models/index.ts +++ b/apps/api/src/models/index.ts @@ -1,12 +1,14 @@ import sequelize from "../config/database"; import Anchor from "./anchor.model"; import ApiKey from "./apiKey.model"; +import KycLevel2 from "./kycLevel2.model"; import MaintenanceSchedule from "./maintenanceSchedule.model"; import Partner from "./partner.model"; import QuoteTicket from "./quoteTicket.model"; import RampState from "./rampState.model"; import Subsidy from "./subsidy.model"; import TaxId from "./taxId.model"; +import User from "./user.model"; import Webhook from "./webhook.model"; // Define associations @@ -17,16 +19,31 @@ Partner.hasMany(QuoteTicket, { as: "quotes", foreignKey: "partnerId" }); RampState.hasMany(Subsidy, { as: "subsidies", foreignKey: "rampId" }); Subsidy.belongsTo(RampState, { as: "rampState", foreignKey: "rampId" }); +// User associations +User.hasMany(QuoteTicket, { as: "quoteTickets", foreignKey: "userId" }); +QuoteTicket.belongsTo(User, { as: "user", foreignKey: "userId" }); + +User.hasMany(RampState, { as: "rampStates", foreignKey: "userId" }); +RampState.belongsTo(User, { as: "user", foreignKey: "userId" }); + +User.hasMany(KycLevel2, { as: "kycRecords", foreignKey: "userId" }); +KycLevel2.belongsTo(User, { as: "user", foreignKey: "userId" }); + +User.hasMany(TaxId, { as: "taxIds", foreignKey: "userId" }); +TaxId.belongsTo(User, { as: "user", foreignKey: "userId" }); + // Initialize models const models = { Anchor, ApiKey, + KycLevel2, MaintenanceSchedule, Partner, QuoteTicket, RampState, Subsidy, TaxId, + User, Webhook }; diff --git a/apps/api/src/models/kycLevel2.model.ts b/apps/api/src/models/kycLevel2.model.ts new file mode 100644 index 000000000..1dde0ff01 --- /dev/null +++ b/apps/api/src/models/kycLevel2.model.ts @@ -0,0 +1,110 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import sequelize from "../config/database"; + +export interface KycLevel2Attributes { + id: string; + userId: string | null; + subaccountId: string; + documentType: "RG" | "CNH"; + uploadData: any; + status: "Requested" | "DataCollected" | "BrlaValidating" | "Rejected" | "Accepted" | "Cancelled"; + errorLogs: any[]; + createdAt: Date; + updatedAt: Date; +} + +type KycLevel2CreationAttributes = Optional; + +class KycLevel2 extends Model implements KycLevel2Attributes { + declare id: string; + declare userId: string | null; + declare subaccountId: string; + declare documentType: "RG" | "CNH"; + declare uploadData: any; + declare status: "Requested" | "DataCollected" | "BrlaValidating" | "Rejected" | "Accepted" | "Cancelled"; + declare errorLogs: any[]; + declare createdAt: Date; + declare updatedAt: Date; +} + +KycLevel2.init( + { + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + documentType: { + allowNull: false, + field: "document_type", + type: DataTypes.ENUM("RG", "CNH") + }, + errorLogs: { + allowNull: false, + defaultValue: [], + field: "error_logs", + type: DataTypes.JSONB + }, + id: { + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + type: DataTypes.UUID + }, + status: { + allowNull: false, + defaultValue: "Requested", + field: "status", + type: DataTypes.ENUM("Requested", "DataCollected", "BrlaValidating", "Rejected", "Accepted", "Cancelled") + }, + subaccountId: { + allowNull: false, + field: "subaccount_id", + type: DataTypes.STRING + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + }, + uploadData: { + allowNull: false, + field: "upload_data", + type: DataTypes.JSONB + }, + userId: { + allowNull: true, + field: "user_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID + } + }, + { + indexes: [ + { + fields: ["subaccount_id"], + name: "idx_kyc_level_2_subaccount" + }, + { + fields: ["status"], + name: "idx_kyc_level_2_status" + }, + { + fields: ["user_id"], + name: "idx_kyc_level_2_user_id" + } + ], + modelName: "KycLevel2", + sequelize, + tableName: "kyc_level_2", + timestamps: true + } +); + +export default KycLevel2; diff --git a/apps/api/src/models/quoteTicket.model.ts b/apps/api/src/models/quoteTicket.model.ts index 6e2d0a633..cb27f94f5 100644 --- a/apps/api/src/models/quoteTicket.model.ts +++ b/apps/api/src/models/quoteTicket.model.ts @@ -1,11 +1,19 @@ -import { DestinationType, Networks, PaymentMethod, QuoteFeeStructure, RampCurrency, RampDirection } from "@vortexfi/shared"; -import { DataTypes, Model, Optional } from "sequelize"; -import { QuoteTicketMetadata } from "../api/services/quote/core/types"; +import { + DestinationType, + Networks, + PaymentMethod, + QuoteFeeStructure, + RampCurrency, + RampDirection +} from "@vortexfi/shared"; +import {DataTypes, Model, Optional} from "sequelize"; +import {QuoteTicketMetadata} from "../api/services/quote/core/types"; import sequelize from "../config/database"; // Define the attributes of the QuoteTicket model export interface QuoteTicketAttributes { id: string; // UUID + userId: string | null; // UUID reference to Supabase Auth user (nullable for unauthenticated quotes) rampType: RampDirection; from: DestinationType; to: DestinationType; @@ -33,6 +41,8 @@ export type QuoteTicketCreationAttributes = Optional implements QuoteTicketAttributes { declare id: string; + declare userId: string | null; + declare rampType: RampDirection; declare from: DestinationType; @@ -171,6 +181,17 @@ QuoteTicket.init( defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE + }, + userId: { + allowNull: true, + field: "user_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID } }, { diff --git a/apps/api/src/models/rampState.model.ts b/apps/api/src/models/rampState.model.ts index 6b0bb915e..d8bfc83f8 100644 --- a/apps/api/src/models/rampState.model.ts +++ b/apps/api/src/models/rampState.model.ts @@ -8,8 +8,8 @@ import { RampPhase, UnsignedTx } from "@vortexfi/shared"; -import { DataTypes, Model, Optional } from "sequelize"; -import { StateMetadata } from "../api/services/phases/meta-state-types"; +import {DataTypes, Model, Optional} from "sequelize"; +import {StateMetadata} from "../api/services/phases/meta-state-types"; import sequelize from "../config/database"; export interface PhaseHistoryEntry { @@ -39,6 +39,7 @@ type PostCompleteState = { // Define the attributes of the RampState model export interface RampStateAttributes { id: string; // UUID + userId: string | null; // UUID reference to Supabase Auth user type: RampDirection; currentPhase: RampPhase; unsignedTxs: UnsignedTx[]; // JSONB array @@ -63,6 +64,8 @@ export type RampStateCreationAttributes = Optional implements RampStateAttributes { declare id: string; + declare userId: string | null; + declare type: RampDirection; declare currentPhase: RampPhase; @@ -203,6 +206,17 @@ RampState.init( defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE + }, + userId: { + allowNull: true, + field: "user_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID } }, { diff --git a/apps/api/src/models/taxId.model.ts b/apps/api/src/models/taxId.model.ts index 8dbcf264a..0735710e8 100644 --- a/apps/api/src/models/taxId.model.ts +++ b/apps/api/src/models/taxId.model.ts @@ -12,6 +12,7 @@ export enum TaxIdInternalStatus { // Define the attributes of the TaxId model export interface TaxIdAttributes { taxId: string; + userId: string | null; // UUID reference to Supabase Auth user subAccountId: string; accountType: AveniaAccountType; kycAttempt: string | null; @@ -44,6 +45,7 @@ type TaxIdCreationAttributes = Optional< // Define the TaxId model class TaxId extends Model implements TaxIdAttributes { declare taxId: string; + declare userId: string | null; declare subAccountId: string; declare accountType: AveniaAccountType; declare kycAttempt: string | null; @@ -127,6 +129,17 @@ TaxId.init( defaultValue: DataTypes.NOW, field: "updated_at", type: DataTypes.DATE + }, + userId: { + allowNull: true, + field: "user_id", + onDelete: "CASCADE", + onUpdate: "CASCADE", + references: { + key: "id", + model: "profiles" + }, + type: DataTypes.UUID } }, { diff --git a/apps/api/src/models/user.model.ts b/apps/api/src/models/user.model.ts new file mode 100644 index 000000000..92478cc2f --- /dev/null +++ b/apps/api/src/models/user.model.ts @@ -0,0 +1,61 @@ +import {DataTypes, Model, Optional} from "sequelize"; +import sequelize from "../config/database"; + +export interface UserAttributes { + id: string; // UUID from Supabase Auth + email: string; + createdAt: Date; + updatedAt: Date; +} + +type UserCreationAttributes = Optional; + +class User extends Model implements UserAttributes { + declare id: string; + declare email: string; + declare createdAt: Date; + declare updatedAt: Date; +} + +User.init( + { + createdAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "created_at", + type: DataTypes.DATE + }, + email: { + allowNull: false, + type: DataTypes.STRING(255), + unique: true + }, + id: { + allowNull: false, + comment: "User ID from Supabase Auth (synced)", + primaryKey: true, + type: DataTypes.UUID + }, + updatedAt: { + allowNull: false, + defaultValue: DataTypes.NOW, + field: "updated_at", + type: DataTypes.DATE + } + }, + { + indexes: [ + { + fields: ["email"], + name: "idx_profiles_email", + unique: true + } + ], + modelName: "User", + sequelize, + tableName: "profiles", + timestamps: true + } +); + +export default User; diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example new file mode 100644 index 000000000..37e5fe2b7 --- /dev/null +++ b/apps/frontend/.env.example @@ -0,0 +1,3 @@ +# Supabase Configuration +VITE_SUPABASE_URL=https://your-project-id.supabase.co +VITE_SUPABASE_ANON_KEY=your-anon-key-here diff --git a/apps/frontend/App.css b/apps/frontend/App.css index 88467f798..32a2e9c11 100644 --- a/apps/frontend/App.css +++ b/apps/frontend/App.css @@ -1,7 +1,11 @@ @import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); @plugin "daisyui"; :root { + /* DaisyUI theme colors */ --color-primary: oklch(0.45 0.2 260); --color-primary-content: oklch(1 0 0); --color-secondary: oklch(0.97 0.003 260); @@ -15,14 +19,20 @@ --color-base-300: oklch(0.92 0.008 250); --color-base-content: oklch(0.5 0.04 250); + /* Project-specific variables */ --radius-field: 9px; - --border: 1px; - --text: oklch(0.15 0 0); --bg-modal: oklch(1 0 0); --modal-border: oklch(0.91 0 0); --rounded-btn: 9px; --btn-text-case: none; + + /* Base radius for components */ + --radius: 0.625rem; + + /* QuoteSummary layout */ + --quote-summary-height: 88px; + --widget-min-height: 506px; } @layer base { @@ -117,6 +127,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 { @@ -134,6 +149,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 { @@ -149,6 +169,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 { @@ -175,6 +200,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 { @@ -191,6 +221,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 { @@ -214,6 +249,10 @@ } @layer utilities { + .bottom-above-quote { + bottom: calc(var(--quote-summary-height) + 1rem); + } + .shadow-custom { box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); } @@ -331,3 +370,26 @@ transform: translateY(0) scale(1); } } + +@keyframes caret-blink { + 0%, + 70%, + 100% { + opacity: 1; + } + 20%, + 50% { + opacity: 0; + } +} + +.animate-caret-blink { + animation: caret-blink 1.2s ease-out infinite; +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} diff --git a/apps/frontend/components.json b/apps/frontend/components.json new file mode 100644 index 000000000..f760dc41b --- /dev/null +++ b/apps/frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "aliases": { + "components": "@/components", + "hooks": "@/hooks", + "lib": "@/lib", + "ui": "@/components/ui", + "utils": "@/helpers" + }, + "iconLibrary": "lucide", + "rsc": false, + "style": "new-york", + "tailwind": { + "baseColor": "neutral", + "config": "", + "css": "App.css", + "cssVariables": true, + "prefix": "" + }, + "tsx": true +} diff --git a/apps/frontend/package.json b/apps/frontend/package.json index fb638b03d..ddf8b2926 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -25,6 +25,7 @@ "@sentry/react": "^8.36.0", "@sentry/vite-plugin": "^2.22.6", "@storybook/react": "catalog:", + "@supabase/supabase-js": "catalog:", "@tailwindcss/vite": "^4.0.3", "@talismn/connect-components": "^1.1.9", "@talismn/connect-wallets": "^1.2.8", @@ -46,11 +47,16 @@ "big.js": "catalog:", "bn.js": "^5.2.1", "buffer": "^6.0.3", - "clsx": "catalog:", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "crypto-js": "^4.2.0", "i18next": "^24.2.3", + "input-otp": "^1.4.2", "lottie-react": "^2.4.1", + "lucide-react": "^0.562.0", "motion": "^12.0.3", + "numora": "^3.0.2", + "numora-react": "3.0.3", "qrcode.react": "^4.2.0", "react": "=19.2.0", "react-dom": "=19.2.0", @@ -58,7 +64,7 @@ "react-i18next": "^15.4.1", "react-toastify": "^11.0.5", "stellar-sdk": "catalog:", - "tailwind-merge": "^3.1.0", + "tailwind-merge": "^3.4.0", "tailwindcss": "^4.0.3", "viem": "catalog:", "wagmi": "catalog:", @@ -100,6 +106,7 @@ "prettier": "catalog:", "storybook": "^9.1.4", "ts-node": "^10.9.1", + "tw-animate-css": "^1.4.0", "typescript": "catalog:", "vite": "^6.2.6", "vite-plugin-node-polyfills": "^0.23.0", diff --git a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx index 779aba973..4eaedbb91 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYBFlow/AveniaKYBVerifyStep.tsx @@ -1,7 +1,7 @@ import { ArrowTopRightOnSquareIcon } from "@heroicons/react/24/solid"; import { Trans, useTranslation } from "react-i18next"; import { useQuote } from "../../../stores/quote/useQuoteStore"; -import { DetailsStepQuoteSummary } from "../../widget-steps/DetailsStep/DetailsStepQuoteSummary"; +import { StepFooter } from "../../StepFooter"; interface AveniaKYBVerifyStepProps { titleKey: string; @@ -32,50 +32,54 @@ export const AveniaKYBVerifyStep = ({ const { t } = useTranslation(); return ( - <> -
-
-

{t(titleKey)}

+
+
+
+
+

{t(titleKey)}

- Business Check + Business Check - {!isVerificationStarted && ( -

- - Please provide our trusted partner - - Avenia - - with your company registration information and the required documents. - -

- )} + {!isVerificationStarted && ( +

+ + Please provide our trusted partner + + Avenia + + with your company registration information and the required documents. + +

+ )} - {isVerificationStarted && ( -
- - here - - ) - }} - i18nKey="components.aveniaKYB.tryAgain" - /> -
- )} + {isVerificationStarted && ( +
+ + here + + ) + }} + i18nKey="components.aveniaKYB.tryAgain" + /> +
+ )} +
+
+
-
- - + +
); }; diff --git a/apps/frontend/src/components/Avenia/AveniaKYBForm.tsx b/apps/frontend/src/components/Avenia/AveniaKYBForm.tsx index 723f29de6..f5515fefb 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYBForm.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYBForm.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { useAveniaKycActor, useAveniaKycSelector } from "../../contexts/rampState"; import { useKYCForm } from "../../hooks/brla/useKYCForm"; import { useQuote } from "../../stores/quote/useQuoteStore"; -import { DetailsStepQuoteSummary } from "../widget-steps/DetailsStep/DetailsStepQuoteSummary"; +import { QuoteSummary } from "../QuoteSummary"; import { AveniaFieldProps, ExtendedAveniaFieldOptions } from "./AveniaField"; import { AveniaVerificationForm } from "./AveniaVerificationForm"; @@ -18,10 +18,6 @@ export const AveniaKYBForm = () => { const { t } = useTranslation(); - console.log( - "AveniaKYBForm: kycFormData from aveniaState context before passing to useKYCForm:", - aveniaState?.context.kycFormData - ); const { kycForm } = useKYCForm({ cpfApiError: null, initialData: aveniaState?.context.kycFormData }); useEffect(() => { @@ -57,9 +53,11 @@ export const AveniaKYBForm = () => { ]; return ( - <> - - - +
+
+ +
+ {quote && } +
); }; diff --git a/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx b/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx index faeec3a1c..2e23100c6 100644 --- a/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx +++ b/apps/frontend/src/components/Avenia/AveniaKYCForm.tsx @@ -3,9 +3,9 @@ import { useTranslation } from "react-i18next"; import { useAveniaKycActor, useAveniaKycSelector } from "../../contexts/rampState"; import { useKYCForm } from "../../hooks/brla/useKYCForm"; import { useQuote } from "../../stores/quote/useQuoteStore"; +import { QuoteSummary } from "../QuoteSummary"; import { StepBackButton } from "../StepBackButton"; import { AveniaLivenessStep } from "../widget-steps/AveniaLivenessStep"; -import { DetailsStepQuoteSummary } from "../widget-steps/DetailsStep/DetailsStepQuoteSummary"; import { AveniaFieldProps, ExtendedAveniaFieldOptions } from "./AveniaField"; import { AveniaVerificationForm } from "./AveniaVerificationForm"; import { DocumentUpload } from "./DocumentUpload"; @@ -17,10 +17,6 @@ export const AveniaKYCForm = () => { const quote = useQuote(); const { t } = useTranslation(); - console.log( - "AveniaKYCForm: kycFormData from aveniaState context before passing to useKYCForm:", - aveniaState?.context.kycFormData - ); const { kycForm } = useKYCForm({ cpfApiError: null, initialData: aveniaState?.context.kycFormData }); if (!aveniaState) return null; @@ -149,14 +145,16 @@ export const AveniaKYCForm = () => { } return ( - <> -
-
- +
+
+
+
+ +
+ {content}
- {content}
- - + {quote && } +
); }; diff --git a/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx b/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx index 539870385..1df0d9896 100644 --- a/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx +++ b/apps/frontend/src/components/Avenia/AveniaVerificationForm/index.tsx @@ -5,6 +5,8 @@ import { Trans, useTranslation } from "react-i18next"; import { KYCFormData } from "../../../hooks/brla/useKYCForm"; import { useMaintenanceAwareButton } from "../../../hooks/useMaintenanceAware"; import { AveniaKycActorRef } from "../../../machines/types"; + +import { StepFooter } from "../../StepFooter"; import { AveniaField, AveniaFieldProps, ExtendedAveniaFieldOptions } from "../AveniaField"; interface AveniaVerificationFormProps { @@ -31,12 +33,12 @@ export const AveniaVerificationForm = ({ form, fields, aveniaKycActor, isCompany -
+

{isCompany ? t("components.aveniaKYB.title.default") : t("components.aveniaKYC.title")}

@@ -59,7 +61,7 @@ export const AveniaVerificationForm = ({ form, fields, aveniaKycActor, isCompany ))}
{!isCompany && ( -
+
@@ -75,16 +77,12 @@ export const AveniaVerificationForm = ({ form, fields, aveniaKycActor, isCompany
)}
-
+ -
+ ); diff --git a/apps/frontend/src/components/Avenia/DocumentUpload/index.tsx b/apps/frontend/src/components/Avenia/DocumentUpload/index.tsx index e961280a3..c21f1a762 100644 --- a/apps/frontend/src/components/Avenia/DocumentUpload/index.tsx +++ b/apps/frontend/src/components/Avenia/DocumentUpload/index.tsx @@ -1,12 +1,15 @@ -import { CameraIcon, CheckCircleIcon, DocumentTextIcon } from "@heroicons/react/24/outline"; +import { DocumentTextIcon } from "@heroicons/react/24/outline"; +import { CheckCircleIcon } from "@heroicons/react/24/solid"; import { AveniaDocumentType } from "@vortexfi/shared"; -import { motion } from "motion/react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; +import { durations, easings } from "../../../constants/animations"; import { useMaintenanceAwareButton } from "../../../hooks/useMaintenanceAware"; import { AveniaKycActorRef } from "../../../machines/types"; import { BrlaService } from "../../../services/api"; import { KycLevel2Toggle } from "../../KycLevel2Toggle"; +import { StepFooter } from "../../StepFooter"; const MAX_FILE_SIZE = 15 * 1024 * 1024; // 15 MB const ALLOWED_TYPES = ["image/png", "image/jpeg", "application/pdf"]; @@ -36,6 +39,7 @@ async function uploadFileAsBuffer(file: File, url: string) { export const DocumentUpload: React.FC = ({ aveniaKycActor, taxId }) => { const { t } = useTranslation(); const { buttonProps, isMaintenanceDisabled } = useMaintenanceAwareButton(); + const shouldReduceMotion = useReducedMotion(); const [docType, setDocType] = useState(AveniaDocumentType.DRIVERS_LICENSE); @@ -114,12 +118,6 @@ export const DocumentUpload: React.FC = ({ aveniaKycActor, console.error("Validation flags were true, but file data is missing. This is a bug."); return; } - console.log( - " urls: ", - response.idUpload.uploadURLFront, - response.idUpload.uploadURLBack, - response.selfieUpload.uploadURLFront - ); uploads.push( uploadFileAsBuffer(front, response.idUpload.uploadURLFront), @@ -131,12 +129,6 @@ export const DocumentUpload: React.FC = ({ aveniaKycActor, console.error("Validation flags were true, but file data is missing. This is a bug."); return; } - console.log( - " urls: ", - response.idUpload.uploadURLFront, - response.idUpload.uploadURLBack, - response.selfieUpload.uploadURLFront - ); // TODO how do we stop the flow until avenia liveness is done? uploads.push(uploadFileAsBuffer(front, response.idUpload.uploadURLFront)); } @@ -169,60 +161,108 @@ export const DocumentUpload: React.FC = ({ aveniaKycActor, {label} {fileName || t("components.documentUpload.helperText")} - {valid && } + + {valid && ( + + + + )} + ); + const fieldTransition = shouldReduceMotion ? { duration: 0 } : { duration: durations.normal, ease: easings.easeOutCubic }; + return ( - { + e.preventDefault(); + handleSubmit(); + }} + transition={shouldReduceMotion ? { duration: 0 } : { duration: durations.slow, ease: easings.easeOutCubic }} > -

{t("components.documentUpload.title")}

-

{t("components.documentUpload.description")}

- - - -
- {docType === AveniaDocumentType.ID && ( - <> - {renderField( - t("components.documentUpload.fields.rgFront"), - e => handleFileChange(e, setFront, setFrontValid), - frontValid, - DocumentTextIcon, - front?.name - )} - {renderField( - t("components.documentUpload.fields.rgBack"), - e => handleFileChange(e, setBack, setBackValid), - backValid, - DocumentTextIcon, - back?.name +
+

{t("components.documentUpload.title")}

+

{t("components.documentUpload.description")}

+ + + +
+ + {docType === AveniaDocumentType.ID ? ( + + {renderField( + t("components.documentUpload.fields.rgFront"), + e => handleFileChange(e, setFront, setFrontValid), + frontValid, + DocumentTextIcon, + front?.name + )} + {renderField( + t("components.documentUpload.fields.rgBack"), + e => handleFileChange(e, setBack, setBackValid), + backValid, + DocumentTextIcon, + back?.name + )} + + ) : ( + + {renderField( + t("components.documentUpload.fields.cnhDocument"), + e => handleFileChange(e, setFront, setFrontValid), + frontValid, + DocumentTextIcon, + front?.name + )} + )} - - )} - {docType === AveniaDocumentType.DRIVERS_LICENSE && - renderField( - t("components.documentUpload.fields.cnhDocument"), - e => handleFileChange(e, setFront, setFrontValid), - frontValid, - DocumentTextIcon, - front?.name + +
+ + + {error && ( + + {error} + )} +
- {error &&

{error}

} - -
+ -
- + + ); }; diff --git a/apps/frontend/src/components/Avenia/VerificationStatus/index.tsx b/apps/frontend/src/components/Avenia/VerificationStatus/index.tsx index 98b8c9f86..0e5b49f1d 100644 --- a/apps/frontend/src/components/Avenia/VerificationStatus/index.tsx +++ b/apps/frontend/src/components/Avenia/VerificationStatus/index.tsx @@ -1,7 +1,8 @@ -import { motion } from "motion/react"; +import { motion, useReducedMotion } from "motion/react"; import React from "react"; import { useTranslation } from "react-i18next"; import { AveniaKycActorRef, SelectedAveniaData } from "../../../machines/types"; + import { KycStatus } from "../../../services/signingService"; import { Spinner } from "../../Spinner"; @@ -16,7 +17,7 @@ export const VerificationStatus: React.FC = ({ aveniaKy return ( @@ -89,50 +90,60 @@ export const VerificationStatus: React.FC = ({ aveniaKy ); }; -const SuccessIcon = () => ( - - - -); +const SuccessIcon = () => { + const shouldReduceMotion = useReducedMotion(); + const { t } = useTranslation(); + + return ( + + + + ); +}; -const ErrorIcon = () => ( - - - -); +const ErrorIcon = () => { + const shouldReduceMotion = useReducedMotion(); + const { t } = useTranslation(); + + return ( + + + + ); +}; diff --git a/apps/frontend/src/components/CallToActionSection/index.tsx b/apps/frontend/src/components/CallToActionSection/index.tsx index 6f9472cfa..613cb7106 100644 --- a/apps/frontend/src/components/CallToActionSection/index.tsx +++ b/apps/frontend/src/components/CallToActionSection/index.tsx @@ -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"; @@ -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 = ( + <> + {buttonText} +
diff --git a/apps/frontend/src/components/CollapsibleCard/index.tsx b/apps/frontend/src/components/CollapsibleCard/index.tsx index 746c12b55..87bfba9eb 100644 --- a/apps/frontend/src/components/CollapsibleCard/index.tsx +++ b/apps/frontend/src/components/CollapsibleCard/index.tsx @@ -1,6 +1,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { createContext, forwardRef, ReactNode, useContext, useId, useState } from "react"; +import { createContext, forwardRef, ReactNode, useCallback, useContext, useId, useState } from "react"; import { durations, easings } from "../../constants/animations"; +import { cn } from "../../helpers/cn"; interface CollapsibleCardProps { children: ReactNode; @@ -40,16 +41,18 @@ const CollapsibleCard = forwardRef( const [isExpanded, setIsExpanded] = useState(defaultExpanded); const detailsId = useId(); - const toggle = () => { - const newState = !isExpanded; - setIsExpanded(newState); - onToggle?.(newState); - }; + const toggle = useCallback(() => { + setIsExpanded(prev => { + const newState = !prev; + onToggle?.(newState); + return newState; + }); + }, [onToggle]); return (
{children} @@ -71,19 +74,23 @@ const CollapsibleDetails = ({ children, className = "" }: CollapsibleDetailsProp return (
{isExpanded && ( {children} diff --git a/apps/frontend/src/components/ContactForm/ContactInfo.tsx b/apps/frontend/src/components/ContactForm/ContactInfo.tsx new file mode 100644 index 000000000..807af413b --- /dev/null +++ b/apps/frontend/src/components/ContactForm/ContactInfo.tsx @@ -0,0 +1,59 @@ +import { ChevronRightIcon } from "@heroicons/react/24/outline"; +import { useTranslation } from "react-i18next"; + +export function ContactInfo() { + const { t } = useTranslation(); + + return ( +
+

+ {t("pages.contact.info.title")} +

+ +
    +
  • + + {t("pages.contact.info.requestDemo")} +
  • +
  • + + {t("pages.contact.info.onboardingHelp")} +
  • +
  • + + {t("pages.contact.info.integrationHelp")} +
  • +
+ +
+

{t("pages.contact.info.technicalQuestions")}

+ + {t("pages.contact.info.supportLink")} + +
+
+ ); +} + +function CheckIcon() { + return ( + + ); +} diff --git a/apps/frontend/src/components/ContactForm/index.tsx b/apps/frontend/src/components/ContactForm/index.tsx new file mode 100644 index 000000000..7dfbcd5d8 --- /dev/null +++ b/apps/frontend/src/components/ContactForm/index.tsx @@ -0,0 +1,271 @@ +import { useMutation } from "@tanstack/react-query"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { cn } from "../../helpers/cn"; +import { useContactForm } from "../../hooks/useContactForm"; +import { submitContactForm } from "../../services/api/contact.service"; +import { Field } from "../Field"; +import { HoldButton } from "../HoldButton"; +import { TextArea } from "../TextArea"; + +type ButtonState = "idle" | "loading" | "success" | "error"; + +export function ContactForm() { + const { t, i18n } = useTranslation(); + const { form } = useContactForm(); + const formRef = useRef(null); + const shouldReduceMotion = useReducedMotion(); + + const { + register, + handleSubmit, + formState: { isSubmitting, isValid, errors, touchedFields }, + reset + } = form; + + const [buttonState, setButtonState] = useState("idle"); + + const { mutate, isPending } = useMutation({ + mutationFn: submitContactForm, + onError: () => { + setButtonState("error"); + }, + onSuccess: () => { + reset(); + setButtonState("success"); + } + }); + + useEffect(() => { + if (buttonState === "success" || buttonState === "error") { + const timeout = setTimeout(() => setButtonState("idle"), 3000); + return () => clearTimeout(timeout); + } + }, [buttonState]); + + useEffect(() => { + if (isPending || isSubmitting) { + setButtonState("loading"); + } + }, [isPending, isSubmitting]); + + const onSubmit = handleSubmit(data => { + mutate({ + email: data.email, + fullName: data.fullName, + inquiry: data.inquiry, + projectName: data.projectName, + timestamp: new Date().toISOString() + }); + }); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + if (isValid && !isPending && !isSubmitting) { + onSubmit(); + } + } + }, + [isValid, isPending, isSubmitting, onSubmit] + ); + + const loading = isPending || isSubmitting; + const formId = useId(); + + const buttonCopy: Record = { + error: t("pages.contact.error"), + idle: t("pages.contact.form.holdToSubmit"), + loading: "Sending…", + success: t("pages.contact.success") + }; + + const getButtonClassName = () => { + if (buttonState === "success") return "bg-green-500 text-white"; + if (buttonState === "error") return "bg-red-300 text-red-800"; + if (loading) return "bg-primary text-white"; + return ""; + }; + + return ( +
e.preventDefault()} ref={formRef}> + + + + + + + + + + + + + +