From b7afb147d366cca53b98ef21e27330366d99e8d8 Mon Sep 17 00:00:00 2001 From: skuhlmann Date: Thu, 14 May 2026 10:06:56 -0600 Subject: [PATCH 1/9] bandaid on the client submission form --- bun.lock | 38 ++++- env.sample | 7 +- package.json | 2 +- src/app/api/applications/route.ts | 84 ++------- src/app/api/consultations/route.ts | 262 ++++++++++++++++++++++++++--- src/lib/gql-client.ts | 35 ---- src/lib/queries.ts | 25 --- src/lib/validation.ts | 29 ---- 8 files changed, 288 insertions(+), 194 deletions(-) delete mode 100644 src/lib/gql-client.ts delete mode 100644 src/lib/queries.ts diff --git a/bun.lock b/bun.lock index 537a919..27121b1 100644 --- a/bun.lock +++ b/bun.lock @@ -25,12 +25,12 @@ "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-toast": "^1.2.14", "@radix-ui/react-tooltip": "^1.2.8", + "@sendgrid/mail": "^8.1.6", "@tanstack/react-query": "^5.85.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "fathom-client": "^3.7.2", - "graphql-request": "^7.3.2", "lucide-react": "^0.545.0", "next": "15.5.7", "next-themes": "^0.4.6", @@ -89,8 +89,6 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], - "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -285,6 +283,12 @@ "@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.14.0", "", {}, "sha512-WJFej426qe4RWOm9MMtP4V3CV4AucXolQty+GRgAWLgQXmpCuwzs7hEpxxhSc/znXUSxum9d/P/32MW0FlAAlA=="], + "@sendgrid/client": ["@sendgrid/client@8.1.6", "", { "dependencies": { "@sendgrid/helpers": "^8.0.0", "axios": "^1.12.0" } }, "sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA=="], + + "@sendgrid/helpers": ["@sendgrid/helpers@8.0.0", "", { "dependencies": { "deepmerge": "^4.2.2" } }, "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA=="], + + "@sendgrid/mail": ["@sendgrid/mail@8.1.6", "", { "dependencies": { "@sendgrid/client": "^8.1.5", "@sendgrid/helpers": "^8.0.0" } }, "sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg=="], + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], @@ -399,6 +403,8 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -429,10 +435,14 @@ "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "axe-core": ["axe-core@4.11.0", "", {}, "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ=="], + "axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="], + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -467,6 +477,8 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -485,10 +497,14 @@ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], @@ -575,8 +591,12 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], @@ -607,10 +627,6 @@ "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], - "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], - - "graphql-request": ["graphql-request@7.3.2", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.2.0" }, "peerDependencies": { "graphql": "14 - 16" } }, "sha512-ZnONGa8imXXPOImZoSTrV4qtrCAOv/ulXMAMmFzttObjdILDmNwDEcnloZKOncG+ucWN1JKyjMG7lAzuYDZmlg=="], - "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -625,6 +641,8 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -753,6 +771,10 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -817,6 +839,8 @@ "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], diff --git a/env.sample b/env.sample index 40b2873..1507988 100644 --- a/env.sample +++ b/env.sample @@ -1,4 +1,7 @@ -HASURA_GRAPHQL_ADMIN_SECRET= -NEXT_PUBLIC_API_URL= EMAIL_REFERRALS_API_URL= FORM_INGEST_API_KEY= +DISCORD_BOT_TOKEN= +DISCORD_CONSULTATION_CHANNEL_ID= +SENDGRID_API_KEY= +SENDGRID_FROM_EMAIL= +SENDGRID_TO_EMAILS= diff --git a/package.json b/package.json index c3ec4f3..209c933 100644 --- a/package.json +++ b/package.json @@ -29,12 +29,12 @@ "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-toast": "^1.2.14", "@radix-ui/react-tooltip": "^1.2.8", + "@sendgrid/mail": "^8.1.6", "@tanstack/react-query": "^5.85.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "fathom-client": "^3.7.2", - "graphql-request": "^7.3.2", "lucide-react": "^0.545.0", "next": "15.5.7", "next-themes": "^0.4.6", diff --git a/src/app/api/applications/route.ts b/src/app/api/applications/route.ts index eced3d2..1d4c152 100644 --- a/src/app/api/applications/route.ts +++ b/src/app/api/applications/route.ts @@ -1,75 +1,11 @@ -import { NextRequest, NextResponse } from "next/server"; -import { APPLICATION_CREATE_MUTATION } from "@/lib/queries"; -import client from "@/lib/gql-client"; -import { applicationApiSchema } from "@/lib/validation"; -import { z } from "zod"; - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - - // Validate the request body structure - const validationResult = applicationApiSchema.safeParse(body); - - if (!validationResult.success) { - const errors = validationResult.error.issues.map((issue: z.ZodIssue) => ({ - field: issue.path.join("."), - message: issue.message, - })); - - return NextResponse.json( - { - success: false, - error: "Validation failed", - details: errors, - }, - { status: 400 } - ); - } - - const { applicationData } = validationResult.data; - - console.log("applicationData", applicationData); - - // Get the token from the request headers or body - const token = - request.headers.get("authorization")?.replace("Bearer ", "") || - body.token; - - // if (!token) { - // return NextResponse.json( - // { error: "Authentication token required" }, - // { status: 401 } - // ); - // } - - // Create GraphQL client with token - const gqlClient = client({ token }); - - // Execute the mutation - const result = await gqlClient.request(APPLICATION_CREATE_MUTATION, { - application: applicationData, - }); - - return NextResponse.json( - { - success: true, - data: result, - message: "Application submitted successfully", - }, - { status: 200 } - ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error: any) { - console.error("Error submitting Application:", error); - - return NextResponse.json( - { - success: false, - error: error.message || "Failed to submit Application", - details: error.response?.errors || error, - }, - { status: 500 } - ); - } +import { NextResponse } from "next/server"; + +export async function POST() { + return NextResponse.json( + { + success: false, + error: "Application intake has been deprecated", + }, + { status: 410 } + ); } diff --git a/src/app/api/consultations/route.ts b/src/app/api/consultations/route.ts index ff5b08f..7ae2d0d 100644 --- a/src/app/api/consultations/route.ts +++ b/src/app/api/consultations/route.ts @@ -1,9 +1,207 @@ import { NextRequest, NextResponse } from "next/server"; -import { CONSULTATIONS_CREATE_MUTATION } from "@/lib/queries"; -import client from "@/lib/gql-client"; -import { consultationApiSchema } from "@/lib/validation"; +import sgMail from "@sendgrid/mail"; +import { + consultationApiSchema, + type ConsultationApiData, +} from "@/lib/validation"; import { z } from "zod"; +export const runtime = "nodejs"; + +type ConsultationData = ConsultationApiData["consultData"]; + +type ConsultationSummary = { + contactName: string; + email: string; + bio: string; + discord?: string; + telegram?: string; + projectName: string; + description: string; + specsLink?: string; + specsKey: string; + budget: string; + timeline: string; + priority: string; + services: string[]; +}; + +const DISCORD_MESSAGE_LIMIT = 2000; + +const getEnv = (key: string) => { + const value = process.env[key]; + + if (!value) { + throw new Error(`${key} is not configured`); + } + + return value; +}; + +const truncate = (value: string, maxLength: number) => { + if (value.length <= maxLength) return value; + + return `${value.slice(0, maxLength - 3)}...`; +}; + +const escapeHtml = (value: string) => + value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + +const stripTimelinePrefix = (value: string) => + value.replace(/^Timeline:\s*/i, "").replace(/\.$/, ""); + +const createSummary = (consultData: ConsultationData): ConsultationSummary => { + const contact = consultData.consultations_contacts.data.contact.data; + const contactInfo = contact.contact_info.data; + + return { + contactName: contact.name, + email: contactInfo.email, + bio: contact.bio, + discord: contactInfo.discord || undefined, + telegram: contactInfo.telegram || undefined, + projectName: consultData.name, + description: consultData.description, + specsLink: consultData.link || undefined, + specsKey: consultData.specs_key, + budget: consultData.budget_key, + timeline: stripTimelinePrefix(consultData.additional_info), + priority: consultData.delivery_priorities_key, + services: consultData.consultations_services_required.data.map( + (service) => service.guild_service_key + ), + }; +}; + +const formatDiscordMessage = (summary: ConsultationSummary) => { + const altContact = [ + summary.discord ? `Discord: ${summary.discord}` : null, + summary.telegram ? `Telegram: ${summary.telegram}` : null, + ] + .filter(Boolean) + .join("\n"); + + const sections = [ + "**New consultation request**", + `**Project:** ${summary.projectName}`, + `**Contact:** ${summary.contactName}`, + `**Email:** ${summary.email}`, + altContact, + `**Budget:** ${summary.budget}`, + `**Timeline:** ${summary.timeline}`, + `**Priority:** ${summary.priority}`, + `**Services:** ${summary.services.join(", ")}`, + `**Specs:** ${summary.specsLink || summary.specsKey}`, + `**Bio:**\n${summary.bio}`, + `**Description:**\n${summary.description}`, + ].filter(Boolean); + + return truncate(sections.join("\n\n"), DISCORD_MESSAGE_LIMIT); +}; + +const formatEmail = (summary: ConsultationSummary) => { + const fields = [ + ["Project", summary.projectName], + ["Contact", summary.contactName], + ["Email", summary.email], + ["Discord", summary.discord], + ["Telegram", summary.telegram], + ["Budget", summary.budget], + ["Timeline", summary.timeline], + ["Priority", summary.priority], + ["Services", summary.services.join(", ")], + ["Specs", summary.specsLink || summary.specsKey], + ].filter(([, value]) => value); + + const text = [ + "New consultation request", + "", + ...fields.map(([label, value]) => `${label}: ${value}`), + "", + "Bio:", + summary.bio, + "", + "Description:", + summary.description, + ].join("\n"); + + const htmlFields = fields + .map( + ([label, value]) => + `

${escapeHtml(label || "")}: ${escapeHtml( + value || "" + )}

` + ) + .join(""); + + const html = ` +

New consultation request

+ ${htmlFields} +

Bio

+

${escapeHtml(summary.bio).replaceAll("\n", "
")}

+

Description

+

${escapeHtml(summary.description).replaceAll("\n", "
")}

+ `; + + return { text, html }; +}; + +const sendDiscordMessage = async (content: string) => { + const botToken = getEnv("DISCORD_BOT_TOKEN"); + const channelId = getEnv("DISCORD_CONSULTATION_CHANNEL_ID"); + + const response = await fetch( + `https://discord.com/api/v10/channels/${channelId}/messages`, + { + method: "POST", + headers: { + Authorization: `Bot ${botToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content, + allowed_mentions: { parse: [] }, + }), + } + ); + + if (!response.ok) { + const details = await response.text(); + throw new Error(`Discord notification failed: ${details}`); + } +}; + +const sendConsultationEmail = async (summary: ConsultationSummary) => { + const apiKey = getEnv("SENDGRID_API_KEY"); + const from = getEnv("SENDGRID_FROM_EMAIL"); + const recipients = getEnv("SENDGRID_TO_EMAILS") + .split(",") + .map((email) => email.trim()) + .filter(Boolean); + + if (recipients.length === 0) { + throw new Error("SENDGRID_TO_EMAILS must include at least one recipient"); + } + + sgMail.setApiKey(apiKey); + + const { text, html } = formatEmail(summary); + + await sgMail.send({ + to: recipients, + from, + replyTo: summary.email, + subject: `New consultation request: ${summary.projectName}`, + text, + html, + }); +}; + export async function POST(request: NextRequest) { try { const body = await request.json(); @@ -28,31 +226,53 @@ export async function POST(request: NextRequest) { } const { consultData } = validationResult.data; + const summary = createSummary(consultData); + const discordMessage = formatDiscordMessage(summary); - // Get the token from the request headers or body - const token = - request.headers.get("authorization")?.replace("Bearer ", "") || - body.token; + const notificationResults = await Promise.allSettled([ + sendDiscordMessage(discordMessage), + sendConsultationEmail(summary), + ]); - // if (!token) { - // return NextResponse.json( - // { error: "Authentication token required" }, - // { status: 401 } - // ); - // } + const failedNotifications = notificationResults + .map((result, index) => ({ + result, + service: index === 0 ? "discord" : "sendgrid", + })) + .filter(({ result }) => result.status === "rejected"); - // Create GraphQL client with token - const gqlClient = client({ token }); + if (failedNotifications.length > 0) { + console.error( + "Consultation notification failures:", + failedNotifications.map(({ result, service }) => ({ + service, + reason: + result.status === "rejected" && result.reason instanceof Error + ? result.reason.message + : result, + })) + ); - // Execute the mutation - const result = await gqlClient.request(CONSULTATIONS_CREATE_MUTATION, { - consultations: consultData, - }); + return NextResponse.json( + { + success: false, + error: "Failed to submit consultation", + details: failedNotifications.map(({ service }) => ({ + service, + message: "Notification failed", + })), + }, + { status: 500 } + ); + } return NextResponse.json( { success: true, - data: result, + data: { + discord: true, + email: true, + }, message: "Consultation submitted successfully", }, { status: 200 } @@ -65,7 +285,7 @@ export async function POST(request: NextRequest) { { success: false, error: error.message || "Failed to submit consultation", - details: error.response?.errors || error, + details: error.response?.errors || undefined, }, { status: 500 } ); diff --git a/src/lib/gql-client.ts b/src/lib/gql-client.ts deleted file mode 100644 index d33f1eb..0000000 --- a/src/lib/gql-client.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { GraphQLClient } from 'graphql-request'; - -const API_URL = process.env.NEXT_PUBLIC_API_URL || ''; -const HASURA_ADMIN_SECRET = process.env.HASURA_GRAPHQL_ADMIN_SECRET; - -type ClientParams = { - token?: string; - userId?: string; -}; - -type Headers = { - authorization?: string; - 'x-hasura-user-id'?: string; - 'x-hasura-admin-secret'?: string; -}; - -const client = ({ token, userId }: ClientParams) => { - const headers: Headers = {}; - - if (token) { - headers.authorization = `Bearer ${token}`; - - // * Set matching session variables for Hasura where needed - if (userId) { - headers['x-hasura-user-id'] = userId; - } - } - if (HASURA_ADMIN_SECRET) { - headers['x-hasura-admin-secret'] = HASURA_ADMIN_SECRET; - } - - return new GraphQLClient(API_URL, { headers }); -}; - -export default client; diff --git a/src/lib/queries.ts b/src/lib/queries.ts deleted file mode 100644 index 21cd557..0000000 --- a/src/lib/queries.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { gql } from "graphql-request"; - -// @Dev: do not use 'insert_consultations_one' -// this query is not callable for non RaidGuild member/cohort wallets. -// Hasura doesn't let you use insert_models_one unless the role (user) has -// permissions to select ids at least. -// We'll want to use insert_models in any case like this. - -export const CONSULTATIONS_CREATE_MUTATION = gql` - mutation ConsultationsInsertMutation( - $consultations: consultations_insert_input! - ) { - insert_consultations(objects: [$consultations]) { - affected_rows - } - } -`; - -export const APPLICATION_CREATE_MUTATION = gql` - mutation ApplicationInsertMutation($application: applications_insert_input!) { - insert_applications(objects: [$application]) { - affected_rows - } - } -`; diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 8401ea1..3756958 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -92,39 +92,10 @@ export const joinUsFormSchema = z.object({ }), }); -// Server-side validation schema for the API -export const applicationApiSchema = z.object({ - applicationData: z.object({ - contact_info: z.object({ - data: z.object({ - email: z.email("Valid email is required"), - discord: z.string().optional(), - github: z.string().optional(), - }), - }), - name: z.string().min(3, "Project name must be at least 3 characters"), - introduction: z - .string() - .min(10, "Introduction must be at least 10 characters"), - comments: z - .string() - .min(10, "Showcase description must be at least 10 characters"), - links: z.object({ - data: z.array( - z.object({ - type: z.string(), - link: z.url("Valid url is required"), - }) - ), - }), - }), -}); - // Type exports for use in components export type HireUsFormData = z.infer; export type JoinUsFormData = z.infer; export type ConsultationApiData = z.infer; -export type ApplicationApiData = z.infer; // Helper function to transform form data to API format export const transformFormDataToApiFormat = (formData: HireUsFormData) => { From 477d3e8ad78cf5414dc627a481daedf5e6b0b19a Mon Sep 17 00:00:00 2001 From: ECWireless <40322776+ECWireless@users.noreply.github.com> Date: Thu, 28 May 2026 06:39:37 -0600 Subject: [PATCH 2/9] feat: add Vercel Analytics integration (#76) * feat: add Vercel Analytics integration * fix: harden server analytics tracking * fix: classify consultation submission errors --- bun.lock | 4 +- package.json | 1 + src/app/api/consultations/route.ts | 40 ++++++++++++++ src/app/layout.tsx | 2 + src/components/HireUs.tsx | 85 ++++++++++++++++++++++++++++-- src/components/JoinUs.tsx | 16 +++++- src/components/VercelAnalytics.tsx | 46 ++++++++++++++++ src/lib/analytics.ts | 31 +++++++++++ src/lib/server-analytics.ts | 43 +++++++++++++++ 9 files changed, 262 insertions(+), 6 deletions(-) create mode 100644 src/components/VercelAnalytics.tsx create mode 100644 src/lib/analytics.ts create mode 100644 src/lib/server-analytics.ts diff --git a/bun.lock b/bun.lock index 27121b1..15c81f9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "raidguild-website", @@ -27,6 +26,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@sendgrid/mail": "^8.1.6", "@tanstack/react-query": "^5.85.5", + "@vercel/analytics": "^2.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -399,6 +399,8 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + "@vercel/analytics": ["@vercel/analytics@2.0.1", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "nuxt": ">= 3", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "nuxt", "react", "svelte", "vue", "vue-router"] }, "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g=="], + "acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], diff --git a/package.json b/package.json index 209c933..105ef14 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@sendgrid/mail": "^8.1.6", "@tanstack/react-query": "^5.85.5", + "@vercel/analytics": "^2.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/src/app/api/consultations/route.ts b/src/app/api/consultations/route.ts index 7ae2d0d..4370e34 100644 --- a/src/app/api/consultations/route.ts +++ b/src/app/api/consultations/route.ts @@ -4,6 +4,10 @@ import { consultationApiSchema, type ConsultationApiData, } from "@/lib/validation"; +import { + serverAnalyticsEvents, + trackServerAnalyticsEvent, +} from "@/lib/server-analytics"; import { z } from "zod"; export const runtime = "nodejs"; @@ -215,6 +219,15 @@ export async function POST(request: NextRequest) { message: issue.message, })); + void trackServerAnalyticsEvent( + serverAnalyticsEvents.consultationSubmitFailed, + { + reason: "validation", + service: "api", + }, + request + ); + return NextResponse.json( { success: false, @@ -253,6 +266,15 @@ export async function POST(request: NextRequest) { })) ); + void trackServerAnalyticsEvent( + serverAnalyticsEvents.consultationSubmitFailed, + { + reason: "notification", + service: failedNotifications.map(({ service }) => service).join(","), + }, + request + ); + return NextResponse.json( { success: false, @@ -266,6 +288,15 @@ export async function POST(request: NextRequest) { ); } + void trackServerAnalyticsEvent( + serverAnalyticsEvents.consultationSubmitted, + { + budget: summary.budget, + servicesCount: summary.services.length, + }, + request + ); + return NextResponse.json( { success: true, @@ -281,6 +312,15 @@ export async function POST(request: NextRequest) { } catch (error: any) { console.error("Error submitting consultation:", error); + void trackServerAnalyticsEvent( + serverAnalyticsEvents.consultationSubmitFailed, + { + reason: "exception", + service: "api", + }, + request + ); + return NextResponse.json( { success: false, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 62867c6..769a722 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; // import { ThemeProvider } from "next-themes"; import { Providers } from "@/providers/providers"; import Fathom from "@/components/Fathom"; +import VercelAnalytics from "@/components/VercelAnalytics"; export const metadata: Metadata = { title: "Raid Guild", @@ -21,6 +22,7 @@ export default function RootLayout({ className={`${maziusDisplay.variable} ${ebGaramond.variable} ${ubuntuMono.variable} antialiased`} > + {/* >; @@ -346,6 +347,8 @@ export default function HireUs() { const [validationErrors, setValidationErrors] = useState< Array<{ field: string; message: string }> >([]); + const formRef = React.useRef(null); + const hasTrackedFormView = React.useRef(false); const form = useForm({ resolver: zodResolver(hireUsFormSchema), @@ -365,14 +368,45 @@ export default function HireUs() { }, }); + React.useEffect(() => { + if (!formRef.current || hasTrackedFormView.current) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting || hasTrackedFormView.current) return; + + hasTrackedFormView.current = true; + trackAnalyticsEvent(analyticsEvents.hireFormViewed); + observer.disconnect(); + }, + { threshold: 0.35 } + ); + + observer.observe(formRef.current); + + return () => observer.disconnect(); + }, []); + // Validation functions for each step const validatePersonalInfo = async () => { const result = await form.trigger(["name", "email", "bio"]); + if (!result) { + trackAnalyticsEvent(analyticsEvents.hireFormSubmitError, { + reason: "validation", + step: "contact_info", + }); + } return result; }; const validateProjectDetails = async () => { const result = await form.trigger(["projectName", "description"]); + if (!result) { + trackAnalyticsEvent(analyticsEvents.hireFormSubmitError, { + reason: "validation", + step: "project_details", + }); + } return result; }; @@ -383,6 +417,12 @@ export default function HireUs() { "services", "projectPriority", ]); + if (!result) { + trackAnalyticsEvent(analyticsEvents.hireFormSubmitError, { + reason: "validation", + step: "requirements", + }); + } return result; }; @@ -391,6 +431,7 @@ export default function HireUs() { const formData = form.getValues(); console.log("Wizard completed:", formData); + const servicesCount = formData.services?.length ?? 0; // Reset states setIsSubmitting(true); @@ -400,6 +441,10 @@ export default function HireUs() { //tracking trackEvent("hire-us-submission"); + trackAnalyticsEvent(analyticsEvents.hireFormSubmitAttempt, { + budget: formData.budget, + servicesCount, + }); // Transform form data to API format using the centralized function const consultData = transformFormDataToApiFormat(formData); @@ -421,6 +466,10 @@ export default function HireUs() { //tracking trackEvent("hire-us-submission"); + trackAnalyticsEvent(analyticsEvents.hireFormSubmitSuccess, { + budget: formData.budget, + servicesCount, + }); // Reset form after successful submission form.reset(); } else { @@ -428,14 +477,26 @@ export default function HireUs() { setSubmissionStatus("error"); // Handle validation errors - if (result.details && Array.isArray(result.details)) { + if ( + response.status === 400 && + result.details && + Array.isArray(result.details) + ) { console.error("Validation errors:", result.details); setValidationErrors(result.details); setErrorMessage("Please fix the validation errors below."); + trackAnalyticsEvent(analyticsEvents.hireFormSubmitError, { + reason: "server_validation", + step: "submit", + }); } else { setErrorMessage( result.error || "Failed to submit consultation. Please try again." ); + trackAnalyticsEvent(analyticsEvents.hireFormSubmitError, { + reason: "server_error", + step: "submit", + }); } } } catch (error) { @@ -444,6 +505,10 @@ export default function HireUs() { setErrorMessage( "Network error. Please check your connection and try again." ); + trackAnalyticsEvent(analyticsEvents.hireFormSubmitError, { + reason: "network", + step: "submit", + }); } finally { setIsSubmitting(false); } @@ -527,7 +592,13 @@ export default function HireUs() { // description: "Tell us about yourself", component: , validation: validatePersonalInfo, - onStepComplete: () => trackEvent("hire-us-step-1"), + onStepComplete: () => { + trackEvent("hire-us-step-1"); + trackAnalyticsEvent(analyticsEvents.hireFormStepCompleted, { + step: "contact_info", + stepNumber: 1, + }); + }, }, { id: "project-description", @@ -535,7 +606,13 @@ export default function HireUs() { // description: "Describe your project requirements", component: , validation: validateProjectDetails, - onStepComplete: () => trackEvent("hire-us-step-2"), + onStepComplete: () => { + trackEvent("hire-us-step-2"); + trackAnalyticsEvent(analyticsEvents.hireFormStepCompleted, { + step: "project_details", + stepNumber: 2, + }); + }, }, { id: "requirements", @@ -567,7 +644,7 @@ export default function HireUs() { {/* Wizard */} -
+
{submissionStatus === "success" ? ( diff --git a/src/components/JoinUs.tsx b/src/components/JoinUs.tsx index 4235b11..3ba2b0a 100644 --- a/src/components/JoinUs.tsx +++ b/src/components/JoinUs.tsx @@ -11,12 +11,12 @@ import { FormField, FormItem, FormLabel, - RequiredFieldIndicator, } from "@/components/ui/form"; import { joinUsFormSchema, type JoinUsFormData } from "@/lib/validation"; import Image from "next/image"; import { trackEvent } from "fathom-client"; import { Button } from "./ui/button"; +import { analyticsEvents, trackAnalyticsEvent } from "@/lib/analytics"; const joinUsImages = [ "/images/join-image-1-bw.webp", @@ -55,6 +55,9 @@ export default function JoinUs({ referral }: JoinUsProps) { setIsSubmitting(true); setSubmissionStatus("idle"); setErrorMessage(""); + trackAnalyticsEvent(analyticsEvents.joinSignupAttempt, { + hasReferral: Boolean(referral), + }); try { const response = await fetch("/api/email-referrals", { @@ -76,12 +79,19 @@ export default function JoinUs({ referral }: JoinUsProps) { //tracking trackEvent("join-us-submission"); + trackAnalyticsEvent(analyticsEvents.joinSignupSuccess, { + hasReferral: Boolean(referral), + }); // Reset form after successful submission form.reset(); } else { console.error("Failed to submit email referral:", result); setSubmissionStatus("error"); setErrorMessage(result.error || "Failed to submit. Please try again."); + trackAnalyticsEvent(analyticsEvents.joinSignupError, { + hasReferral: Boolean(referral), + reason: "server_error", + }); } } catch (error) { console.error("Error submitting application:", error); @@ -89,6 +99,10 @@ export default function JoinUs({ referral }: JoinUsProps) { setErrorMessage( "Network error. Please check your connection and try again.", ); + trackAnalyticsEvent(analyticsEvents.joinSignupError, { + hasReferral: Boolean(referral), + reason: "network", + }); } finally { setIsSubmitting(false); } diff --git a/src/components/VercelAnalytics.tsx b/src/components/VercelAnalytics.tsx new file mode 100644 index 0000000..2bf9189 --- /dev/null +++ b/src/components/VercelAnalytics.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { Analytics, type BeforeSendEvent } from "@vercel/analytics/next"; +import { useEffect } from "react"; +import { analyticsEvents, trackAnalyticsEvent } from "@/lib/analytics"; + +const redactUrl = (url: string) => { + try { + const parsedUrl = new URL(url, window.location.origin); + parsedUrl.search = ""; + parsedUrl.hash = ""; + return parsedUrl.toString(); + } catch { + return url.split("?")[0].split("#")[0]; + } +}; + +export default function VercelAnalytics() { + useEffect(() => { + const handleClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element)) return; + + const trackedElement = target.closest("[data-click]"); + if (!(trackedElement instanceof HTMLElement)) return; + + const cta = trackedElement.getAttribute("data-click"); + if (!cta) return; + + trackAnalyticsEvent(analyticsEvents.ctaClick, { cta }); + }; + + document.addEventListener("click", handleClick); + + return () => document.removeEventListener("click", handleClick); + }, []); + + return ( + ({ + ...event, + url: redactUrl(event.url), + })} + /> + ); +} diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 0000000..a14cd15 --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,31 @@ +"use client"; + +import { track } from "@vercel/analytics"; + +type AnalyticsProperties = Record; + +export const analyticsEvents = { + ctaClick: "CTA Click", + hireFormViewed: "Hire Form Viewed", + hireFormStepCompleted: "Hire Form Step Completed", + hireFormSubmitAttempt: "Hire Form Submit Attempt", + hireFormSubmitSuccess: "Hire Form Submit Success", + hireFormSubmitError: "Hire Form Submit Error", + joinSignupAttempt: "Join Signup Attempt", + joinSignupSuccess: "Join Signup Success", + joinSignupError: "Join Signup Error", +} as const; + +export function trackAnalyticsEvent( + eventName: string, + properties?: AnalyticsProperties +) { + try { + track(eventName, properties); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.warn("Vercel Analytics event failed:", eventName, error); + } + } +} + diff --git a/src/lib/server-analytics.ts b/src/lib/server-analytics.ts new file mode 100644 index 0000000..be90b12 --- /dev/null +++ b/src/lib/server-analytics.ts @@ -0,0 +1,43 @@ +import { track } from "@vercel/analytics/server"; + +type AnalyticsProperties = Record; + +const ANALYTICS_HEADER_ALLOWLIST = [ + "accept-language", + "user-agent", + "x-forwarded-for", + "x-real-ip", + "x-vercel-ip", +] as const; + +export const serverAnalyticsEvents = { + consultationSubmitted: "Consultation Submitted", + consultationSubmitFailed: "Consultation Submit Failed", +} as const; + +function getAnalyticsHeaders(request: Request) { + const headers = new Headers(); + + for (const key of ANALYTICS_HEADER_ALLOWLIST) { + const value = request.headers.get(key); + if (value) headers.set(key, value); + } + + return headers; +} + +export async function trackServerAnalyticsEvent( + eventName: string, + properties?: AnalyticsProperties, + request?: Request +) { + try { + await track( + eventName, + properties, + request ? { request: { headers: getAnalyticsHeaders(request) } } : undefined + ); + } catch (error) { + console.error("Vercel Analytics server event failed:", eventName, error); + } +} From f25ea7365625b18e2e328656d317240d67f6e53f Mon Sep 17 00:00:00 2001 From: ECWireless <40322776+ECWireless@users.noreply.github.com> Date: Thu, 28 May 2026 07:09:59 -0600 Subject: [PATCH 3/9] feat: improve form error message handling (#77) * feat: improve form error message handling * fix: wire services field accessibility * docs: add PR review workflow documentation --- docs/pr-review-workflow.md | 63 ++++++++++++++++++++++++++++++++++++++ src/components/HireUs.tsx | 57 ++++++++++++++++++++++++++-------- src/components/JoinUs.tsx | 2 ++ src/components/ui/form.tsx | 1 - src/lib/validation.ts | 2 +- 5 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 docs/pr-review-workflow.md diff --git a/docs/pr-review-workflow.md b/docs/pr-review-workflow.md new file mode 100644 index 0000000..786ea83 --- /dev/null +++ b/docs/pr-review-workflow.md @@ -0,0 +1,63 @@ +# PR Review Comment Workflow + +Use this workflow when an agent is asked to handle GitHub PR review feedback. +It is intentionally generic enough to copy into other repositories. + +## Safety + +- Do not paste tokens, secrets, private data, or real customer data into chat, logs, commits, tests, or GitHub replies. +- Prefer repository-scoped credentials with the minimum permissions needed. +- Keep authentication local to the machine, for example through `gh`, environment variables, or ignored local files. +- Do not stage, commit, push, comment, or resolve GitHub threads without explicit user approval. + +## Flow + +1. Fetch all unresolved review threads first. + - Preserve thread IDs, file paths, line anchors, resolution state, and whether comments are outdated. + - Avoid relying only on flat comment lists when thread state matters. + +2. Summarize the review map before editing. + - List each actionable thread. + - For each thread, state what it claims, whether it appears accurate, and the intended action. + - Separate duplicate, outdated, informational, or ambiguous comments from actionable ones. + +3. Validate each comment against the code. + - Inspect the relevant code and surrounding behavior. + - Do not assume the reviewer is correct. + - If the comment is inaccurate, record the reason for the eventual reply. + +4. Fix valid comments locally. + - Keep changes traceable to the review thread. + - Prefer cohesive local fixes over one commit or push per comment. + - If a comment conflicts with product intent or another comment, pause and explain the tradeoff. + +5. Verify after the selected fixes. + - Run the smallest useful tests for narrow changes. + - Run broader checks for shared behavior, schema changes, auth, exports, imports, or UI flow. + - Record exactly which checks passed or could not be run. + +6. Summarize local results to the user. + - List fixed threads. + - List intentionally unchanged threads and why. + - List files changed and verification commands. + - Ask before staging, committing, pushing, or posting GitHub replies. + +7. Reply to GitHub threads only after approval. + - Reply after code is pushed when a code fix was made. + - Include the commit SHA or short SHA that contains the fix when one is available. + - Keep replies concise and specific: what changed, what check supports it, or why it was left unchanged. + - Leave thread resolution to the user unless they explicitly ask the agent to resolve threads. + +## Reply Style + +Good replies: + +- `Addressed in abc1234 by moving the shared state object out of the server-action file so it only exports async functions. Verified with pnpm typecheck.` +- `Leaving this unchanged: the route intentionally returns 401 before period lookup so unauthenticated requests do not reveal period state.` +- `Partially addressed: the UI now disables the import controls for locked periods, and the server action still enforces the lock as a race-condition guard.` + +Avoid: + +- exposing secrets, raw financial data, or decrypted identifiers; +- vague replies like `Fixed`; +- resolving threads without the user's permission. diff --git a/src/components/HireUs.tsx b/src/components/HireUs.tsx index 1028aeb..6ff4075 100644 --- a/src/components/HireUs.tsx +++ b/src/components/HireUs.tsx @@ -20,7 +20,9 @@ import { FormField, FormItem, FormLabel, + FormMessage, RequiredFieldIndicator, + useFormField, } from "@/components/ui/form"; import { BUDGET_OPTIONS, @@ -36,7 +38,7 @@ import { transformFormDataToApiFormat, } from "@/lib/validation"; import Image from "next/image"; -import MultipleSelector from "./ui/multiselect"; +import MultipleSelector, { type Option } from "./ui/multiselect"; import { DISCORD_INVITE_URL } from "@/lib/data/constants"; import { trackEvent } from "fathom-client"; import { analyticsEvents, trackAnalyticsEvent } from "@/lib/analytics"; @@ -64,6 +66,7 @@ const PersonalInfoStep = ({ form, isActive }: StepProps) => { + )} /> @@ -82,6 +85,7 @@ const PersonalInfoStep = ({ form, isActive }: StepProps) => { {...field} /> + )} /> @@ -101,6 +105,7 @@ const PersonalInfoStep = ({ form, isActive }: StepProps) => { {...field} /> + )} /> @@ -171,6 +176,7 @@ const ProjectDetailsStep = ({ form, isActive }: StepProps) => { {...field} /> + )} /> @@ -188,6 +194,7 @@ const ProjectDetailsStep = ({ form, isActive }: StepProps) => { {...field} /> + )} /> @@ -205,6 +212,7 @@ const ProjectDetailsStep = ({ form, isActive }: StepProps) => { {...field} /> + {/*
*/} )} @@ -213,6 +221,37 @@ const ProjectDetailsStep = ({ form, isActive }: StepProps) => { ); }; +const ServicesSelector = ({ + onChange, +}: { + onChange: (options: Option[]) => void; +}) => { + const { error, formItemId, formDescriptionId, formMessageId } = + useFormField(); + + return ( + + no results found. +

+ } + /> + ); +}; + const RequirementsStep = ({ form, isActive }: StepProps) => { if (!isActive) return null; @@ -276,6 +315,7 @@ const RequirementsStep = ({ form, isActive }: StepProps) => { })} + )} /> @@ -303,6 +343,7 @@ const RequirementsStep = ({ form, isActive }: StepProps) => { })} + )} /> @@ -317,18 +358,8 @@ const RequirementsStep = ({ form, isActive }: StepProps) => { Services Needed - - no results found. -

- } - /> + + )} /> diff --git a/src/components/JoinUs.tsx b/src/components/JoinUs.tsx index 3ba2b0a..46b6760 100644 --- a/src/components/JoinUs.tsx +++ b/src/components/JoinUs.tsx @@ -11,6 +11,7 @@ import { FormField, FormItem, FormLabel, + FormMessage, } from "@/components/ui/form"; import { joinUsFormSchema, type JoinUsFormData } from "@/lib/validation"; import Image from "next/image"; @@ -234,6 +235,7 @@ export default function JoinUs({ referral }: JoinUsProps) { className="contact-form-input-scroll-100 w-full lg:w-4/5" /> + )} /> diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx index 2cba6ca..890748a 100644 --- a/src/components/ui/form.tsx +++ b/src/components/ui/form.tsx @@ -106,7 +106,6 @@ function FormLabel({ {...props} > {children} - {error && (required)} ); } diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 3756958..89df024 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -17,7 +17,7 @@ export const hireUsFormSchema = z.object({ message: "Project name is required.", }), description: z.string().min(10, { - message: "Description is required.", + message: "Please describe how we can help in at least 10 characters.", }), specsLink: z.string().url().optional().or(z.literal("")), budget: z.string().min(1, { From 7c871abe443b07a971404182548bb162f52056a5 Mon Sep 17 00:00:00 2001 From: Sam Kuhlmann Date: Thu, 28 May 2026 10:00:21 -0600 Subject: [PATCH 4/9] removes fathom (#78) --- bun.lock | 4 +-- package.json | 1 - public/witch/index.html | 7 ----- src/app/layout.tsx | 2 -- src/components/CohortHero.tsx | 6 ++-- src/components/Fathom.tsx | 55 ----------------------------------- src/components/HireUs.tsx | 5 ---- src/components/JoinUs.tsx | 2 -- src/lib/analytics.ts | 3 +- src/providers/providers.tsx | 10 +++++-- 10 files changed, 14 insertions(+), 81 deletions(-) delete mode 100644 src/components/Fathom.tsx diff --git a/bun.lock b/bun.lock index 15c81f9..bf466e0 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "raidguild-website", @@ -30,7 +31,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "fathom-client": "^3.7.2", "lucide-react": "^0.545.0", "next": "15.5.7", "next-themes": "^0.4.6", @@ -579,8 +579,6 @@ "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], - "fathom-client": ["fathom-client@3.7.2", "", {}, "sha512-sWtaNivhg7uwp/q1bUuIiNj4LeQZMEZ5NXXFFpZ8le4uDedAfQG84gPOdYehtVXbl+1yX2s8lmXZ2+IQ9a/xxA=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], diff --git a/package.json b/package.json index 105ef14..9d3a0d5 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "fathom-client": "^3.7.2", "lucide-react": "^0.545.0", "next": "15.5.7", "next-themes": "^0.4.6", diff --git a/public/witch/index.html b/public/witch/index.html index 2446a66..5b05b87 100644 --- a/public/witch/index.html +++ b/public/witch/index.html @@ -171,13 +171,6 @@ (n.className += t + "touch"); })(window, document); - - -