diff --git a/.cursor/rules/trigger.advanced-tasks.mdc b/.cursor/rules/trigger.advanced-tasks.mdc new file mode 100644 index 000000000..78551c1ba --- /dev/null +++ b/.cursor/rules/trigger.advanced-tasks.mdc @@ -0,0 +1,456 @@ +--- +description: Comprehensive rules to help you write advanced Trigger.dev tasks +globs: **/trigger/**/*.ts +alwaysApply: false +--- +# Trigger.dev Advanced Tasks (v4) + +**Advanced patterns and features for writing tasks** + +## Tags & Organization + +```ts +import { task, tags } from "@trigger.dev/sdk"; + +export const processUser = task({ + id: "process-user", + run: async (payload: { userId: string; orgId: string }, { ctx }) => { + // Add tags during execution + await tags.add(`user_${payload.userId}`); + await tags.add(`org_${payload.orgId}`); + + return { processed: true }; + }, +}); + +// Trigger with tags +await processUser.trigger( + { userId: "123", orgId: "abc" }, + { tags: ["priority", "user_123", "org_abc"] } // Max 10 tags per run +); + +// Subscribe to tagged runs +for await (const run of runs.subscribeToRunsWithTag("user_123")) { + console.log(`User task ${run.id}: ${run.status}`); +} +``` + +**Tag Best Practices:** + +- Use prefixes: `user_123`, `org_abc`, `video:456` +- Max 10 tags per run, 1-64 characters each +- Tags don't propagate to child tasks automatically + +## Concurrency & Queues + +```ts +import { task, queue } from "@trigger.dev/sdk"; + +// Shared queue for related tasks +const emailQueue = queue({ + name: "email-processing", + concurrencyLimit: 5, // Max 5 emails processing simultaneously +}); + +// Task-level concurrency +export const oneAtATime = task({ + id: "sequential-task", + queue: { concurrencyLimit: 1 }, // Process one at a time + run: async (payload) => { + // Critical section - only one instance runs + }, +}); + +// Per-user concurrency +export const processUserData = task({ + id: "process-user-data", + run: async (payload: { userId: string }) => { + // Override queue with user-specific concurrency + await childTask.trigger(payload, { + queue: { + name: `user-${payload.userId}`, + concurrencyLimit: 2, + }, + }); + }, +}); + +export const emailTask = task({ + id: "send-email", + queue: emailQueue, // Use shared queue + run: async (payload: { to: string }) => { + // Send email logic + }, +}); +``` + +## Error Handling & Retries + +```ts +import { task, retry, AbortTaskRunError } from "@trigger.dev/sdk"; + +export const resilientTask = task({ + id: "resilient-task", + retry: { + maxAttempts: 10, + factor: 1.8, // Exponential backoff multiplier + minTimeoutInMs: 500, + maxTimeoutInMs: 30_000, + randomize: false, + }, + catchError: async ({ error, ctx }) => { + // Custom error handling + if (error.code === "FATAL_ERROR") { + throw new AbortTaskRunError("Cannot retry this error"); + } + + // Log error details + console.error(`Task ${ctx.task.id} failed:`, error); + + // Allow retry by returning nothing + return { retryAt: new Date(Date.now() + 60000) }; // Retry in 1 minute + }, + run: async (payload) => { + // Retry specific operations + const result = await retry.onThrow( + async () => { + return await unstableApiCall(payload); + }, + { maxAttempts: 3 } + ); + + // Conditional HTTP retries + const response = await retry.fetch("https://api.example.com", { + retry: { + maxAttempts: 5, + condition: (response, error) => { + return response?.status === 429 || response?.status >= 500; + }, + }, + }); + + return result; + }, +}); +``` + +## Machines & Performance + +```ts +export const heavyTask = task({ + id: "heavy-computation", + machine: { preset: "large-2x" }, // 8 vCPU, 16 GB RAM + maxDuration: 1800, // 30 minutes timeout + run: async (payload, { ctx }) => { + // Resource-intensive computation + if (ctx.machine.preset === "large-2x") { + // Use all available cores + return await parallelProcessing(payload); + } + + return await standardProcessing(payload); + }, +}); + +// Override machine when triggering +await heavyTask.trigger(payload, { + machine: { preset: "medium-1x" }, // Override for this run +}); +``` + +**Machine Presets:** + +- `micro`: 0.25 vCPU, 0.25 GB RAM +- `small-1x`: 0.5 vCPU, 0.5 GB RAM (default) +- `small-2x`: 1 vCPU, 1 GB RAM +- `medium-1x`: 1 vCPU, 2 GB RAM +- `medium-2x`: 2 vCPU, 4 GB RAM +- `large-1x`: 4 vCPU, 8 GB RAM +- `large-2x`: 8 vCPU, 16 GB RAM + +## Idempotency + +```ts +import { task, idempotencyKeys } from "@trigger.dev/sdk"; + +export const paymentTask = task({ + id: "process-payment", + retry: { + maxAttempts: 3, + }, + run: async (payload: { orderId: string; amount: number }) => { + // Automatically scoped to this task run, so if the task is retried, the idempotency key will be the same + const idempotencyKey = await idempotencyKeys.create(`payment-${payload.orderId}`); + + // Ensure payment is processed only once + await chargeCustomer.trigger(payload, { + idempotencyKey, + idempotencyKeyTTL: "24h", // Key expires in 24 hours + }); + }, +}); + +// Payload-based idempotency +import { createHash } from "node:crypto"; + +function createPayloadHash(payload: any): string { + const hash = createHash("sha256"); + hash.update(JSON.stringify(payload)); + return hash.digest("hex"); +} + +export const deduplicatedTask = task({ + id: "deduplicated-task", + run: async (payload) => { + const payloadHash = createPayloadHash(payload); + const idempotencyKey = await idempotencyKeys.create(payloadHash); + + await processData.trigger(payload, { idempotencyKey }); + }, +}); +``` + +## Metadata & Progress Tracking + +```ts +import { task, metadata } from "@trigger.dev/sdk"; + +export const batchProcessor = task({ + id: "batch-processor", + run: async (payload: { items: any[] }, { ctx }) => { + const totalItems = payload.items.length; + + // Initialize progress metadata + metadata + .set("progress", 0) + .set("totalItems", totalItems) + .set("processedItems", 0) + .set("status", "starting"); + + const results = []; + + for (let i = 0; i < payload.items.length; i++) { + const item = payload.items[i]; + + // Process item + const result = await processItem(item); + results.push(result); + + // Update progress + const progress = ((i + 1) / totalItems) * 100; + metadata + .set("progress", progress) + .increment("processedItems", 1) + .append("logs", `Processed item ${i + 1}/${totalItems}`) + .set("currentItem", item.id); + } + + // Final status + metadata.set("status", "completed"); + + return { results, totalProcessed: results.length }; + }, +}); + +// Update parent metadata from child task +export const childTask = task({ + id: "child-task", + run: async (payload, { ctx }) => { + // Update parent task metadata + metadata.parent.set("childStatus", "processing"); + metadata.root.increment("childrenCompleted", 1); + + return { processed: true }; + }, +}); +``` + +## Advanced Triggering + +### Frontend Triggering (React) + +```tsx +"use client"; +import { useTaskTrigger } from "@trigger.dev/react-hooks"; +import type { myTask } from "../trigger/tasks"; + +function TriggerButton({ accessToken }: { accessToken: string }) { + const { submit, handle, isLoading } = useTaskTrigger("my-task", { accessToken }); + + return ( + + ); +} +``` + +### Large Payloads + +```ts +// For payloads > 512KB (max 10MB) +export const largeDataTask = task({ + id: "large-data-task", + run: async (payload: { dataUrl: string }) => { + // Trigger.dev automatically handles large payloads + // For > 10MB, use external storage + const response = await fetch(payload.dataUrl); + const largeData = await response.json(); + + return { processed: largeData.length }; + }, +}); + +// Best practice: Use presigned URLs for very large files +await largeDataTask.trigger({ + dataUrl: "https://s3.amazonaws.com/bucket/large-file.json?presigned=true", +}); +``` + +### Advanced Options + +```ts +await myTask.trigger(payload, { + delay: "2h30m", // Delay execution + ttl: "24h", // Expire if not started within 24 hours + priority: 100, // Higher priority (time offset in seconds) + tags: ["urgent", "user_123"], + metadata: { source: "api", version: "v2" }, + queue: { + name: "priority-queue", + concurrencyLimit: 10, + }, + idempotencyKey: "unique-operation-id", + idempotencyKeyTTL: "1h", + machine: { preset: "large-1x" }, + maxAttempts: 5, +}); +``` + +## Hidden Tasks + +```ts +// Hidden task - not exported, only used internally +const internalProcessor = task({ + id: "internal-processor", + run: async (payload: { data: string }) => { + return { processed: payload.data.toUpperCase() }; + }, +}); + +// Public task that uses hidden task +export const publicWorkflow = task({ + id: "public-workflow", + run: async (payload: { input: string }) => { + // Use hidden task internally + const result = await internalProcessor.triggerAndWait({ + data: payload.input, + }); + + if (result.ok) { + return { output: result.output.processed }; + } + + throw new Error("Internal processing failed"); + }, +}); +``` + +## Logging & Tracing + +```ts +import { task, logger } from "@trigger.dev/sdk"; + +export const tracedTask = task({ + id: "traced-task", + run: async (payload, { ctx }) => { + logger.info("Task started", { userId: payload.userId }); + + // Custom trace with attributes + const user = await logger.trace( + "fetch-user", + async (span) => { + span.setAttribute("user.id", payload.userId); + span.setAttribute("operation", "database-fetch"); + + const userData = await database.findUser(payload.userId); + span.setAttribute("user.found", !!userData); + + return userData; + }, + { userId: payload.userId } + ); + + logger.debug("User fetched", { user: user.id }); + + try { + const result = await processUser(user); + logger.info("Processing completed", { result }); + return result; + } catch (error) { + logger.error("Processing failed", { + error: error.message, + userId: payload.userId, + }); + throw error; + } + }, +}); +``` + +## Usage Monitoring + +```ts +import { task, usage } from "@trigger.dev/sdk"; + +export const monitoredTask = task({ + id: "monitored-task", + run: async (payload) => { + // Get current run cost + const currentUsage = await usage.getCurrent(); + logger.info("Current cost", { + costInCents: currentUsage.costInCents, + durationMs: currentUsage.durationMs, + }); + + // Measure specific operation + const { result, compute } = await usage.measure(async () => { + return await expensiveOperation(payload); + }); + + logger.info("Operation cost", { + costInCents: compute.costInCents, + durationMs: compute.durationMs, + }); + + return result; + }, +}); +``` + +## Run Management + +```ts +// Cancel runs +await runs.cancel("run_123"); + +// Replay runs with same payload +await runs.replay("run_123"); + +// Retrieve run with cost details +const run = await runs.retrieve("run_123"); +console.log(`Cost: ${run.costInCents} cents, Duration: ${run.durationMs}ms`); +``` + +## Best Practices + +- **Concurrency**: Use queues to prevent overwhelming external services +- **Retries**: Configure exponential backoff for transient failures +- **Idempotency**: Always use for payment/critical operations +- **Metadata**: Track progress for long-running tasks +- **Machines**: Match machine size to computational requirements +- **Tags**: Use consistent naming patterns for filtering +- **Large Payloads**: Use external storage for files > 10MB +- **Error Handling**: Distinguish between retryable and fatal errors + +Design tasks to be stateless, idempotent, and resilient to failures. Use metadata for state tracking and queues for resource management. diff --git a/.cursor/rules/trigger.config.mdc b/.cursor/rules/trigger.config.mdc new file mode 100644 index 000000000..54e400d73 --- /dev/null +++ b/.cursor/rules/trigger.config.mdc @@ -0,0 +1,351 @@ +--- +description: Configure your Trigger.dev project with a trigger.config.ts file +globs: **/trigger.config.ts +alwaysApply: false +--- +# Trigger.dev Configuration (v4) + +**Complete guide to configuring `trigger.config.ts` with build extensions** + +## Basic Configuration + +```ts +import { defineConfig } from "@trigger.dev/sdk"; + +export default defineConfig({ + project: "", // Required: Your project reference + dirs: ["./trigger"], // Task directories + runtime: "node", // "node", "node-22", or "bun" + logLevel: "info", // "debug", "info", "warn", "error" + + // Default retry settings + retries: { + enabledInDev: false, + default: { + maxAttempts: 3, + minTimeoutInMs: 1000, + maxTimeoutInMs: 10000, + factor: 2, + randomize: true, + }, + }, + + // Build configuration + build: { + autoDetectExternal: true, + keepNames: true, + minify: false, + extensions: [], // Build extensions go here + }, + + // Global lifecycle hooks + onStart: async ({ payload, ctx }) => { + console.log("Global task start"); + }, + onSuccess: async ({ payload, output, ctx }) => { + console.log("Global task success"); + }, + onFailure: async ({ payload, error, ctx }) => { + console.log("Global task failure"); + }, +}); +``` + +## Build Extensions + +### Database & ORM + +#### Prisma + +```ts +import { prismaExtension } from "@trigger.dev/build/extensions/prisma"; + +extensions: [ + prismaExtension({ + schema: "prisma/schema.prisma", + version: "5.19.0", // Optional: specify version + migrate: true, // Run migrations during build + directUrlEnvVarName: "DIRECT_DATABASE_URL", + typedSql: true, // Enable TypedSQL support + }), +]; +``` + +#### TypeScript Decorators (for TypeORM) + +```ts +import { emitDecoratorMetadata } from "@trigger.dev/build/extensions/typescript"; + +extensions: [ + emitDecoratorMetadata(), // Enables decorator metadata +]; +``` + +### Scripting Languages + +#### Python + +```ts +import { pythonExtension } from "@trigger.dev/build/extensions/python"; + +extensions: [ + pythonExtension({ + scripts: ["./python/**/*.py"], // Copy Python files + requirementsFile: "./requirements.txt", // Install packages + devPythonBinaryPath: ".venv/bin/python", // Dev mode binary + }), +]; + +// Usage in tasks +const result = await python.runInline(`print("Hello, world!")`); +const output = await python.runScript("./python/script.py", ["arg1"]); +``` + +### Browser Automation + +#### Playwright + +```ts +import { playwright } from "@trigger.dev/build/extensions/playwright"; + +extensions: [ + playwright({ + browsers: ["chromium", "firefox", "webkit"], // Default: ["chromium"] + headless: true, // Default: true + }), +]; +``` + +#### Puppeteer + +```ts +import { puppeteer } from "@trigger.dev/build/extensions/puppeteer"; + +extensions: [puppeteer()]; + +// Environment variable needed: +// PUPPETEER_EXECUTABLE_PATH: "/usr/bin/google-chrome-stable" +``` + +#### Lightpanda + +```ts +import { lightpanda } from "@trigger.dev/build/extensions/lightpanda"; + +extensions: [ + lightpanda({ + version: "latest", // or "nightly" + disableTelemetry: false, + }), +]; +``` + +### Media Processing + +#### FFmpeg + +```ts +import { ffmpeg } from "@trigger.dev/build/extensions/core"; + +extensions: [ + ffmpeg({ version: "7" }), // Static build, or omit for Debian version +]; + +// Automatically sets FFMPEG_PATH and FFPROBE_PATH +// Add fluent-ffmpeg to external packages if using +``` + +#### Audio Waveform + +```ts +import { audioWaveform } from "@trigger.dev/build/extensions/audioWaveform"; + +extensions: [ + audioWaveform(), // Installs Audio Waveform 1.1.0 +]; +``` + +### System & Package Management + +#### System Packages (apt-get) + +```ts +import { aptGet } from "@trigger.dev/build/extensions/core"; + +extensions: [ + aptGet({ + packages: ["ffmpeg", "imagemagick", "curl=7.68.0-1"], // Can specify versions + }), +]; +``` + +#### Additional NPM Packages + +Only use this for installing CLI tools, NOT packages you import in your code. + +```ts +import { additionalPackages } from "@trigger.dev/build/extensions/core"; + +extensions: [ + additionalPackages({ + packages: ["wrangler"], // CLI tools and specific versions + }), +]; +``` + +#### Additional Files + +```ts +import { additionalFiles } from "@trigger.dev/build/extensions/core"; + +extensions: [ + additionalFiles({ + files: ["wrangler.toml", "./assets/**", "./fonts/**"], // Glob patterns supported + }), +]; +``` + +### Environment & Build Tools + +#### Environment Variable Sync + +```ts +import { syncEnvVars } from "@trigger.dev/build/extensions/core"; + +extensions: [ + syncEnvVars(async (ctx) => { + // ctx contains: environment, projectRef, env + return [ + { name: "SECRET_KEY", value: await getSecret(ctx.environment) }, + { name: "API_URL", value: ctx.environment === "prod" ? "api.prod.com" : "api.dev.com" }, + ]; + }), +]; +``` + +#### ESBuild Plugins + +```ts +import { esbuildPlugin } from "@trigger.dev/build/extensions"; +import { sentryEsbuildPlugin } from "@sentry/esbuild-plugin"; + +extensions: [ + esbuildPlugin( + sentryEsbuildPlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + { placement: "last", target: "deploy" } // Optional config + ), +]; +``` + +## Custom Build Extensions + +```ts +import { defineConfig } from "@trigger.dev/sdk"; + +const customExtension = { + name: "my-custom-extension", + + externalsForTarget: (target) => { + return ["some-native-module"]; // Add external dependencies + }, + + onBuildStart: async (context) => { + console.log(`Build starting for ${context.target}`); + // Register esbuild plugins, modify build context + }, + + onBuildComplete: async (context, manifest) => { + console.log("Build complete, adding layers"); + // Add build layers, modify deployment + context.addLayer({ + id: "my-layer", + files: [{ source: "./custom-file", destination: "/app/custom" }], + commands: ["chmod +x /app/custom"], + }); + }, +}; + +export default defineConfig({ + project: "my-project", + build: { + extensions: [customExtension], + }, +}); +``` + +## Advanced Configuration + +### Telemetry + +```ts +import { PrismaInstrumentation } from "@prisma/instrumentation"; +import { OpenAIInstrumentation } from "@langfuse/openai"; + +export default defineConfig({ + // ... other config + telemetry: { + instrumentations: [new PrismaInstrumentation(), new OpenAIInstrumentation()], + exporters: [customExporter], // Optional custom exporters + }, +}); +``` + +### Machine & Performance + +```ts +export default defineConfig({ + // ... other config + defaultMachine: "large-1x", // Default machine for all tasks + maxDuration: 300, // Default max duration (seconds) + enableConsoleLogging: true, // Console logging in development +}); +``` + +## Common Extension Combinations + +### Full-Stack Web App + +```ts +extensions: [ + prismaExtension({ schema: "prisma/schema.prisma", migrate: true }), + additionalFiles({ files: ["./public/**", "./assets/**"] }), + syncEnvVars(async (ctx) => [...envVars]), +]; +``` + +### AI/ML Processing + +```ts +extensions: [ + pythonExtension({ + scripts: ["./ai/**/*.py"], + requirementsFile: "./requirements.txt", + }), + ffmpeg({ version: "7" }), + additionalPackages({ packages: ["wrangler"] }), +]; +``` + +### Web Scraping + +```ts +extensions: [ + playwright({ browsers: ["chromium"] }), + puppeteer(), + additionalFiles({ files: ["./selectors.json", "./proxies.txt"] }), +]; +``` + +## Best Practices + +- **Use specific versions**: Pin extension versions for reproducible builds +- **External packages**: Add modules with native addons to the `build.external` array +- **Environment sync**: Use `syncEnvVars` for dynamic secrets +- **File paths**: Use glob patterns for flexible file inclusion +- **Debug builds**: Use `--log-level debug --dry-run` for troubleshooting + +Extensions only affect deployment, not local development. Use `external` array for packages that shouldn't be bundled. diff --git a/.dockerignore b/.dockerignore index 993944f0e..1574bc26d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,6 +13,7 @@ storage # installed files node_modules +dist out .cache .claude diff --git a/.env b/.env index 91092ec58..d61bf2742 100644 --- a/.env +++ b/.env @@ -5,10 +5,13 @@ DOTENV_PUBLIC_KEY="029b5432287e802a315a922dd9d54d39c60a9c2b90352e6e182c7ebd760852b510" VITE_APP_URL="https://{env.PULL_REQUEST}.rectangularlabs.com" -VITE_MENTIONS_URL="https://{env.PULL_REQUEST}.mentions.rectangularlabs.com" +VITE_SEO_URL="https://{env.PULL_REQUEST}.seo.rectangularlabs.com" AUTH_ENCRYPTION_KEY="encrypted:BIE5Z8BAuioQtWpzw17jS8AMiySKliZhtiJJOq6vEF9yFMDgnkE3PVdY7SeapU6xM0PZhuw/hpPzHosna3N2vVLX/wtPW5W7VCDtATypf72Rvjb4aL/AO4PVBxkuHBpMl+RBa6hxaHtgMeley03rQCXzaYEb4r7UhcwOpFucK7RRurhzrt60r0LCmoxW" AUTH_GITHUB_ID="encrypted:BGggZsKdNloQB/Eowp0xHbwH/zi8Nx6+5/q2UyH6AQhbgkkgxCXHKROcx74VNkvkXBkZyy4NVEQHlC3W/8NZenkJlwkxdIzkg3H898qSBCLps/iAg1xbi6JuGCF7mXp5C2Ia4bvqOnR7zy9aIIlkIVFPCLeX" AUTH_GITHUB_SECRET="encrypted:BJ8mhInEz77Cdfj6Kj8MOuj06zmySKJZt+WfnzzlgHU7lNCXNsWbjqwe1gPhe/nRUmpgy/2CjWWpjT2UTU32aCu1v7jqUCNeo4SAdt2g3N7pT87HEudzF+7z+9zpfm7AI1CnsfmAa0/HhySLiFTk9rG/H/WRF7j2mrPHb8915MYj6yw2aWhK9vI=" DATABASE_URL="encrypted:BOmJpqtAa5VZFQLySIYf/OcgshOpNgV+CRsNRp3Xu/zlW0luMe7HZEQQfefrdtGbh8vjlTG+/HvvZVZyNK6+5C5MRVCPaVuqHb6yRIh7fR5BiiApDm+U9LDfwsRbaBDTkH6iacPC3EuOfNk5GLiqnhmPDVapqFuYldYQKtPP2riJVceh1yNCtWYv8shDO4awDms/efsJUBW7j8JUaK7LPtAgc8pbc0ylpAP+fSr0wuY+C4yycupbi0dz3VySPwqXzvWNVNkDKGZRe/CWFnk=" +GOOGLE_GENERATIVE_AI_API_KEY="encrypted:BHwRYKNORCwTf6ctgb8x5U5fbwHrMtA1Ejrrdmut2spPl9esaJgOEC80tO1spinAcG5Fdbx+J5rUFX10MLN2PFiSgxPVdamgi0Jqiil+6r7a7k/APUqEgxGKQKMKCpoPrE5wl7Y/LlLL0i/MYsT8Ot3Nykz1iLt6gnNSs4yacQpAqeQqyD5MzQ==" +TRIGGER_SECRET_KEY="encrypted:BJuy27GWKGa05sHyHespRWuUDuvEKrn3BWvn2jhbC8k88tIeV2nsiL5bZVzY1BlegqFVKe6pcm0nmerZD1H9u2gQ0coi/BoluXEA29oFGsfw0bgLFp3wlA4MeFmYelzf2o45pJlY9DXC6ostTou2/afYZdYMB1rHRxyh25E=" +TRIGGER_PROJECT_ID="encrypted:BP3z3xgCc+FE8B9ngJL4F1/Mh95qTYsJ7wPraD2IEY6SoGKt+UJJHCad+IMZEO0R10G9NnRmOOcK7ota0ncbwQ8L/HH2D22mmHcub5rpw18vDc1Udlb923Z00j+bIgN+qDEmLWMs8McxAc/MMxfItfhNg99l3iBqk4U=" \ No newline at end of file diff --git a/.env.production b/.env.production index bbb08e71d..9219642c0 100644 --- a/.env.production +++ b/.env.production @@ -6,10 +6,13 @@ DOTENV_PUBLIC_KEY_PRODUCTION="03741dc2723a48fc90ce0be37d246e96d1ee68ff34af5f702d # .env.production VITE_APP_URL="https://rectangularlabs.com" -VITE_MENTIONS_URL="https://mentions.rectangularlabs.com" +VITE_SEO_URL="https://seo.rectangularlabs.com" AUTH_ENCRYPTION_KEY="encrypted:BB7GTM5rZHiQxZ8UNUnajRL+QRCxDuSZvOfcrsqrQIFtLTrM/p0Xhc6zDnZnDDnShczhi4m1eaHq5cZWBuzjMUfv2CpnU4yPxZYuOMRsKjY1mQ/DztxYMT5tH42+QCMxA1bU6sJCdDnWwFPqx0DcL30oxCkxuaWzBmeHpmEPAcjV/NHtZ9pbm87cdXtj" AUTH_GITHUB_ID="encrypted:BGhstwS69Y3j8JlzPfLKAXSSB0OYXBmLDzq0BFT0q5ujVp6yzziNzfiV3Miom6sIk6PQCcGYud4cwyQWAp/G/Vu3f3vBvIvTwjB05xR8ZFW5P3Eb1a4L5k2wT2/tr5ANY0P4i9T14j4zy6nNhXafU7f8fiVA" AUTH_GITHUB_SECRET="encrypted:BJ42L6e6x7bgwQW8AiYjl53L+Gu81RklwcApDtP5xkZkOPZNU714g9BP3HQrdjWuQTmyDlEi9WPfS89M93Wqm6AGhUnZFdU48/EXhphZzHCed6cXKzTyWQROfM0hXwtaoFzecPKeJyEwBfT3SBe61fq88nX+oyniGToLi+4PMmQCBUuCfor+/YA=" DATABASE_URL="encrypted:BILowGq6zORIhEi96PxHyCIANFDPnlbXZFc1rKzdC82fUHBWAbF/MZaYD2+Mb8+bDmVRMlAdd6wONZsQc62U2H5gP/1gUjafBV5jwCOkvuu4I2eNR6qPwDOixfidkVsnt7h16nO7whQ9AM9R0faoBUw3f0BMB7uyKfJmr9b/T6bVX94oCVLy3nVCnjkD8lt5rPQHR+Yqper3Onc1u24HDjGXIaQQN9o2nv1zfYXYgtqhhIYNRnUiJUPNri/i2ocEzLGFuCowU/ac5AAEqy0=" +GOOGLE_GENERATIVE_AI_API_KEY="encrypted:BDZa7EK7Vm8lSHNIttoWwrxvx44wvX5lGXIiCodWuD3mVAjLgqzPchI7UWrGoxyJkuYXdbhxw5AYcaIhaZBVKCzVdXKkCx5x+0WNZjzHNAYGI/diPsTDl8MNGx/J43csJ12Yc7rYJGLdOFiD/hSqgiDJCTqMvweNXtniMnD4sSMMU/JFe6Fhqw==" +TRIGGER_SECRET_KEY="encrypted:BCBR73LyqV9Zo8J3vz5h3hljSwg0XZehyt56nRdsGyZMR0l44Xw59Uc48inCjueR3BTlv9VVeAv/YplTFRfevZ2U4EJ5lt3WRUX0jSAqCn7PhK6K4CXQUsyRAzl7itFhc6L29aGFbP3mvb5vDXwCngbymnBK6BCCQ5FLRTA=" +TRIGGER_PROJECT_ID="encrypted:BOKJYM+aJcoLofNjhGSlf7zVlH85eS7WxD3BJVkbvcbmgMpB2MVQOJgb3XZ8lcu2B+vAYXQAD7zRd7EskH24r+GWqrXNShQecIySJPxKrWqvbft5OQazgixdfEr87z7vtmEezUeV8B6MeA1D7pBZGUCubGdyfJ0DJ1Q=" diff --git a/.github/workflows/cloudflare.yml b/.github/workflows/cloudflare.yml index 32dda66a8..48267f6ef 100644 --- a/.github/workflows/cloudflare.yml +++ b/.github/workflows/cloudflare.yml @@ -100,10 +100,10 @@ jobs: ENV_PATH: ${{ steps.resolve.outputs.env_path }} run: | FILE_WWW="apps/www/wrangler.jsonc" - FILE_MENTIONS="apps/mentions/wrangler.jsonc" + FILE_SEO="apps/seo/wrangler.jsonc" # Replace placeholder with computed slug sed -i "s|{env.PULL_REQUEST}|$ENVIRONMENT|g" "$FILE_WWW" - sed -i "s|{env.PULL_REQUEST}|$ENVIRONMENT|g" "$FILE_MENTIONS" + sed -i "s|{env.PULL_REQUEST}|$ENVIRONMENT|g" "$FILE_SEO" sed -i "s|{env.PULL_REQUEST}|$ENVIRONMENT|g" "$ENV_PATH" - name: Prepare secrets @@ -130,12 +130,14 @@ jobs: command: ${{ steps.resolve.outputs.deploy_command }} environment: ${{ steps.resolve.outputs.environment }} secrets: ${{ steps.prepare_secrets.outputs.secret_names }} - - name: Deploy Mentions - id: deploy_mentions + + + - name: Deploy SEO + id: deploy_seo uses: cloudflare/wrangler-action@v3 with: packageManager: pnpm - workingDirectory: apps/mentions + workingDirectory: apps/seo apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: ${{ steps.resolve.outputs.deploy_command }} @@ -148,7 +150,7 @@ jobs: with: message: | Cloudflare Preview URL for WWW :balloon: : https://${{ steps.deploy_www.outputs.deployment-url }} - Cloudflare Preview URL for Mentions :balloon: : https://${{ steps.deploy_mentions.outputs.deployment-url }} + Cloudflare Preview URL for SEO :balloon: : https://${{ steps.deploy_seo.outputs.deployment-url }} comment-tag: execution cleanup: @@ -166,11 +168,10 @@ jobs: shell: bash run: | FILE_WWW="apps/www/wrangler.jsonc" - FILE_MENTIONS="apps/mentions/wrangler.jsonc" + FILE_SEO="apps/seo/wrangler.jsonc" # Replace placeholder with computed slug sed -i "s|{env.PULL_REQUEST}|$STAGE|g" "$FILE_WWW" - sed -i "s|{env.PULL_REQUEST}|$STAGE|g" "$FILE_MENTIONS" - + sed -i "s|{env.PULL_REQUEST}|$STAGE|g" "$FILE_SEO" - name: Destroy Preview Environment for WWW uses: cloudflare/wrangler-action@v3 with: @@ -180,11 +181,12 @@ jobs: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: delete --env ${{ env.STAGE }} - - name: Destroy Preview Environment for Mentions + - name: Destroy Preview Environment for SEO uses: cloudflare/wrangler-action@v3 with: packageManager: pnpm - workingDirectory: apps/mentions + workingDirectory: apps/seo apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: delete --env ${{ env.STAGE }} \ No newline at end of file + command: delete --env ${{ env.STAGE }} + \ No newline at end of file diff --git a/.github/workflows/trigger-dev.yml b/.github/workflows/trigger-dev.yml new file mode 100644 index 000000000..76a8eb035 --- /dev/null +++ b/.github/workflows/trigger-dev.yml @@ -0,0 +1,58 @@ +name: Trigger + +on: + push: + branches: + - main + pull_request: + +concurrency: + group: task-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + deploy-staging: + runs-on: ubuntu-latest + name: Deploy Staging + if: ${{ github.event_name == 'pull_request' }} + + env: + DOTENV_PRIVATE_KEY: ${{ secrets.DOTENV_PRIVATE_KEY }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Dependencies + uses: ./tooling/github/setup + - name: Build and deploy + run: | + pnpm run build + + - name: Echo profile + env: + TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }} + run: | + echo "TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}" + pnpm dotenvx run -f .env -- pnpx trigger.dev@4.0.4 whoami --profile default + + - name: Deploy to Trigger.dev Staging + env: + TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }} + run: | + pnpm dotenvx run -f .env -- pnpm run task:deploy + deploy-production: + runs-on: ubuntu-latest + name: Deploy Production + if: ${{ github.event_name != 'pull_request' }} + env: + DOTENV_PRIVATE_KEY_PRODUCTION: ${{ secrets.DOTENV_PRIVATE_KEY_PRODUCTION }} + + steps: + - uses: actions/checkout@v4 + - name: Setup Dependencies + uses: ./tooling/github/setup + + - name: Deploy to Trigger.dev Production + env: + TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN_PRODUCTION }} + run: | + pnpm dotenvx run -f .env.production -- pnpm run task:deploy \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2fe156a20..b5c8a67f6 100644 --- a/.gitignore +++ b/.gitignore @@ -108,4 +108,7 @@ sst-env.d.ts .cursorrules # content-collections -.content-collections \ No newline at end of file +.content-collections + +# trigger dev +.trigger \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 68be19f0c..64b1f50c2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -49,6 +49,7 @@ "arktype", "crawlee", "Creds", + "defuddle", "dotenvx", "fumadocs", "metas", @@ -64,6 +65,7 @@ "totp", "turso", "Unlazied", + "uuidv", "webauthn" ] } diff --git a/apps/mentions/src/lib/auth/client.ts b/apps/mentions/src/lib/auth/client.ts deleted file mode 100644 index aa5922353..000000000 --- a/apps/mentions/src/lib/auth/client.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { createAuthClient } from "@rectangular-labs/auth/client"; -import { createIsomorphicFn } from "@tanstack/react-start"; -import { getWebRequest } from "@tanstack/react-start/server"; -import { clientEnv } from "../env"; -import { authServerHandler } from "./server"; - -export const authClient = createAuthClient(clientEnv().VITE_MENTIONS_URL); - -export const getCurrentSession = createIsomorphicFn() - .server(async () => { - const request = getWebRequest(); - const session = await authServerHandler.api.getSession({ - headers: request.headers, - }); - return session; - }) - .client(async () => { - const session = await authClient.getSession(); - return session.data; - }); - -export const getUserOrganizations = createIsomorphicFn() - .server(async () => { - const request = getWebRequest(); - const organizations = await authServerHandler.api.listOrganizations({ - headers: request.headers, - }); - return organizations; - }) - .client(async () => { - const organizations = await authClient.organization.list(); - if (organizations.error) { - throw new Error( - organizations.error.message ?? - "Something went wrong loading organizations. Please try again", - ); - } - return organizations.data; - }); diff --git a/apps/mentions/src/routes/_authed/dashboard.tsx b/apps/mentions/src/routes/_authed/dashboard.tsx deleted file mode 100644 index 2bb26a95d..000000000 --- a/apps/mentions/src/routes/_authed/dashboard.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; -import { apiClientRq } from "~/lib/api"; -import { getUserOrganizations } from "~/lib/auth/client"; - -export const Route = createFileRoute("/_authed/dashboard")({ - component: ProjectPicker, - beforeLoad: async ({ context }) => { - const organizations = await getUserOrganizations(); - if (organizations.length === 0) { - throw redirect({ - to: "/onboarding", - }); - } - // we need to check org first otherwise the following call will throw a 404 - const projects = await context.queryClient.fetchQuery( - apiClientRq.projects.list.queryOptions({ - input: { - limit: 1, - }, - }), - ); - if (projects.data.length === 0) { - throw redirect({ - to: "/onboarding", - }); - } - }, -}); - -function ProjectPicker() { - return null; -} diff --git a/apps/mentions/src/routes/_authed/onboarding/-components/3-understanding-company.tsx b/apps/mentions/src/routes/_authed/onboarding/-components/3-understanding-company.tsx deleted file mode 100644 index fd5e3eb90..000000000 --- a/apps/mentions/src/routes/_authed/onboarding/-components/3-understanding-company.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { Check, Spinner } from "@rectangular-labs/ui/components/icon"; -import { Button } from "@rectangular-labs/ui/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@rectangular-labs/ui/components/ui/card"; -import { useQuery } from "@tanstack/react-query"; -import { apiClientRq } from "~/lib/api"; -import { OnboardingSteps } from "../-lib/steps"; - -const steps = [ - { - id: "background", - label: "Understanding site background", - }, - { id: "customer", label: "Constructing ideal customer profile" }, - { id: "tone", label: "Figuring out the best tone and response" }, -] as const; -type StepIds = (typeof steps)[number]["id"]; - -export function OnboardingUnderstandingCompany({ - description, - title, -}: { - title: string; - description: string; -}) { - const matcher = OnboardingSteps.useStepper(); - const { crawlId } = matcher.getMetadata<{ - crawlId: string; - }>("user-company"); - - const { data: companyBackground, error: getStatusError } = useQuery( - apiClientRq.companyBackground.getCrawlStatus.queryOptions({ - enabled: !!crawlId, - input: { - id: crawlId ?? "", - }, - refetchInterval: 3_000, // every 3 seconds - }), - ); - - if (getStatusError) { - return ( -
Something went wrong getting status. Trying again in 5 seconds.
- ); - } - - const status: Record = { - background: companyBackground?.data?.description ? "done" : "doing", - customer: companyBackground?.data?.idealCustomer - ? "done" - : companyBackground?.data?.description - ? "doing" - : "pending", - tone: companyBackground?.data?.responseTone - ? "done" - : companyBackground?.data?.idealCustomer - ? "doing" - : "pending", - }; - - return ( -
- - - {title} - {description} - - - {steps.map((step) => { - return ( -
- {status[step.id] === "pending" && ( -
- )} - {status[step.id] === "doing" && ( - - )} - {status[step.id] === "done" && ( - - )} -
{step.label}
-
- ); - })} - - -
- - -
-
- -
- ); -} diff --git a/apps/mentions/src/routes/_authed/onboarding/-components/5-review-project.tsx b/apps/mentions/src/routes/_authed/onboarding/-components/5-review-project.tsx deleted file mode 100644 index 5a63de6a3..000000000 --- a/apps/mentions/src/routes/_authed/onboarding/-components/5-review-project.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { Button } from "@rectangular-labs/ui/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@rectangular-labs/ui/components/ui/card"; -import { - arktypeResolver, - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, - useForm, -} from "@rectangular-labs/ui/components/ui/form"; -import { Input } from "@rectangular-labs/ui/components/ui/input"; -import { type } from "arktype"; -import { authClient } from "~/lib/auth/client"; -import { OnboardingSteps } from "../-lib/steps"; - -const schema = type({ - name: "string.alphanumeric", - description: "string", - targetAudience: "string", - suggestedKeywords: "string[]", - responseTone: "string", -}); -export function OnboardingReviewProject() { - const matcher = OnboardingSteps.useStepper(); - const form = useForm({ - resolver: arktypeResolver(schema), - }); - - const handleSubmit = async (values: typeof schema.infer) => { - const valid = await authClient.organization.checkSlug({ - slug: values.name, - }); - if (valid.error) { - form.setError("root", { - message: - valid.error.message ?? - "Something went wrong creating the organization. Please try again later", - }); - return; - } - if (!valid.data?.status) { - form.setError("name", { - message: "Organization name already taken, please choose another one!", - }); - return; - } - - const organizationResult = await authClient.organization.create({ - name: values.name, - slug: values.name, - metadata: { description: values.description }, - }); - if (organizationResult.error) { - form.setError("root", { - message: - organizationResult.error.message || - organizationResult.error.statusText, - }); - return; - } - matcher.next(); - }; - - return ( -
- - - {matcher.current.title} - {matcher.current.description} - -
- - - ( - - Organization Name - - - - - - You will be able to change this at anytime later on - - - - )} - /> - - ( - - - Organization Description{" "} - (optional) - - - - - - - )} - /> - - {form.formState.errors.root && ( - {form.formState.errors.root.message} - )} - - -
- - -
-
-
- -
-
- ); -} diff --git a/apps/mentions/src/routes/_authed/onboarding/index.tsx b/apps/mentions/src/routes/_authed/onboarding/index.tsx deleted file mode 100644 index c6cf1736b..000000000 --- a/apps/mentions/src/routes/_authed/onboarding/index.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { getApiClient } from "~/lib/api"; -import { getUserOrganizations } from "~/lib/auth/client"; -import { OnboardingContent } from "./-components/content"; -import { OnboardingSteps } from "./-lib/steps"; - -export const Route = createFileRoute("/_authed/onboarding/")({ - loader: async () => { - const organizations = await getUserOrganizations(); - if (organizations.length === 0) { - return { organizations: organizations, existingProjects: [] }; - } - - const existingProjects = await getApiClient().projects.list({ limit: 1 }); - return { - organizations: organizations, - existingProjects: existingProjects.data, - }; - }, - component: OnboardingPage, -}); - -function OnboardingPage() { - const { existingProjects, organizations } = Route.useLoaderData(); - - return ( - - - - ); -} diff --git a/apps/mentions/src/styles.css b/apps/mentions/src/styles.css deleted file mode 100644 index 420f960bf..000000000 --- a/apps/mentions/src/styles.css +++ /dev/null @@ -1,31 +0,0 @@ -@import "@rectangular-labs/ui/styles.css"; -@import "@rectangular-labs/content/styles.css"; -@source "./**/*.{ts,tsx}"; - -:root { - --radius: 0.8rem; - --fun-pink: 336 100% 67%; /* hot pink */ - --fun-yellow: 45 100% 51%; /* sunny yellow */ - --fun-blue: 210 100% 56%; /* bright blue */ -} - -.marketing-fun-bg { - background: - radial-gradient(1200px 400px at 10% -10%, hsl(var(--fun-pink)/0.12), transparent 60%), - radial-gradient(900px 300px at 110% 10%, hsl(var(--fun-blue)/0.12), transparent 60%), - radial-gradient(800px 300px at -10% 90%, hsl(var(--fun-yellow)/0.12), transparent 60%); -} - -/* Fun accent gradient on headings */ -.fun-gradient-text { - background-image: linear-gradient(180deg, hsl(var(--fun-pink)), hsl(var(--fun-blue)) 60%, hsl(var(--fun-yellow))); - -webkit-background-clip: text; - background-clip: text; - color: transparent; -} - -/* Card subtle hover float */ -.card:hover { - transform: translateY(-2px); - transition: transform 200ms ease; -} diff --git a/apps/mentions/package.json b/apps/seo/package.json similarity index 96% rename from apps/mentions/package.json rename to apps/seo/package.json index a7fdc3e7b..5339ae05c 100644 --- a/apps/mentions/package.json +++ b/apps/seo/package.json @@ -1,5 +1,5 @@ { - "name": "mentions", + "name": "seo", "private": true, "sideEffects": false, "type": "module", @@ -21,10 +21,10 @@ "cf-typegen": "wrangler types --env-interface Env" }, "dependencies": { + "@rectangular-labs/api-seo": "workspace:*", "@rectangular-labs/auth": "workspace:*", "@rectangular-labs/content": "workspace:*", "@rectangular-labs/db": "workspace:*", - "@rectangular-labs/mentions-api": "workspace:*", "@rectangular-labs/result": "workspace:*", "@rectangular-labs/ui": "workspace:*", "@stepperize/react": "^5.1.7", diff --git a/apps/mentions/public/android-chrome-192x192.png b/apps/seo/public/android-chrome-192x192.png similarity index 100% rename from apps/mentions/public/android-chrome-192x192.png rename to apps/seo/public/android-chrome-192x192.png diff --git a/apps/mentions/public/android-chrome-512x512.png b/apps/seo/public/android-chrome-512x512.png similarity index 100% rename from apps/mentions/public/android-chrome-512x512.png rename to apps/seo/public/android-chrome-512x512.png diff --git a/apps/mentions/public/apple-touch-icon.png b/apps/seo/public/apple-touch-icon.png similarity index 100% rename from apps/mentions/public/apple-touch-icon.png rename to apps/seo/public/apple-touch-icon.png diff --git a/apps/mentions/public/favicon-96x96.png b/apps/seo/public/favicon-96x96.png similarity index 100% rename from apps/mentions/public/favicon-96x96.png rename to apps/seo/public/favicon-96x96.png diff --git a/apps/mentions/public/favicon.ico b/apps/seo/public/favicon.ico similarity index 100% rename from apps/mentions/public/favicon.ico rename to apps/seo/public/favicon.ico diff --git a/apps/mentions/public/favicon.svg b/apps/seo/public/favicon.svg similarity index 100% rename from apps/mentions/public/favicon.svg rename to apps/seo/public/favicon.svg diff --git a/apps/mentions/public/site.webmanifest b/apps/seo/public/site.webmanifest similarity index 100% rename from apps/mentions/public/site.webmanifest rename to apps/seo/public/site.webmanifest diff --git a/apps/mentions/src/components/error-boundary.tsx b/apps/seo/src/components/error-boundary.tsx similarity index 84% rename from apps/mentions/src/components/error-boundary.tsx rename to apps/seo/src/components/error-boundary.tsx index 2403ae399..4e309f12a 100644 --- a/apps/mentions/src/components/error-boundary.tsx +++ b/apps/seo/src/components/error-boundary.tsx @@ -16,8 +16,17 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) { }); console.error(error); - const message = - error instanceof Error ? error.message : "An unexpected error occurred."; + const message = (() => { + if (error instanceof Error) { + try { + const parsed = JSON.parse(error.message); + return parsed[0].problem; + } catch { + return error.message; + } + } + return "An unexpected error occurred."; + })(); return (
diff --git a/apps/mentions/src/components/logout-button.tsx b/apps/seo/src/components/logout-button.tsx similarity index 100% rename from apps/mentions/src/components/logout-button.tsx rename to apps/seo/src/components/logout-button.tsx diff --git a/apps/mentions/src/components/not-found.tsx b/apps/seo/src/components/not-found.tsx similarity index 100% rename from apps/mentions/src/components/not-found.tsx rename to apps/seo/src/components/not-found.tsx diff --git a/apps/mentions/src/lib/api.ts b/apps/seo/src/lib/api.ts similarity index 79% rename from apps/mentions/src/lib/api.ts rename to apps/seo/src/lib/api.ts index afe02125c..3034f6580 100644 --- a/apps/mentions/src/lib/api.ts +++ b/apps/seo/src/lib/api.ts @@ -1,5 +1,5 @@ -import { rpcClient, rqApiClient } from "@rectangular-labs/mentions-api/client"; -import { serverClient } from "@rectangular-labs/mentions-api/server"; +import { rpcClient, rqApiClient } from "@rectangular-labs/api-seo/client"; +import { serverClient } from "@rectangular-labs/api-seo/server"; import { createIsomorphicFn } from "@tanstack/react-start"; import { getWebRequest } from "@tanstack/react-start/server"; import { clientEnv } from "./env"; @@ -23,5 +23,5 @@ export const getApiClient = createIsomorphicFn() export const apiClientRq = rqApiClient( typeof window !== "undefined" ? window.location.origin - : clientEnv().VITE_MENTIONS_URL, + : clientEnv().VITE_SEO_URL, ); diff --git a/apps/seo/src/lib/auth/client.ts b/apps/seo/src/lib/auth/client.ts new file mode 100644 index 000000000..40a7a4d01 --- /dev/null +++ b/apps/seo/src/lib/auth/client.ts @@ -0,0 +1,75 @@ +import { createAuthClient } from "@rectangular-labs/auth/client"; +import { err, ok, safe } from "@rectangular-labs/result"; +import { createIsomorphicFn } from "@tanstack/react-start"; +import { getWebRequest } from "@tanstack/react-start/server"; +import { clientEnv } from "../env"; + +export const authClient = createAuthClient(clientEnv().VITE_SEO_URL); + +export const getCurrentSession = createIsomorphicFn() + .server(async () => { + const { authServerHandler } = await import("./server"); + const request = getWebRequest(); + const session = await authServerHandler.api.getSession({ + headers: request.headers, + }); + return session; + }) + .client(async () => { + const session = await authClient.getSession(); + return session.data; + }); + +export const getUserOrganizations = createIsomorphicFn() + .server(async () => { + const { authServerHandler } = await import("./server"); + const request = getWebRequest(); + const organizations = await safe(() => + authServerHandler.api.listOrganizations({ + headers: request.headers, + }), + ); + + return organizations; + }) + .client(async () => { + const organizations = await authClient.organization.list(); + if (organizations.error) { + return err( + new Error( + organizations.error.message ?? + "Something went wrong loading organizations. Please try again", + ), + ); + } + return ok(organizations.data); + }); + +export const setDefaultOrganization = createIsomorphicFn() + .server( + async (args: { organizationId: string; organizationSlug: string }) => { + const { authServerHandler } = await import("./server"); + const request = getWebRequest(); + const organization = await safe(() => + authServerHandler.api.setActiveOrganization({ + headers: request.headers, + body: args, + }), + ); + return organization; + }, + ) + .client( + async (args: { organizationId: string; organizationSlug: string }) => { + const organization = await authClient.organization.setActive(args); + if (organization.error) { + return err( + new Error( + organization.error.message ?? + "Something went wrong setting the default organization. Please try again", + ), + ); + } + return ok(organization.data); + }, + ); diff --git a/apps/mentions/src/lib/auth/server.ts b/apps/seo/src/lib/auth/server.ts similarity index 58% rename from apps/mentions/src/lib/auth/server.ts rename to apps/seo/src/lib/auth/server.ts index 11fc18595..5e9d4dc08 100644 --- a/apps/mentions/src/lib/auth/server.ts +++ b/apps/seo/src/lib/auth/server.ts @@ -1,8 +1,8 @@ -import { initAuthHandler } from "@rectangular-labs/auth"; +import { type Auth, initAuthHandler } from "@rectangular-labs/auth"; import { createDb } from "@rectangular-labs/db"; import { serverEnv } from "../env"; export const authServerHandler = initAuthHandler( - serverEnv().VITE_MENTIONS_URL, + serverEnv().VITE_SEO_URL, createDb(), -); +) as Auth; diff --git a/apps/mentions/src/lib/env.ts b/apps/seo/src/lib/env.ts similarity index 85% rename from apps/mentions/src/lib/env.ts rename to apps/seo/src/lib/env.ts index d2ee8429e..d50c2a388 100644 --- a/apps/mentions/src/lib/env.ts +++ b/apps/seo/src/lib/env.ts @@ -1,4 +1,4 @@ -import { apiEnv } from "@rectangular-labs/mentions-api/env"; +import { apiEnv } from "@rectangular-labs/api-seo/env"; import { createEnv } from "@t3-oss/env-core"; import { type } from "arktype"; @@ -7,7 +7,7 @@ export const clientEnv = () => extends: [], clientPrefix: "VITE_", client: { - VITE_MENTIONS_URL: type("string"), + VITE_SEO_URL: type("string"), }, runtimeEnv: import.meta.env, emptyStringAsUndefined: true, diff --git a/apps/mentions/src/lib/seo.ts b/apps/seo/src/lib/seo.ts similarity index 90% rename from apps/mentions/src/lib/seo.ts rename to apps/seo/src/lib/seo.ts index d182da511..cc899546b 100644 --- a/apps/mentions/src/lib/seo.ts +++ b/apps/seo/src/lib/seo.ts @@ -13,11 +13,13 @@ export const seo = ({ { title }, { name: "description", content: description }, { name: "keywords", content: keywords }, + { name: "robots", content: "index,follow" }, { name: "twitter:title", content: title }, { name: "twitter:description", content: description }, { name: "twitter:creator", content: "@winston_yeo" }, { name: "twitter:site", content: "@winston_yeo" }, { name: "og:type", content: "website" }, + { name: "og:site_name", content: "SEO" }, { name: "og:title", content: title }, { name: "og:description", content: description }, ...(image diff --git a/apps/mentions/src/reportWebVitals.ts b/apps/seo/src/reportWebVitals.ts similarity index 100% rename from apps/mentions/src/reportWebVitals.ts rename to apps/seo/src/reportWebVitals.ts diff --git a/apps/mentions/src/routeTree.gen.ts b/apps/seo/src/routeTree.gen.ts similarity index 69% rename from apps/mentions/src/routeTree.gen.ts rename to apps/seo/src/routeTree.gen.ts index c4e6a1d3e..f4bb982ce 100644 --- a/apps/mentions/src/routeTree.gen.ts +++ b/apps/seo/src/routeTree.gen.ts @@ -15,15 +15,14 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as MarketingRouteRouteImport } from './routes/_marketing/route' import { Route as AuthedRouteRouteImport } from './routes/_authed/route' import { Route as MarketingIndexRouteImport } from './routes/_marketing/index' -import { Route as AuthedProjectsRouteImport } from './routes/_authed/projects' -import { Route as AuthedOrganizationsRouteImport } from './routes/_authed/organizations' -import { Route as AuthedDashboardRouteImport } from './routes/_authed/dashboard' import { Route as MarketingBlogRouteRouteImport } from './routes/_marketing/blog/route' +import { Route as AuthedOrganizationSlugRouteRouteImport } from './routes/_authed/$organizationSlug/route' import { Route as MarketingBlogIndexRouteImport } from './routes/_marketing/blog/index' import { Route as AuthedOnboardingIndexRouteImport } from './routes/_authed/onboarding/index' +import { Route as AuthedOrganizationSlugIndexRouteImport } from './routes/_authed/$organizationSlug/index' import { Route as MarketingBlogSplatRouteImport } from './routes/_marketing/blog/$' -import { Route as AuthedProjectIdKeywordsRouteImport } from './routes/_authed/$projectId/keywords' -import { Route as AuthedProjectIdInboxRouteImport } from './routes/_authed/$projectId/inbox' +import { Route as AuthedOrganizationSlugSettingRouteImport } from './routes/_authed/$organizationSlug/setting' +import { Route as AuthedOrganizationSlugProjectSlugIndexRouteImport } from './routes/_authed/$organizationSlug/$projectSlug/index' import { ServerRoute as ApiSplatServerRouteImport } from './routes/api/$' import { ServerRoute as ApiRpcSplatServerRouteImport } from './routes/api/rpc.$' import { ServerRoute as MarketingBlogRssDotxmlServerRouteImport } from './routes/_marketing/blog/rss[.]xml' @@ -48,26 +47,17 @@ const MarketingIndexRoute = MarketingIndexRouteImport.update({ path: '/', getParentRoute: () => MarketingRouteRoute, } as any) -const AuthedProjectsRoute = AuthedProjectsRouteImport.update({ - id: '/projects', - path: '/projects', - getParentRoute: () => AuthedRouteRoute, -} as any) -const AuthedOrganizationsRoute = AuthedOrganizationsRouteImport.update({ - id: '/organizations', - path: '/organizations', - getParentRoute: () => AuthedRouteRoute, -} as any) -const AuthedDashboardRoute = AuthedDashboardRouteImport.update({ - id: '/dashboard', - path: '/dashboard', - getParentRoute: () => AuthedRouteRoute, -} as any) const MarketingBlogRouteRoute = MarketingBlogRouteRouteImport.update({ id: '/blog', path: '/blog', getParentRoute: () => MarketingRouteRoute, } as any) +const AuthedOrganizationSlugRouteRoute = + AuthedOrganizationSlugRouteRouteImport.update({ + id: '/$organizationSlug', + path: '/$organizationSlug', + getParentRoute: () => AuthedRouteRoute, + } as any) const MarketingBlogIndexRoute = MarketingBlogIndexRouteImport.update({ id: '/', path: '/', @@ -78,21 +68,29 @@ const AuthedOnboardingIndexRoute = AuthedOnboardingIndexRouteImport.update({ path: '/onboarding/', getParentRoute: () => AuthedRouteRoute, } as any) +const AuthedOrganizationSlugIndexRoute = + AuthedOrganizationSlugIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AuthedOrganizationSlugRouteRoute, + } as any) const MarketingBlogSplatRoute = MarketingBlogSplatRouteImport.update({ id: '/$', path: '/$', getParentRoute: () => MarketingBlogRouteRoute, } as any) -const AuthedProjectIdKeywordsRoute = AuthedProjectIdKeywordsRouteImport.update({ - id: '/$projectId/keywords', - path: '/$projectId/keywords', - getParentRoute: () => AuthedRouteRoute, -} as any) -const AuthedProjectIdInboxRoute = AuthedProjectIdInboxRouteImport.update({ - id: '/$projectId/inbox', - path: '/$projectId/inbox', - getParentRoute: () => AuthedRouteRoute, -} as any) +const AuthedOrganizationSlugSettingRoute = + AuthedOrganizationSlugSettingRouteImport.update({ + id: '/setting', + path: '/setting', + getParentRoute: () => AuthedOrganizationSlugRouteRoute, + } as any) +const AuthedOrganizationSlugProjectSlugIndexRoute = + AuthedOrganizationSlugProjectSlugIndexRouteImport.update({ + id: '/$projectSlug/', + path: '/$projectSlug/', + getParentRoute: () => AuthedOrganizationSlugRouteRoute, + } as any) const ApiSplatServerRoute = ApiSplatServerRouteImport.update({ id: '/api/$', path: '/api/$', @@ -112,86 +110,78 @@ const MarketingBlogRssDotxmlServerRoute = export interface FileRoutesByFullPath { '/login': typeof LoginRoute + '/$organizationSlug': typeof AuthedOrganizationSlugRouteRouteWithChildren '/blog': typeof MarketingBlogRouteRouteWithChildren - '/dashboard': typeof AuthedDashboardRoute - '/organizations': typeof AuthedOrganizationsRoute - '/projects': typeof AuthedProjectsRoute '/': typeof MarketingIndexRoute - '/$projectId/inbox': typeof AuthedProjectIdInboxRoute - '/$projectId/keywords': typeof AuthedProjectIdKeywordsRoute + '/$organizationSlug/setting': typeof AuthedOrganizationSlugSettingRoute '/blog/$': typeof MarketingBlogSplatRoute + '/$organizationSlug/': typeof AuthedOrganizationSlugIndexRoute '/onboarding': typeof AuthedOnboardingIndexRoute '/blog/': typeof MarketingBlogIndexRoute + '/$organizationSlug/$projectSlug': typeof AuthedOrganizationSlugProjectSlugIndexRoute } export interface FileRoutesByTo { '/login': typeof LoginRoute - '/dashboard': typeof AuthedDashboardRoute - '/organizations': typeof AuthedOrganizationsRoute - '/projects': typeof AuthedProjectsRoute '/': typeof MarketingIndexRoute - '/$projectId/inbox': typeof AuthedProjectIdInboxRoute - '/$projectId/keywords': typeof AuthedProjectIdKeywordsRoute + '/$organizationSlug/setting': typeof AuthedOrganizationSlugSettingRoute '/blog/$': typeof MarketingBlogSplatRoute + '/$organizationSlug': typeof AuthedOrganizationSlugIndexRoute '/onboarding': typeof AuthedOnboardingIndexRoute '/blog': typeof MarketingBlogIndexRoute + '/$organizationSlug/$projectSlug': typeof AuthedOrganizationSlugProjectSlugIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_authed': typeof AuthedRouteRouteWithChildren '/_marketing': typeof MarketingRouteRouteWithChildren '/login': typeof LoginRoute + '/_authed/$organizationSlug': typeof AuthedOrganizationSlugRouteRouteWithChildren '/_marketing/blog': typeof MarketingBlogRouteRouteWithChildren - '/_authed/dashboard': typeof AuthedDashboardRoute - '/_authed/organizations': typeof AuthedOrganizationsRoute - '/_authed/projects': typeof AuthedProjectsRoute '/_marketing/': typeof MarketingIndexRoute - '/_authed/$projectId/inbox': typeof AuthedProjectIdInboxRoute - '/_authed/$projectId/keywords': typeof AuthedProjectIdKeywordsRoute + '/_authed/$organizationSlug/setting': typeof AuthedOrganizationSlugSettingRoute '/_marketing/blog/$': typeof MarketingBlogSplatRoute + '/_authed/$organizationSlug/': typeof AuthedOrganizationSlugIndexRoute '/_authed/onboarding/': typeof AuthedOnboardingIndexRoute '/_marketing/blog/': typeof MarketingBlogIndexRoute + '/_authed/$organizationSlug/$projectSlug/': typeof AuthedOrganizationSlugProjectSlugIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/login' + | '/$organizationSlug' | '/blog' - | '/dashboard' - | '/organizations' - | '/projects' | '/' - | '/$projectId/inbox' - | '/$projectId/keywords' + | '/$organizationSlug/setting' | '/blog/$' + | '/$organizationSlug/' | '/onboarding' | '/blog/' + | '/$organizationSlug/$projectSlug' fileRoutesByTo: FileRoutesByTo to: | '/login' - | '/dashboard' - | '/organizations' - | '/projects' | '/' - | '/$projectId/inbox' - | '/$projectId/keywords' + | '/$organizationSlug/setting' | '/blog/$' + | '/$organizationSlug' | '/onboarding' | '/blog' + | '/$organizationSlug/$projectSlug' id: | '__root__' | '/_authed' | '/_marketing' | '/login' + | '/_authed/$organizationSlug' | '/_marketing/blog' - | '/_authed/dashboard' - | '/_authed/organizations' - | '/_authed/projects' | '/_marketing/' - | '/_authed/$projectId/inbox' - | '/_authed/$projectId/keywords' + | '/_authed/$organizationSlug/setting' | '/_marketing/blog/$' + | '/_authed/$organizationSlug/' | '/_authed/onboarding/' | '/_marketing/blog/' + | '/_authed/$organizationSlug/$projectSlug/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -259,27 +249,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MarketingIndexRouteImport parentRoute: typeof MarketingRouteRoute } - '/_authed/projects': { - id: '/_authed/projects' - path: '/projects' - fullPath: '/projects' - preLoaderRoute: typeof AuthedProjectsRouteImport - parentRoute: typeof AuthedRouteRoute - } - '/_authed/organizations': { - id: '/_authed/organizations' - path: '/organizations' - fullPath: '/organizations' - preLoaderRoute: typeof AuthedOrganizationsRouteImport - parentRoute: typeof AuthedRouteRoute - } - '/_authed/dashboard': { - id: '/_authed/dashboard' - path: '/dashboard' - fullPath: '/dashboard' - preLoaderRoute: typeof AuthedDashboardRouteImport - parentRoute: typeof AuthedRouteRoute - } '/_marketing/blog': { id: '/_marketing/blog' path: '/blog' @@ -287,6 +256,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MarketingBlogRouteRouteImport parentRoute: typeof MarketingRouteRoute } + '/_authed/$organizationSlug': { + id: '/_authed/$organizationSlug' + path: '/$organizationSlug' + fullPath: '/$organizationSlug' + preLoaderRoute: typeof AuthedOrganizationSlugRouteRouteImport + parentRoute: typeof AuthedRouteRoute + } '/_marketing/blog/': { id: '/_marketing/blog/' path: '/' @@ -301,6 +277,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedOnboardingIndexRouteImport parentRoute: typeof AuthedRouteRoute } + '/_authed/$organizationSlug/': { + id: '/_authed/$organizationSlug/' + path: '/' + fullPath: '/$organizationSlug/' + preLoaderRoute: typeof AuthedOrganizationSlugIndexRouteImport + parentRoute: typeof AuthedOrganizationSlugRouteRoute + } '/_marketing/blog/$': { id: '/_marketing/blog/$' path: '/$' @@ -308,19 +291,19 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MarketingBlogSplatRouteImport parentRoute: typeof MarketingBlogRouteRoute } - '/_authed/$projectId/keywords': { - id: '/_authed/$projectId/keywords' - path: '/$projectId/keywords' - fullPath: '/$projectId/keywords' - preLoaderRoute: typeof AuthedProjectIdKeywordsRouteImport - parentRoute: typeof AuthedRouteRoute + '/_authed/$organizationSlug/setting': { + id: '/_authed/$organizationSlug/setting' + path: '/setting' + fullPath: '/$organizationSlug/setting' + preLoaderRoute: typeof AuthedOrganizationSlugSettingRouteImport + parentRoute: typeof AuthedOrganizationSlugRouteRoute } - '/_authed/$projectId/inbox': { - id: '/_authed/$projectId/inbox' - path: '/$projectId/inbox' - fullPath: '/$projectId/inbox' - preLoaderRoute: typeof AuthedProjectIdInboxRouteImport - parentRoute: typeof AuthedRouteRoute + '/_authed/$organizationSlug/$projectSlug/': { + id: '/_authed/$organizationSlug/$projectSlug/' + path: '/$projectSlug' + fullPath: '/$organizationSlug/$projectSlug' + preLoaderRoute: typeof AuthedOrganizationSlugProjectSlugIndexRouteImport + parentRoute: typeof AuthedOrganizationSlugRouteRoute } } } @@ -350,21 +333,33 @@ declare module '@tanstack/react-start/server' { } } +interface AuthedOrganizationSlugRouteRouteChildren { + AuthedOrganizationSlugSettingRoute: typeof AuthedOrganizationSlugSettingRoute + AuthedOrganizationSlugIndexRoute: typeof AuthedOrganizationSlugIndexRoute + AuthedOrganizationSlugProjectSlugIndexRoute: typeof AuthedOrganizationSlugProjectSlugIndexRoute +} + +const AuthedOrganizationSlugRouteRouteChildren: AuthedOrganizationSlugRouteRouteChildren = + { + AuthedOrganizationSlugSettingRoute: AuthedOrganizationSlugSettingRoute, + AuthedOrganizationSlugIndexRoute: AuthedOrganizationSlugIndexRoute, + AuthedOrganizationSlugProjectSlugIndexRoute: + AuthedOrganizationSlugProjectSlugIndexRoute, + } + +const AuthedOrganizationSlugRouteRouteWithChildren = + AuthedOrganizationSlugRouteRoute._addFileChildren( + AuthedOrganizationSlugRouteRouteChildren, + ) + interface AuthedRouteRouteChildren { - AuthedDashboardRoute: typeof AuthedDashboardRoute - AuthedOrganizationsRoute: typeof AuthedOrganizationsRoute - AuthedProjectsRoute: typeof AuthedProjectsRoute - AuthedProjectIdInboxRoute: typeof AuthedProjectIdInboxRoute - AuthedProjectIdKeywordsRoute: typeof AuthedProjectIdKeywordsRoute + AuthedOrganizationSlugRouteRoute: typeof AuthedOrganizationSlugRouteRouteWithChildren AuthedOnboardingIndexRoute: typeof AuthedOnboardingIndexRoute } const AuthedRouteRouteChildren: AuthedRouteRouteChildren = { - AuthedDashboardRoute: AuthedDashboardRoute, - AuthedOrganizationsRoute: AuthedOrganizationsRoute, - AuthedProjectsRoute: AuthedProjectsRoute, - AuthedProjectIdInboxRoute: AuthedProjectIdInboxRoute, - AuthedProjectIdKeywordsRoute: AuthedProjectIdKeywordsRoute, + AuthedOrganizationSlugRouteRoute: + AuthedOrganizationSlugRouteRouteWithChildren, AuthedOnboardingIndexRoute: AuthedOnboardingIndexRoute, } diff --git a/apps/mentions/src/router.tsx b/apps/seo/src/router.tsx similarity index 100% rename from apps/mentions/src/router.tsx rename to apps/seo/src/router.tsx diff --git a/apps/mentions/src/routes/__root.tsx b/apps/seo/src/routes/__root.tsx similarity index 86% rename from apps/mentions/src/routes/__root.tsx rename to apps/seo/src/routes/__root.tsx index 0dc646882..5ac2fcd3e 100644 --- a/apps/mentions/src/routes/__root.tsx +++ b/apps/seo/src/routes/__root.tsx @@ -31,9 +31,11 @@ export const Route = createRootRouteWithContext<{ content: "Mentions", }, ...seo({ - title: "Mentions — Monitor keywords, generate replies, publish fast", + title: "The AI SEO employee — Understand. Plan. Forecast. Ship.", description: - "Track brand and product mentions, curate an inbox, generate helpful replies with AI, and publish to Reddit in one flow.", + "Hire the first AI SEO employee. Understand your site, plan campaigns by intent, forecast ranking ranges, and schedule content that ships.", + keywords: + "AI SEO employee, SEO automation, SEO forecasting, content calendar, keyword clusters", }), ], links: [ diff --git a/apps/seo/src/routes/_authed/$organizationSlug/$projectSlug/index.tsx b/apps/seo/src/routes/_authed/$organizationSlug/$projectSlug/index.tsx new file mode 100644 index 000000000..a6733ac41 --- /dev/null +++ b/apps/seo/src/routes/_authed/$organizationSlug/$projectSlug/index.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute( + "/_authed/$organizationSlug/$projectSlug/", +)({ + component: RouteComponent, +}); + +function RouteComponent() { + return
Hello "/_authed/$organizationSlug/$projectSlug/"!
; +} diff --git a/apps/seo/src/routes/_authed/$organizationSlug/index.tsx b/apps/seo/src/routes/_authed/$organizationSlug/index.tsx new file mode 100644 index 000000000..824a90a45 --- /dev/null +++ b/apps/seo/src/routes/_authed/$organizationSlug/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/_authed/$organizationSlug/")({ + component: RouteComponent, +}); + +function RouteComponent() { + return
Hello "/_authed/$organizationSlug/"!
; +} diff --git a/apps/seo/src/routes/_authed/$organizationSlug/route.tsx b/apps/seo/src/routes/_authed/$organizationSlug/route.tsx new file mode 100644 index 000000000..abcccdbd8 --- /dev/null +++ b/apps/seo/src/routes/_authed/$organizationSlug/route.tsx @@ -0,0 +1,53 @@ +import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; +import { getApiClient } from "~/lib/api"; +import { + getUserOrganizations, + setDefaultOrganization, +} from "~/lib/auth/client"; + +export const Route = createFileRoute("/_authed/$organizationSlug")({ + beforeLoad: async ({ params, location }) => { + console.log("params", params); + // auto route if org slug is 'organization' + if (params.organizationSlug === "organization") { + const [organizations, activeOrganization] = await Promise.all([ + getUserOrganizations(), + getApiClient().organization.active(), + ]); + if (!organizations.ok) { + throw new Error(organizations.error.message); + } + const [organization] = organizations.value; + // redirect users to onboarding if they are not in an organization + if (!organization) { + throw redirect({ + to: "/onboarding", + }); + } + + // already has an active organization, let's go to it + if (activeOrganization?.slug) { + throw redirect({ + to: location.href.replace("organization", activeOrganization.slug), + }); + } + + // no active organization, set the default organization + const result = await setDefaultOrganization({ + organizationId: organization.id, + organizationSlug: organization.slug, + }); + if (!result.ok) { + throw new Error(result.error.message); + } + throw redirect({ + to: location.href.replace("organization", organization.slug), + }); + } + }, + component: RouteComponent, +}); + +function RouteComponent() { + return ; +} diff --git a/apps/mentions/src/routes/_authed/organizations.tsx b/apps/seo/src/routes/_authed/$organizationSlug/setting.tsx similarity index 86% rename from apps/mentions/src/routes/_authed/organizations.tsx rename to apps/seo/src/routes/_authed/$organizationSlug/setting.tsx index 58ee614e3..fd7002ac0 100644 --- a/apps/mentions/src/routes/_authed/organizations.tsx +++ b/apps/seo/src/routes/_authed/$organizationSlug/setting.tsx @@ -9,11 +9,11 @@ import { import { Separator } from "@rectangular-labs/ui/components/ui/separator"; import { createFileRoute } from "@tanstack/react-router"; -export const Route = createFileRoute("/_authed/organizations")({ - component: OrganizationsPage, +export const Route = createFileRoute("/_authed/$organizationSlug/setting")({ + component: OrganizationSettingPage, }); -function OrganizationsPage() { +function OrganizationSettingPage() { return (
diff --git a/apps/mentions/src/routes/_authed/onboarding/-components/0-welcome.tsx b/apps/seo/src/routes/_authed/onboarding/-components/0-welcome.tsx similarity index 100% rename from apps/mentions/src/routes/_authed/onboarding/-components/0-welcome.tsx rename to apps/seo/src/routes/_authed/onboarding/-components/0-welcome.tsx diff --git a/apps/mentions/src/routes/_authed/onboarding/-components/1-user-background.tsx b/apps/seo/src/routes/_authed/onboarding/-components/1-user-background.tsx similarity index 99% rename from apps/mentions/src/routes/_authed/onboarding/-components/1-user-background.tsx rename to apps/seo/src/routes/_authed/onboarding/-components/1-user-background.tsx index 0a8066bfc..35c495c34 100644 --- a/apps/mentions/src/routes/_authed/onboarding/-components/1-user-background.tsx +++ b/apps/seo/src/routes/_authed/onboarding/-components/1-user-background.tsx @@ -119,7 +119,7 @@ export function OnboardingUserBackground({ return (
- + {title} {description} diff --git a/apps/mentions/src/routes/_authed/onboarding/-components/4-review-organization.tsx b/apps/seo/src/routes/_authed/onboarding/-components/2-create-organization.tsx similarity index 73% rename from apps/mentions/src/routes/_authed/onboarding/-components/4-review-organization.tsx rename to apps/seo/src/routes/_authed/onboarding/-components/2-create-organization.tsx index 6c8fd63ca..ac4db4195 100644 --- a/apps/mentions/src/routes/_authed/onboarding/-components/4-review-organization.tsx +++ b/apps/seo/src/routes/_authed/onboarding/-components/2-create-organization.tsx @@ -22,20 +22,22 @@ import { Input } from "@rectangular-labs/ui/components/ui/input"; import { type } from "arktype"; import { authClient } from "~/lib/auth/client"; import { OnboardingSteps } from "../-lib/steps"; +import { toSlug } from "../-lib/to-slug"; const backgroundSchema = type({ - name: "string.alphanumeric", - "description?": "string", + name: type("string").atLeastLength(1), }); -export function OnboardingReviewOrganization() { + +export function OnboardingCreateOrganization() { const matcher = OnboardingSteps.useStepper(); const form = useForm({ resolver: arktypeResolver(backgroundSchema), }); const handleSubmit = async (values: typeof backgroundSchema.infer) => { + const slug = toSlug(values.name); const valid = await authClient.organization.checkSlug({ - slug: values.name, + slug, }); if (valid.error) { form.setError("root", { @@ -45,17 +47,16 @@ export function OnboardingReviewOrganization() { }); return; } - if (!valid.data?.status) { + if (!valid.data?.status || slug === "organization") { form.setError("name", { - message: "Organization name already taken, please choose another one!", + message: "Organization name already taken, please choose another one.", }); return; } const organizationResult = await authClient.organization.create({ name: values.name, - slug: values.name, - metadata: { description: values.description }, + slug, }); if (organizationResult.error) { form.setError("root", { @@ -70,16 +71,19 @@ export function OnboardingReviewOrganization() { return (
- + - Review Organization + Set Up Organization Your organization will let you manage team members and projects.
- - + + - ( - - - Organization Description{" "} - (optional) - - - - - - - )} - /> - {form.formState.errors.root && ( {form.formState.errors.root.message} )} diff --git a/apps/mentions/src/routes/_authed/onboarding/-components/2-company-background.tsx b/apps/seo/src/routes/_authed/onboarding/-components/3-website-info.tsx similarity index 89% rename from apps/mentions/src/routes/_authed/onboarding/-components/2-company-background.tsx rename to apps/seo/src/routes/_authed/onboarding/-components/3-website-info.tsx index 06d367cb3..aff22a510 100644 --- a/apps/mentions/src/routes/_authed/onboarding/-components/2-company-background.tsx +++ b/apps/seo/src/routes/_authed/onboarding/-components/3-website-info.tsx @@ -27,7 +27,7 @@ const backgroundSchema = type({ url: type("string.url").configure({ message: () => "Must be a valid URL" }), }); -export function OnboardingCompanyBackground({ +export function OnboardingWebsiteInfo({ description, title, }: { @@ -35,17 +35,17 @@ export function OnboardingCompanyBackground({ title: string; }) { const matcher = OnboardingSteps.useStepper(); - const form = useForm({ resolver: arktypeResolver(backgroundSchema), }); - const { mutate: crawlInfo, isPending } = useMutation( - apiClientRq.companyBackground.crawlInfo.mutationOptions({ + const { mutateAsync: startUnderstanding, isPending } = useMutation( + apiClientRq.companyBackground.understandSite.mutationOptions({ onSuccess: (data, { websiteUrl }) => { - matcher.setMetadata("user-company", { + matcher.setMetadata("website-info", { websiteUrl, - crawlId: data.id, + taskId: data.taskId, + projectId: data.projectId, }); matcher.next(); }, @@ -58,14 +58,14 @@ export function OnboardingCompanyBackground({ ); const handleSubmit = (values: typeof backgroundSchema.infer) => { - crawlInfo({ + startUnderstanding({ websiteUrl: values.url, }); }; return (
- + {title} {description} diff --git a/apps/seo/src/routes/_authed/onboarding/-components/4-understanding-site.tsx b/apps/seo/src/routes/_authed/onboarding/-components/4-understanding-site.tsx new file mode 100644 index 000000000..30fd57b0d --- /dev/null +++ b/apps/seo/src/routes/_authed/onboarding/-components/4-understanding-site.tsx @@ -0,0 +1,133 @@ +import { Button } from "@rectangular-labs/ui/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@rectangular-labs/ui/components/ui/card"; +import { Progress } from "@rectangular-labs/ui/components/ui/progress"; +import { toast } from "@rectangular-labs/ui/components/ui/sonner"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { apiClientRq } from "~/lib/api"; +import { OnboardingSteps } from "../-lib/steps"; + +export function OnboardingUnderstandingSite({ + description, + title, +}: { + title: string; + description: string; +}) { + const matcher = OnboardingSteps.useStepper(); + const { taskId, projectId, websiteUrl } = matcher.getMetadata<{ + taskId: string; + projectId: string; + websiteUrl: string; + }>("website-info"); + const [currentTaskId, setCurrentTaskId] = useState(taskId); + const autoWentNext = matcher.getMetadata("understanding-site"); + const { data: status, error: getStatusError } = useQuery( + apiClientRq.companyBackground.getUnderstandSiteStatus.queryOptions({ + refetchInterval: 5_000, + input: { + id: currentTaskId, + }, + }), + ); + const { mutate: retry, isPending } = useMutation( + apiClientRq.companyBackground.understandSite.mutationOptions({ + onSuccess: (data) => { + setCurrentTaskId(data.taskId); + toast.success("Retrying understanding site"); + }, + onError: () => { + toast.error("Failed to retry understanding site"); + }, + }), + ); + + const goNext = () => { + if (!status?.websiteInfo) { + toast.error("No website info found"); + return; + } + matcher.setMetadata("understanding-site", { + websiteUrl, + projectId, + ...status?.websiteInfo, + }); + matcher.next(); + }; + + const isCompleted = status?.status === "completed"; + const needsRetry = + status?.status === "failed" || status?.status === "cancelled"; + + if (isCompleted && !autoWentNext) { + matcher.setMetadata("understanding-site", { + websiteUrl, + projectId, + ...status?.websiteInfo, + }); + matcher.next(); + } + + return ( +
+ + + {title} + {description} + + +
+
+ {status?.statusMessage ?? + getStatusError?.message ?? + "We are setting things up..."} +
+ +
+
+ +
+ + {!isCompleted && !needsRetry && ( + + )} + {isCompleted && ( + + )} + {needsRetry && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/seo/src/routes/_authed/onboarding/-components/5-review-project.tsx b/apps/seo/src/routes/_authed/onboarding/-components/5-review-project.tsx new file mode 100644 index 000000000..7126c3c6b --- /dev/null +++ b/apps/seo/src/routes/_authed/onboarding/-components/5-review-project.tsx @@ -0,0 +1,250 @@ +import { Button } from "@rectangular-labs/ui/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@rectangular-labs/ui/components/ui/card"; +import { + arktypeResolver, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + useForm, +} from "@rectangular-labs/ui/components/ui/form"; +import { Input } from "@rectangular-labs/ui/components/ui/input"; +import { Textarea } from "@rectangular-labs/ui/components/ui/textarea"; +import { useMutation } from "@tanstack/react-query"; +import { type } from "arktype"; +import { apiClientRq } from "~/lib/api"; +import { OnboardingSteps } from "../-lib/steps"; +import { toSlug } from "../-lib/to-slug"; + +const formSchema = type({ + projectId: type("string.uuid"), + name: type("string") + .atLeastLength(1) + .configure({ + message: () => "Name is required", + }), + websiteUrl: type("string.url") + .atLeastLength(1) + .configure({ + message: () => "Must be a valid URL", + }), + businessOverview: type("string") + .atLeastLength(1) + .configure({ + message: () => "Business Overview is required", + }), + idealCustomer: type("string") + .atLeastLength(1) + .configure({ + message: () => "Ideal Customer is required", + }), + serviceRegion: type("string") + .atLeastLength(1) + .configure({ + message: () => "Service Region is required", + }), + industry: type("string") + .atLeastLength(1) + .configure({ + message: () => "Industry is required", + }), +}); + +export function OnboardingReviewProject() { + const matcher = OnboardingSteps.useStepper(); + + const defaultValues = + matcher.getMetadata< + Partial + >("understanding-site"); + const form = useForm({ + resolver: arktypeResolver(formSchema), + defaultValues: { + projectId: defaultValues?.projectId || "", + name: defaultValues?.name || "", + websiteUrl: defaultValues?.websiteUrl || "", + businessOverview: defaultValues?.businessOverview || "", + idealCustomer: defaultValues?.idealCustomer || "", + serviceRegion: defaultValues?.serviceRegion || "", + industry: defaultValues?.industry || "", + }, + }); + + const { mutate: updateProject, isPending } = useMutation( + apiClientRq.projects.update.mutationOptions({ + onSuccess: (data) => { + matcher.setMetadata("review-project", { + slug: data.slug, + name: data.name, + }); + matcher.next(); + }, + onError: () => { + form.setError("root", { + message: "Failed to update project. Please try again later.", + }); + }, + }), + ); + + const handleSubmit = (values: typeof formSchema.infer) => { + const slug = toSlug(values.name); + updateProject({ + id: values.projectId, + websiteUrl: values.websiteUrl, + name: values.name, + slug, + websiteInfo: { + version: "v1", + businessOverview: values.businessOverview, + idealCustomer: values.idealCustomer, + serviceRegion: values.serviceRegion, + industry: values.industry, + }, + }); + }; + + return ( +
+ + + {matcher.current.title} + {matcher.current.description} + + + + + ( + + Name + + + + + + )} + /> + + ( + + Website URL + + + + + + )} + /> + + ( + + Service Region + + + + + + )} + /> + + ( + + Industry + + + + + + )} + /> + + ( + + Business Overview + +