Compare commits

...

23 Commits

Author SHA1 Message Date
Henry
ed8187c956 chore: compile to ts 2026-02-20 21:25:48 +00:00
Henry
541d160222 chore: update translations format 2026-02-20 21:21:57 +00:00
Henry
71ecf2e6d3 fix: catch and log failed email attempts 2026-02-20 15:42:07 +00:00
Henry
8a9cb8fabf chore: trigger docker publish after translate workflow 2026-02-19 22:30:19 +00:00
Henry
37f6be52ee chore: improve translation workflow 2026-02-19 22:26:20 +00:00
hjball
b72449bef1 chore: compile translations 2026-02-19 21:39:32 +00:00
Henry
5cb47a588f fix: update file pattern for translation workflow 2026-02-19 21:38:25 +00:00
Lingo.dev
d5c2404715 chore: update translations 2026-02-19 21:23:58 +00:00
Henry
0276c51ac7 fix: correct broken workflow 2026-02-19 21:22:58 +00:00
Henry
1eee819c9d fix: typo 2026-02-19 21:20:40 +00:00
Henry
2113cc7873 fix: update translate workflow 2026-02-19 21:19:46 +00:00
Henry
32714ccedf fix: update commit message format in translation workflow 2026-02-19 21:12:41 +00:00
Henry
e72b34b541 feat: setup translation workflow 2026-02-19 21:11:02 +00:00
Henry
b44a1296a0 feat: update remote patterns to be more permissive 2026-02-19 14:38:09 +00:00
Henry
3bf132286b fix: use correct context 2026-02-19 13:17:49 +00:00
Henry
c9c3365c0a fix: use correct path to dockerfile 2026-02-19 13:15:06 +00:00
Henry
7c2937a7b7 fix: add migrator container name to docker compose 2026-02-19 13:12:38 +00:00
Henry
f27a031ea8 feat(cloud): add migrator to docker-compose 2026-02-19 12:52:58 +00:00
Henry
05a0245988 feat: improve pricing tiers and add comparison table (#405) 2026-02-18 22:46:32 +00:00
Matt
b6b9423823 fix: increase max workspace url length from 24 to 64 characters (closes #379( (#398) 2026-02-17 13:40:58 +00:00
Owais Rizvi
1696aab43b fix: handle empty commenter name in mention notification emails (#400)
Use `||` instead of `??` so empty strings also fall back to email.
The nullish coalescing operator (`??`) only catches null/undefined,
so users with an empty name string would appear nameless in emails.
2026-02-17 13:39:24 +00:00
Nick Meinhold
33d2f23955 feat(db): add webhook schema, migration, and repository (#391)
* feat(db): add webhook schema, migration, and repository

Add the database foundation for workspace webhooks:

- Add workspace_webhooks table with migration (webhook_event enum,
  URL, secret, event subscriptions, active flag)
- Add webhook repository with CRUD operations
- Add webhooks schema definition with relations
- Extend card and list repos to return board/list names for
  webhook payload context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(db): remove unused webhook_event enum and fix migration timestamp

- Remove dead webhook_event pgEnum from schema (events column uses text)
- Remove CREATE TYPE statement from migration SQL
- Fix migration journal timestamp to be chronologically after the
  notifications migration (idx 25)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(db): return boardPublicId from card and list repo queries

Add board publicId to getWorkspaceAndCardIdByCardPublicId and
getWorkspaceAndListIdByListPublicId return values, needed for
correct boardId in webhook payloads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(db): add workspaceId index and document secret exposure

- Add index on workspaceId for efficient webhook lookups per workspace
- Add JSDoc comment on getActiveByWorkspaceId explaining that it
  returns secrets for server-side HMAC signing only and must never
  be exposed via client-facing endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(db): extract parseEvents helper in webhook repo

DRY up 6 repeated JSON.parse-and-cast calls into a single helper
function, addressing reviewer feedback on PR #391.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:23:00 +00:00
Morfixx
961219835e refactor: docker image for less final size (#385) 2026-02-17 13:12:53 +00:00
58 changed files with 62770 additions and 29347 deletions

View File

@@ -1,18 +1,62 @@
# Environment
.env .env
.env.*
!.env.example
docker-compose.override.yml # Docker
docker-compose*.yml
Dockerfile
./**/*/Dockerfile
.dockerignore .dockerignore
# Dependencies (rebuilt in Docker)
node_modules node_modules
./**/*/node_modules **/node_modules
# Build outputs (rebuilt in Docker)
**/.next
**/dist
**/out
**/.turbo
**/.cache
# Git
.git
.gitignore
.gitattributes
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# CI/CD
.github
.husky
.changeset
# Documentation (not needed in build)
*.md
!packages/db/migrations/**
LICENSE
CONTRIBUTING.md
AGENTS.md
CHANGELOG.md
# Test files
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
**/__tests__
**/coverage
# Logs
pnpm-debug.log pnpm-debug.log
./**/*/pnpm-debug.log **/pnpm-debug.log
README.md # Cloud compose (separate deployment)
.next cloud/
# .git

View File

@@ -9,11 +9,17 @@ on:
schedule: schedule:
- cron: "21 21 * * *" - cron: "21 21 * * *"
push: push:
branches: ["main"] # Only trigger on tags (releases), not main branch pushes
# Publish semver tags as releases. # Main branch builds are handled by workflow_run after Translate completes
tags: ["v*.*.*"] tags: ["v*.*.*"]
pull_request: pull_request:
branches: ["main"] branches: ["main"]
workflow_run:
workflows: ["Translate"]
types: [completed]
branches: [main]
# Docker builds after Translate completes (success or failure)
# This ensures Docker always has the latest translations
env: env:
# Use docker.io for Docker Hub if empty # Use docker.io for Docker Hub if empty
@@ -23,6 +29,9 @@ env:
jobs: jobs:
build: build:
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read

49
.github/workflows/translate.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Translate
on:
push:
branches: [main]
permissions:
contents: write
jobs:
translate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Extract translations
working-directory: apps/web
run: npx --yes @lingui/cli extract
- name: Lingo.dev
uses: lingodotdev/lingo.dev@main
with:
api-key: ${{ secrets.LINGODOTDEV_API_KEY }}
commit-message: "chore: update translations"
working-directory: apps/web
commit-author-name: "${{ github.actor }}"
commit-author-email: "${{ github.actor }}@users.noreply.github.com"
parallel: true
- name: Compile translations
working-directory: apps/web
run: npx --yes @lingui/cli compile --typescript
- name: Commit compiled translations
run: |
git config --local user.email "${{ github.actor }}@users.noreply.github.com"
git config --local user.name "${{ github.actor }}"
if ls apps/web/src/locales/*/messages.ts 1> /dev/null 2>&1; then
git add apps/web/src/locales/*/messages.ts
git diff --staged --quiet || (git commit -m "chore: compile translations" && git push)
fi

View File

@@ -1,25 +1,23 @@
ARG NODE_VERSION=20 # syntax=docker/dockerfile:1.7
ARG APP_DIRNAME=web
ARG PROJECT=@kan/web
# 1. Alpine image ARG NODE_VERSION=20
ARG DISTROLESS_NODE_IMAGE=gcr.io/distroless/nodejs${NODE_VERSION}-debian12
# ============================================
# Stage 1: Alpine base with pnpm and turbo
# ============================================
FROM node:${NODE_VERSION}-alpine AS alpine FROM node:${NODE_VERSION}-alpine AS alpine
RUN apk update && \ RUN apk update && \
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \ apk add --no-cache libc6-compat && \
rm -rf /var/cache/apk/* /tmp/* || true rm -rf /var/cache/apk/* /tmp/* || true && \
corepack enable && \
npm install turbo@2.3.1 --global && \
pnpm config set store-dir ~/.pnpm-store
# Setup pnpm and turbo on the alpine base # ============================================
FROM alpine AS base # Stage 2: Prune the monorepo
RUN corepack enable # ============================================
# Replace <your-major-version> with the major version installed in your repository. For example: FROM alpine AS pruner
# RUN npm install turbo@2.1.3 --global
RUN npm install turbo@2.3.1 --global
RUN pnpm config set store-dir ~/.pnpm-store
# 2. Prune projects
FROM base AS pruner
ARG PROJECT
RUN apk add --no-cache git RUN apk add --no-cache git
@@ -35,53 +33,89 @@ RUN git fetch --tags --unshallow 2>/dev/null || git fetch --tags 2>/dev/null ||
echo "unknown") && \ echo "unknown") && \
echo "$AUTO_VERSION" > /app/AUTO_VERSION echo "$AUTO_VERSION" > /app/AUTO_VERSION
RUN turbo prune --scope=${PROJECT} --scope=@kan/db --docker RUN turbo prune --scope=@kan/web --scope=@kan/db --docker
# 3. Build the project
FROM base AS builder
ARG PROJECT
ARG APP_VERSION
# ============================================
# Stage 3: Install dependencies
# ============================================
FROM alpine AS deps
WORKDIR /app WORKDIR /app
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
ENV CI=true ENV CI=true
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --frozen-lockfile
COPY --from=pruner /app/out/full/ . # ============================================
# Stage 4: Build the web application
# Use provided APP_VERSION or auto-generated from pruner stage # ============================================
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \ FROM alpine AS builder
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT} ARG APP_VERSION
# # Copy static files to standalone directory
# RUN mkdir -p apps/web/.next/standalone/.next && \
# mv apps/web/public apps/web/.next/standalone/ && \
# mv apps/web/.next/static apps/web/.next/standalone/.next/
# 4. Final image - runner stage to run the application
FROM base AS runner
ARG APP_DIRNAME
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
WORKDIR /app WORKDIR /app
COPY --from=deps /app/ ./
COPY --from=pruner /app/out/full/ .
COPY --from=pruner /app/AUTO_VERSION /tmp/AUTO_VERSION
# Force standalone output for production Docker image
ENV NEXT_PUBLIC_USE_STANDALONE_OUTPUT=true
ENV CI=true
RUN VERSION="${APP_VERSION:-$(cat /tmp/AUTO_VERSION 2>/dev/null | tr -d '\n\r' || echo 'unknown')}" && \
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=@kan/web
# ============================================
# Stage 5: Migration image (run-once container)
# ============================================
FROM node:${NODE_VERSION}-alpine AS migrate
WORKDIR /db
COPY packages/db/drizzle.config.ts ./drizzle.config.ts
COPY packages/db/migrations/ ./migrations/
RUN npm init -y && \
npm install drizzle-kit drizzle-orm pg --save-exact && \
# Strip unnecessary files from node_modules
find node_modules -type f \( \
-name '*.d.ts' -o \
-name '*.d.mts' -o \
-name '*.d.cts' -o \
-name '*.map' -o \
-name '*.md' -o \
-name '*.txt' -o \
-name 'LICENSE*' -o \
-name 'CHANGELOG*' -o \
-name 'README*' -o \
-name '.eslint*' -o \
-name '.prettier*' -o \
-name 'tsconfig*.json' \
\) -delete && \
find node_modules -type d -empty -delete && \
rm -rf /root/.npm /tmp/*
CMD ["npx", "drizzle-kit", "migrate"]
# ============================================
# Stage 6: Production web image (distroless)
# ============================================
FROM ${DISTROLESS_NODE_IMAGE} AS web
WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --chown=nextjs:nodejs --from=builder /app/ ./ # Copy the standalone Next.js server
WORKDIR /app/apps/${APP_DIRNAME} COPY --from=builder /app/apps/web/.next/standalone/ ./
# Copy static assets and public files
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
ARG PORT=3000 # Copy bootstrap script for runtime env var injection
ENV PORT=${PORT} COPY apps/web/bootstrap.cjs ./bootstrap.cjs
EXPOSE ${PORT}
CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"] EXPOSE 3000
CMD ["bootstrap.cjs"]

44
apps/web/bootstrap.cjs Normal file
View File

@@ -0,0 +1,44 @@
/**
* Bootstrap script for the distroless production image.
*
* Distroless images have no shell, so this Node.js script handles two tasks
* that would normally be done in an entrypoint.sh:
*
* 1. Regenerate `public/__ENV.js` with the current runtime NEXT_PUBLIC_*
* environment variables. The file was originally created at build time by
* next-runtime-env's `configureRuntimeEnv()`, but in a Docker deployment the
* env vars are provided at *run* time via docker-compose / docker run.
*
* 2. Start the Next.js standalone server.
*/
const { writeFileSync, existsSync, mkdirSync } = require("fs");
const path = require("path");
// ---------------------------------------------------------------------------
// 1. Inject runtime NEXT_PUBLIC_* env vars into __ENV.js
// ---------------------------------------------------------------------------
const publicDir = path.join(__dirname, "apps", "web", "public");
if (!existsSync(publicDir)) {
mkdirSync(publicDir, { recursive: true });
}
const envVars = {};
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith("NEXT_PUBLIC_")) {
envVars[key] = value;
}
}
writeFileSync(
path.join(publicDir, "__ENV.js"),
`self.__ENV = ${JSON.stringify(envVars)};`,
);
// ---------------------------------------------------------------------------
// 2. Start the Next.js standalone server
// ---------------------------------------------------------------------------
require("./apps/web/server.js");

View File

@@ -1,12 +1,12 @@
{ {
"version": 0, "version": "1.10",
"locale": { "locale": {
"source": "en", "source": "en",
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"] "targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"]
}, },
"buckets": { "buckets": {
"po": { "json": {
"include": ["src/locales/[locale]/messages.po"] "include": ["src/locales/[locale]/messages.json"]
} }
}, },
"$schema": "https://lingo.dev/schema/i18n.json" "$schema": "https://lingo.dev/schema/i18n.json"

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,9 @@
import type { LinguiConfig } from "@lingui/conf"; import { defineConfig } from "@lingui/cli";
import { formatter } from "@lingui/format-json";
const config: LinguiConfig = {
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","pt-BR"], export default defineConfig({
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl", "pt-BR"],
sourceLocale: "en", sourceLocale: "en",
catalogs: [ catalogs: [
{ {
@@ -10,6 +12,5 @@ const config: LinguiConfig = {
exclude: ["**/node_modules/**"], exclude: ["**/node_modules/**"],
}, },
], ],
}; format: formatter({ style: "lingui" }),
});
export default config;

View File

@@ -16,6 +16,18 @@ const config = {
: undefined, : undefined,
reactStrictMode: true, reactStrictMode: true,
/** Exclude build tools and dev-only packages from the standalone output */
outputFileTracingExcludes: {
"**/*": [
"@esbuild/**",
"esbuild/**",
"typescript/**",
"webpack/**",
"uglify-js/**",
"terser/**",
],
},
/** Enables hot reloading for local packages without a build step */ /** Enables hot reloading for local packages without a build step */
transpilePackages: [ transpilePackages: [
"@kan/api", "@kan/api",
@@ -35,49 +47,13 @@ const config = {
remotePatterns: (() => { remotePatterns: (() => {
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */ /** @type {Array<{protocol: "http" | "https", hostname: string}>} */
const patterns = [ const patterns = [
{ { protocol: "https", hostname: "**" },
protocol: "https",
hostname:
env("S3_FORCE_PATH_STYLE") === "true"
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
},
{ {
protocol: "http", protocol: "http",
hostname: "localhost", hostname: "localhost",
}, },
{
protocol: "https",
hostname: "*.googleusercontent.com",
},
{
protocol: 'https',
hostname: 'cdn.discordapp.com',
},
]; ];
// Extract root domain from S3_ENDPOINT and add wildcard pattern
const s3Endpoint = env("S3_ENDPOINT");
if (s3Endpoint) {
try {
const url = new URL(s3Endpoint);
const hostname = url.hostname;
const protocol = url.protocol.replace(":", "");
// Extract root domain (last 2 parts: e.g. cloudflarestorage.com)
const parts = hostname.split(".");
if (parts.length >= 2) {
const rootDomain = parts.slice(-2).join(".");
patterns.push({
protocol: protocol === "http" ? "http" : "https",
hostname: `*.${rootDomain}`,
});
}
} catch {
// If S3_ENDPOINT is not a valid URL, ignore it
}
}
return patterns; return patterns;
})(), })(),
}, },

View File

@@ -79,6 +79,7 @@
"@kan/tailwind-config": "workspace:*", "@kan/tailwind-config": "workspace:*",
"@kan/tsconfig": "workspace:*", "@kan/tsconfig": "workspace:*",
"@lingui/cli": "^5.3.2", "@lingui/cli": "^5.3.2",
"@lingui/format-json": "^5.9.1",
"@lingui/loader": "^5.3.2", "@lingui/loader": "^5.3.2",
"@lingui/swc-plugin": "^5.5.2", "@lingui/swc-plugin": "^5.5.2",
"@svgr/webpack": "^8.1.0", "@svgr/webpack": "^8.1.0",

View File

@@ -33,7 +33,7 @@ const schema = z.object({
.min(3, { .min(3, {
message: t`URL must be at least 3 characters long`, message: t`URL must be at least 3 characters long`,
}) })
.max(24, { message: t`URL cannot exceed 24 characters` }) .max(64, { message: t`URL cannot exceed 64 characters` })
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, { .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`, message: t`URL can only contain letters, numbers, and hyphens`,
}) })

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -11,7 +11,7 @@ import { withRateLimit } from "@kan/api/utils/rateLimit";
const workspaceSlugSchema = z const workspaceSlugSchema = z
.string() .string()
.min(3) .min(3)
.max(24) .max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/); .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/);
interface CheckoutSessionRequest { interface CheckoutSessionRequest {

View File

@@ -1,293 +1,587 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import React, { useEffect, useRef, useState } from "react";
import { HiCheckCircle, HiXCircle } from "react-icons/hi2"; import { HiCheckCircle, HiXCircle } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
interface Feature {
key: string;
label: string;
kan: boolean | string;
trello: boolean | string;
}
interface Section {
name: string;
features: Feature[];
}
type FrequencyValue = "monthly" | "annually"; type FrequencyValue = "monthly" | "annually";
const CellValue = ({ value }: { value: boolean | string }) => { interface PlanFeature {
if (typeof value === "string") { key: string;
return ( label: string;
<span className="text-sm text-dark-900 dark:text-dark-900">{value}</span> free: string | boolean | { text: string; highlight?: string };
); teams: string | boolean | { text: string; highlight?: string };
} pro: string | boolean | { text: string; highlight?: string };
return value ? ( enterprise: string | boolean | { text: string; highlight?: string };
<HiCheckCircle className="h-5 w-5 text-light-950 dark:text-dark-1000" /> }
) : (
<HiXCircle className="h-5 w-5 text-light-700 dark:text-dark-600" /> interface FeatureSection {
); name: string;
}; features: PlanFeature[];
}
const FeatureComparisonTable = ({ const FeatureComparisonTable = ({
frequencyValue, frequencyValue: _frequencyValue,
}: { }: {
frequencyValue: FrequencyValue; frequencyValue: FrequencyValue;
}) => { }) => {
const sections: Section[] = [ // Keep pricing column headers visible as you scroll
const containerRef = useRef<HTMLDivElement | null>(null);
const [isHeaderFixed, setIsHeaderFixed] = useState(false);
const [headerRect, setHeaderRect] = useState<{ left: number; width: number }>({
left: 0,
width: 0,
});
useEffect(() => {
const HEADER_OFFSET = 64; // px; matches fixed top nav height
const handleScroll = () => {
if (!containerRef.current) return;
// Hide fixed header on mobile (screens smaller than 620px)
if (window.innerWidth < 620) {
setIsHeaderFixed(false);
return;
}
const rect = containerRef.current.getBoundingClientRect();
const pastHeader = rect.top <= HEADER_OFFSET;
const aboveBottom = rect.bottom > HEADER_OFFSET + 40;
const shouldFix = pastHeader && aboveBottom;
setIsHeaderFixed(shouldFix);
if (shouldFix) {
setHeaderRect({
left: rect.left,
width: rect.width,
});
}
};
handleScroll();
window.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleScroll);
};
}, []);
const planSpecificLimits: PlanFeature[] = [
{ {
name: t`Core features`, key: "boards",
features: [ label: t`Boards`,
{ free: { text: t`Unlimited boards`, highlight: "Unlimited" },
key: "ulimited-workspaces", teams: { text: t`Unlimited boards`, highlight: "Unlimited" },
label: t`Unlimited workspaces`, pro: { text: t`Unlimited boards`, highlight: "Unlimited" },
kan: true, enterprise: { text: t`Unlimited boards`, highlight: "Unlimited" },
trello: false,
},
{
key: "unlimited-boards",
label: t`Unlimited boards`,
kan: true,
trello: false,
},
{
key: "unlimited-lists",
label: t`Unlimited lists`,
kan: true,
trello: true,
},
{
key: "unlimited-cards",
label: t`Unlimited cards`,
kan: true,
trello: true,
},
{
key: "activity-log",
label: t`Activity log`,
kan: true,
trello: true,
},
{
key: "templates",
label: t`Custom templates`,
kan: true,
trello: false,
},
{ key: "labels", label: t`Labels & filters`, kan: true, trello: true },
{ key: "checklists", label: t`Checklists`, kan: true, trello: true },
{
key: "import",
label: t`Import from Trello`,
kan: true,
trello: false,
},
{
key: "visibility",
label: t`Board visibility`,
kan: true,
trello: true,
},
{
key: "search",
label: t`Intelligent search`,
kan: true,
trello: true,
},
{
key: "workspace-url",
label: t`Custom workspace link`,
kan: true,
trello: false,
},
],
}, },
{ {
name: t`Teams`, key: "members",
features: [ label: t`Members`,
{ free: { text: t`1 user`, highlight: "1" },
key: "pricing", teams: { text: t`Per-seat pricing`, highlight: "Per-seat" },
label: t`Price per user/month`, pro: { text: t`Unlimited members`, highlight: "Unlimited" },
kan: frequencyValue === "monthly" ? `$10.00` : `$8.00`, enterprise: { text: t`Unlimited members`, highlight: "Unlimited" },
trello: frequencyValue === "monthly" ? `$12.00` : `$10.00`, },
}, {
{ key: "file-uploads",
key: "comments", label: t`File uploads`,
label: t`Comments & mentions`, free: { text: t`10mb file uploads`, highlight: "10mb" },
kan: true, teams: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
trello: true, pro: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
}, enterprise: { text: t`Unlimited file uploads`, highlight: "Unlimited" },
{ },
key: "assignments", {
label: t`Assignees`, key: "workspace-username",
kan: true, label: t`Workspace username`,
trello: true, free: { text: t`Default username`, highlight: "Default" },
}, teams: { text: t`Default username`, highlight: "Default" },
{ pro: { text: t`Custom username`, highlight: "Custom" },
key: "sharing", enterprise: { text: t`Custom username`, highlight: "Custom" },
label: t`Board sharing & invites`, },
kan: true, ];
trello: true,
}, const coreFeatures: PlanFeature[] = [
{ {
key: "invite-links", key: "checklists",
label: t`Invite links`, label: t`Checklists`,
kan: true, free: true,
trello: true, teams: true,
}, pro: true,
], enterprise: true,
},
{
key: "custom-templates",
label: t`Board templates`,
free: true,
teams: true,
pro: true,
enterprise: true,
},
{
key: "labels-filters",
label: t`Labels & filters`,
free: true,
teams: true,
pro: true,
enterprise: true,
},
{
key: "board-visibility",
label: t`Board visibility`,
free: true,
teams: true,
pro: true,
enterprise: true,
},
{
key: "search",
label: t`Intelligent search`,
free: true,
teams: true,
pro: true,
enterprise: true,
},
{
key: "activity-log",
label: t`Activity log`,
free: true,
teams: true,
pro: true,
enterprise: true,
},
{
key: "comments",
label: t`Comments`,
free: true,
teams: true,
pro: true,
enterprise: true,
},
{
key: "roles-permissions",
label: t`Roles & permissions`,
free: false,
teams: false,
pro: true,
enterprise: true,
},
{
key: "custom-branding",
label: t`Custom branding`,
free: false,
teams: false,
pro: true,
enterprise: true,
},
];
const collaborationFeatures: PlanFeature[] = [
{
key: "mentions",
label: t`Mentions`,
free: false,
teams: true,
pro: true,
enterprise: true,
},
{
key: "email-notifications",
label: t`Email notifications`,
free: false,
teams: true,
pro: true,
enterprise: true,
},
{
key: "assignees",
label: t`Assignees`,
free: false,
teams: true,
pro: true,
enterprise: true,
},
{
key: "invite-links",
label: t`Invite links`,
free: false,
teams: true,
pro: true,
enterprise: true,
},
];
const importsFeatures: PlanFeature[] = [
{
key: "import-export",
label: t`Trello`,
free: true,
teams: true,
pro: true,
enterprise: true,
},
];
const platformFeatures: PlanFeature[] = [
{
key: "open-source",
label: t`Open source`,
free: true,
teams: true,
pro: true,
enterprise: true,
},
{
key: "rest-api",
label: t`API`,
free: { text: t`Limited API access` },
teams: { text: t`Full API access` },
pro: { text: t`Full API access` },
enterprise: { text: t`Full API access` },
},
{
key: "self-hostable",
label: t`On-premise`,
free: false,
teams: false,
pro: false,
enterprise: true,
},
];
const securityFeatures: PlanFeature[] = [
{
key: "sso",
label: t`SSO`,
free: { text: t`Google SSO` },
teams: { text: t`Google SSO` },
pro: { text: t`Google SSO` },
enterprise: { text: t`Google SSO + SAML` },
},
{
key: "admin-roles",
label: t`Admin roles`,
free: { text: t`Admin roles` },
teams: { text: t`Admin roles` },
pro: { text: t`Admin roles` },
enterprise: { text: t`Advanced admin roles` },
},
];
const supportFeatures: PlanFeature[] = [
{
key: "priority-email-support",
label: t`Priority email support`,
free: false,
teams: true,
pro: true,
enterprise: true,
},
{
key: "account-manager",
label: t`Account manager`,
free: false,
teams: false,
pro: false,
enterprise: true,
},
{
key: "sla",
label: t`SLA`,
free: false,
teams: false,
pro: false,
enterprise: true,
},
];
const sections: FeatureSection[] = [
{
name: "",
features: planSpecificLimits,
},
{
name: t`Core features`,
features: coreFeatures,
},
{
name: t`Collaboration`,
features: collaborationFeatures,
},
{
name: t`Imports`,
features: importsFeatures,
}, },
{ {
name: t`Platform`, name: t`Platform`,
features: [ features: platformFeatures,
{ },
key: "api", {
label: t`REST API`, name: t`Security`,
kan: true, features: securityFeatures,
trello: true, },
}, {
{ name: t`Support`,
key: "open-source", features: supportFeatures,
label: t`Open source`,
kan: true,
trello: false,
},
{
key: "self-hostable",
label: t`Self-hostable`,
kan: true,
trello: false,
},
{
key: "white-label",
label: t`White label`,
kan: true,
trello: false,
},
{
key: "performance",
label: t`Fast & lightweight`,
kan: true,
trello: false,
},
{
key: "this-is-a-joke",
label: t`Owned by Atlassian`,
kan: false,
trello: true,
},
],
}, },
]; ];
const products = [ const plans = [
{ { id: "free", name: t`Free`, tierId: "tier-free" },
id: "kan", { id: "teams", name: t`Teams`, tierId: "tier-teams" },
name: t`Kan`, { id: "pro", name: t`Pro`, tierId: "tier-pro" },
featured: true, { id: "enterprise", name: t`Enterprise`, tierId: "tier-enterprise" },
},
{
id: "trello",
name: t`Trello`,
featured: false,
},
]; ];
const isHighlightValue = (
value: string | boolean | { text: string; highlight?: string },
): value is { text: string; highlight?: string } => {
return typeof value === "object" && value !== null && "text" in value;
};
const shouldHighlight = (text: string): boolean => {
const lowerText = text.toLowerCase();
return ["unlimited", "multiple", "per-seat", "custom", "mentions", "full", "sso", "admin"].some(
(keyword) => lowerText.includes(keyword),
);
};
const CellValue = ({
value,
label,
}: {
value: string | boolean | { text: string; highlight?: string };
label: string;
}) => {
// Handle object with text and highlight
if (isHighlightValue(value)) {
const { text, highlight } = value;
const isUnlimited = shouldHighlight(text);
let displayText;
if (highlight) {
const parts = text.split(new RegExp(`(${highlight})`, "gi"));
displayText = (
<>
{parts.map((part, index) =>
part.toLowerCase() === highlight.toLowerCase() ? (
<span key={index} className="text-light-1000 dark:text-dark-1000">
{part}
</span>
) : (
<span key={index} className="text-light-950 dark:text-dark-800">
{part}
</span>
),
)}
</>
);
} else {
displayText = text;
}
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle
className={twMerge(
"mt-0.5 h-4 w-4 shrink-0",
isUnlimited
? "text-light-1000 dark:text-dark-1000"
: "text-light-400 dark:text-dark-600",
)}
/>
<span
className={twMerge(
"text-sm font-medium",
isUnlimited
? "text-light-1000 dark:text-dark-950"
: "text-light-950 dark:text-dark-800",
)}
>
{displayText}
</span>
</div>
);
}
if (typeof value === "string") {
const isUnlimited = shouldHighlight(value);
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle
className={twMerge(
"mt-0.5 h-4 w-4 shrink-0",
isUnlimited
? "text-light-1000 dark:text-dark-1000"
: "text-light-400 dark:text-dark-600",
)}
/>
<span
className={twMerge(
"text-sm font-medium",
isUnlimited
? "text-light-1000 dark:text-dark-950"
: "text-light-950 dark:text-dark-800",
)}
>
{value}
</span>
</div>
);
}
if (value) {
// Boolean true - show checkmark icon
return (
<div className="flex items-center gap-2.5">
<HiCheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000" />
<span className="text-sm font-medium text-light-1000 dark:text-dark-950">
{label}
</span>
</div>
);
}
// Boolean false - show cross icon
return (
<div className="flex items-center gap-2.5">
<HiXCircle className="mt-0.5 h-4 w-4 shrink-0 text-light-400 dark:text-dark-600" />
<span className="text-sm font-medium text-light-800 dark:text-dark-800">
{label}
</span>
</div>
);
};
const getFeatureValue = (
feature: PlanFeature,
planId: string,
): string | boolean | { text: string; highlight?: string } => {
switch (planId) {
case "free":
return feature.free;
case "teams":
return feature.teams;
case "pro":
return feature.pro;
case "enterprise":
return feature.enterprise;
default:
return false;
}
};
return ( return (
<section aria-labelledby="comparison-heading" className="w-full px-4"> <section aria-labelledby="comparison-heading" className="w-full">
<div className="mx-auto max-w-6xl py-8 lg:py-10"> <div className="mx-auto max-w-7xl">
<div className="grid grid-cols-3 gap-x-8 border-t border-light-500 before:block dark:border-dark-300"> <div
{products.map((product) => ( ref={containerRef}
<div key={product.id} aria-hidden="true" className="-mt-px"> className="relative overflow-x-auto rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50"
<div >
className={`${product.featured ? "border-dark-200 dark:border-dark-800" : "border-transparent"} border-t-2 pt-8`} {isHeaderFixed && (
> <div
<p className="hidden sm:block fixed z-40 border-b border-light-300 bg-light-50/95 dark:border-dark-400 dark:bg-dark-50/95"
className={`${product.featured ? "text-dark-50 dark:text-dark-1000" : "text-light-1000 dark:text-dark-1000"} text-center text-sm font-semibold`} style={{
> top: 64,
{product.name} left: headerRect.left,
</p> width: headerRect.width,
}}
>
<div className="grid grid-cols-4 border-x border-light-300 dark:border-dark-300">
{plans.map((plan) => {
const isMostPopular = plan.id === "pro";
return (
<div
key={`fixed-${plan.id}`}
className={twMerge(
"flex items-center px-6 py-3 text-left text-base font-semibold",
isMostPopular
? "bg-light-200 text-light-1000 dark:bg-dark-100 dark:text-dark-1000"
: "bg-light-50 text-dark-50 dark:bg-dark-50 dark:text-dark-1000",
)}
>
{plan.name}
{plan.id === "pro" && <span className="ml-1 text-xl"></span>}
</div>
);
})}
</div> </div>
</div> </div>
))} )}
</div> <table className="w-full border-separate border-spacing-0">
<thead>
<div className="-mt-6 space-y-12"> <tr>
{sections.map((section) => ( {plans.map((plan) => {
<div key={section.name}> const isMostPopular = plan.id === "pro";
<h4 className="text-sm font-semibold text-light-1000 dark:text-dark-1000"> return (
{section.name} <th
</h4> key={plan.id}
<div className="relative -mx-8 mt-8"> scope="col"
<div className={twMerge(
aria-hidden="true" "w-1/4 px-6 py-4 text-left text-base font-semibold",
className="absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block" isMostPopular
> ? "bg-light-200 text-light-1000 dark:bg-dark-100 dark:text-dark-1000"
{products.map((product) => ( : "bg-light-50 text-dark-50 dark:bg-dark-50 dark:text-dark-1000",
<div )}
key={product.id} >
className={`size-full rounded-lg ${product.featured ? "bg-white shadow-sm dark:bg-dark-50 dark:shadow-none" : ""}`} <span className="flex items-center">
/> {plan.name}
))} {plan.id === "pro" && <span className="ml-1 text-xl"></span>}
</div> </span>
</th>
<table className="relative w-full border-separate border-spacing-x-8"> );
<thead> })}
<tr className="text-left"> </tr>
<th scope="col"> </thead>
<span className="sr-only">{t`Feature`}</span> <tbody>
</th> {sections.map((section, sectionIdx) => (
{products.map((p) => ( <React.Fragment key={section.name || `section-${sectionIdx}`}>
<th key={p.id} scope="col"> {section.name && (
<span className="sr-only">{p.name}</span> <tr>
</th> {plans.map((plan) => {
))} const isPro = plan.id === "pro";
</tr> return (
</thead>
<tbody>
{section.features.map((feature, featureIdx) => (
<tr key={feature.key}>
<th
scope="row"
className="w-1/3 py-3 pr-4 text-left text-sm font-normal text-light-1000 dark:text-dark-1000"
>
{feature.label}
{featureIdx !== section.features.length - 1 ? (
<div className="absolute inset-x-8 mt-3 h-px bg-light-500 dark:bg-dark-300" />
) : null}
</th>
{products.map((p) => (
<td <td
key={p.id} key={plan.id}
className="relative w-1/3 px-4 py-0 text-center" className={twMerge(
"border-t px-6 py-3 text-left text-sm font-semibold text-dark-900 dark:text-dark-900",
isPro
? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
: "border-light-300 bg-light-50 dark:border-dark-400 dark:bg-dark-50",
)}
> >
<span className="relative inline-flex w-full items-center justify-center py-3"> {plan.id === "free" ? section.name : ""}
<CellValue
value={
p.id === "kan" ? feature.kan : feature.trello
}
/>
</span>
</td> </td>
))} );
</tr> })}
))} </tr>
</tbody> )}
</table> {section.features.map((feature) => (
<tr key={feature.key}>
<div {plans.map((plan) => {
aria-hidden="true" const isPro = plan.id === "pro";
className="pointer-events-none absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block" return (
> <td
{products.map((product) => ( key={plan.id}
<div className={twMerge(
key={product.id} "w-1/4 border-t px-6 py-3 text-left",
className={`${product.featured ? "ring-1 ring-light-500 dark:ring-dark-800" : "ring-0"} rounded-2xl`} isPro
/> ? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
: "border-light-300 dark:border-dark-400",
)}
>
<CellValue
value={getFeatureValue(feature, plan.id)}
label={feature.label}
/>
</td>
);
})}
</tr>
))} ))}
</div> </React.Fragment>
</div> ))}
</div> </tbody>
))} </table>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -23,81 +23,115 @@ const Pricing = ({
}) => { }) => {
const tiers = [ const tiers = [
{ {
name: t`Individuals`, name: t`Free`,
id: "tier-individuals", id: "tier-free",
href: "signup", href: "signup",
buttonText: t`Get Started`, buttonText: t`Get started`,
price: { monthly: t`Free`, annually: t`Free` }, price: { monthly: t`$0`, annually: t`$0` },
description: t`Everything you need, free forever. Unlimited boards, unlimited lists, unlimited cards. Upgrade any time.`, description: t`Free for everyone`,
featureHeader: t`Free, forever`, bestFor: t`Best for individuals and solo creators getting started`,
featureHeader: undefined,
features: [ features: [
t`1 user`, { text: t`1 user` },
t`Unlimited boards`, { text: t`Unlimited boards` },
t`Unlimited cards`, { text: t`Unlimited cards` },
t`Custom board templates`, { text: t`Custom board templates` },
t`Checklists`, { text: t`Checklists` },
], ],
showPrice: true, showPrice: true,
ctaSubtext: undefined,
mostPopular: false,
highlighted: false,
}, },
{ {
name: t`Teams`, name: t`Teams`,
id: "tier-teams", id: "tier-teams",
href: "signup", href: "signup",
buttonText: t`Get Started`, buttonText: t`Get started`,
price: { monthly: "$10.00", annually: "$8.00" }, price: { monthly: "$10", annually: "$8" },
description: t`Kan is better with a team. Perfect for small and growing teams looking to collaborate.`, description: t`Perfect for small and growing teams looking to collaborate.`,
featureHeader: t`Everything in the free plan, plus:`, bestFor: t`Best for small and growing teams that need collaboration`,
featureHeader: undefined,
features: [ features: [
t`Workspace members`, { text: t`All Free features +` },
t`Admin roles`, { text: t`Workspace members` },
t`Priority email support`, { text: t`Invite links` },
t`Support the development of the project`, { text: t`Unlimited file uploads` },
{ text: t`Email notifications for mentions` },
{ text: t`Priority email support` },
],
highlighted: false,
showPrice: true,
showPriceSuffix: true,
ctaSubtext: t`14 day free trial · Switch plans or cancel anytime`,
mostPopular: false,
},
{
name: t`Pro`,
id: "tier-pro",
href: "signup",
buttonText: t`Get started`,
price: { monthly: "$29", annually: "$24" },
description: t`Unlimited seats and advanced features for growing teams.`,
bestFor: t`Best for teams that want to scale without limits`,
featureHeader: undefined,
features: [
{ text: t`All Teams features +` },
{ text: t`Unlimited members` },
{ text: t`Custom workspace username` },
{ text: t`Configurable user roles and permissions` },
{ text: t`Custom branding` },
], ],
highlighted: true, highlighted: true,
showPrice: true, showPrice: true,
showPriceSuffix: true, showPriceSuffix: false,
ctaSubtext: t`14 day free trial · Switch plans or cancel anytime`,
mostPopular: true,
}, },
{ {
name: t`Self Host`, name: t`Enterprise`,
id: "tier-self-host", id: "tier-enterprise",
href: "https://github.com/kanbn/kan", href: "mailto:support@kan.bn?subject=Enterprise Inquiry",
buttonText: t`View docs`, buttonText: t`Contact Sales`,
price: { monthly: "-", annually: "-" }, price: { monthly: t`Contact us`, annually: t`Contact us` },
description: t`Host Kan on your own infrastructure. Ideal for organisations that need complete control over their data.`, description: t`Advanced security, compliance, and dedicated support for large organizations.`,
featureHeader: t`Complete control and ownership:`, bestFor: t`Best for large organizations with advanced needs`,
featureHeader: undefined,
features: [ features: [
t`Run on your own infrastructure`, { text: t`All Pro features +` },
t`Own your data`, { text: t`SAML SSO` },
t`Custom domain`, { text: t`Advanced admin controls` },
{ text: t`Dedicated account manager` },
{ text: t`Custom SLA` },
{ text: t`On-premise deployment option` },
], ],
highlighted: false,
showPrice: true,
showPriceSuffix: false,
ctaSubtext: undefined,
mostPopular: false, mostPopular: false,
showPrice: false,
}, },
]; ];
return ( return (
<> <>
<div className="flex flex-col items-center justify-center px-4 pb-10"> <div className="mx-auto max-w-7xl px-4">
<div className="mt-14 flex flex-col items-center justify-center"> <div className="mb-8 flex items-center justify-center">
<div className="mb-8 flex items-center gap-2 rounded-full border bg-white px-4 py-1.5 text-center text-xs font-bold text-gray-800 dark:border-dark-300 dark:bg-dark-1000 dark:text-gray-800 lg:text-sm">
<HiBolt />
<p>{t`Launch offer: unlimited seats for just $29/month with Pro`}</p>
</div>
<fieldset aria-label={t`Payment frequency`}> <fieldset aria-label={t`Payment frequency`}>
<RadioGroup <RadioGroup
value={frequency} value={frequency}
onChange={(value) => setFrequency(value)} onChange={(value) => setFrequency(value)}
className="grid grid-cols-2 gap-x-1 rounded-full p-1 text-center text-xs/5 font-semibold ring-1 ring-inset ring-light-600 dark:ring-dark-600" className="flex gap-1 rounded-lg border border-light-300 bg-light-50 p-1 dark:border-dark-300 dark:bg-dark-50"
> >
{frequencies.map((option) => ( {frequencies.map((option) => (
<Radio <Radio
key={option.value} key={option.value}
value={option} value={option}
className={twMerge( className={twMerge(
"cursor-pointer rounded-full px-2.5 py-1 text-xs transition-colors lg:text-sm", "cursor-pointer rounded-md px-4 py-1.5 text-sm font-medium transition-colors",
frequency?.value === option.value frequency?.value === option.value
? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50" ? "bg-white text-light-1000 shadow-sm dark:bg-dark-100 dark:text-dark-1000"
: "text-light-900 hover:bg-light-100 dark:hover:bg-dark-100", : "text-light-600 dark:text-dark-700",
)} )}
> >
{option.label} {option.label}
@@ -106,103 +140,129 @@ const Pricing = ({
</RadioGroup> </RadioGroup>
</fieldset> </fieldset>
</div> </div>
</div>
<div className="isolate mx-auto mb-10 grid max-w-md grid-cols-1 gap-8 px-4 lg:mx-0 lg:max-w-none lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{tiers.map((tier) => ( {tiers.map((tier) => (
<div <div
key={tier.id} key={tier.id}
className={twMerge( className={twMerge(
tier.highlighted "relative flex h-full flex-col rounded-lg border p-6",
? "bg-dark-50 ring-1 ring-dark-50 dark:ring-dark-800" tier.mostPopular
: "bg-light-50 ring-1 ring-light-300 dark:bg-dark-50 dark:ring-dark-300", ? "border-light-400 bg-light-200 dark:border-dark-400 dark:bg-dark-100"
"rounded-3xl p-8 xl:p-10", : "border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50",
)} )}
> >
<div className="flex items-center justify-between gap-x-4"> {tier.id === "tier-pro" && (
<h3 <div className="absolute right-2 top-2 z-10">
id={tier.id} <span className="inline-flex items-center gap-1 rounded-full border border-light-400 bg-light-100 px-3 py-1 text-center text-xs text-dark-600 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900">
className={twMerge( <HiBolt className="h-3 w-3 -ml-0.5" />
tier.highlighted {t`Launch offer`}
? "text-dark-1000 dark:text-dark-1000" </span>
: "text-dark-50 dark:text-dark-1000", </div>
"text-lg/8 font-semibold", )}
<h3
id={tier.id}
className={twMerge(
"mb-4 flex items-center text-base font-semibold",
tier.mostPopular
? "text-light-1000 dark:text-dark-1000"
: "text-light-1000 dark:text-dark-1000",
)}
>
{tier.name}
{tier.id === "tier-pro" && <span className="ml-1 text-xl leading-none"></span>}
</h3>
<div className="mb-4">
<p className="flex items-baseline gap-x-1">
<span
className={twMerge(
"text-2xl font-semibold",
tier.mostPopular
? "text-light-1000 dark:text-dark-1000"
: "text-light-1000 dark:text-dark-1000",
!tier.showPrice && "opacity-0",
)}
>
{tier.price[frequency?.value ?? "monthly"]}
</span>
{tier.id === "tier-pro" && (
<span
className={twMerge(
"text-2xl font-semibold line-through",
tier.mostPopular
? "text-light-600 dark:text-dark-600"
: "text-light-600 dark:text-dark-600",
)}
>
{frequency?.value === "annually" ? "$79" : "$100"}
</span>
)} )}
> {tier.showPriceSuffix && (
{tier.name} <span className="text-sm font-normal text-dark-400 dark:text-dark-700">
</h3> {frequency?.priceSuffix}
{tier.highlighted && ( </span>
<p className="rounded-full bg-light-50 px-2.5 py-1 text-[12px] font-semibold text-dark-500 dark:bg-dark-1000 dark:text-dark-50"> )}
{t`14 day free trial`} {tier.id === "tier-pro" && (
</p> <span className="text-sm font-normal text-dark-400 dark:text-dark-700">
{t`/month`}
</span>
)}
</p>
</div>
{tier.bestFor && (
<p className="mb-6 text-sm text-dark-400 dark:text-dark-700">
{tier.bestFor}
</p>
)}
<div className="mb-6">
{tier.id === "tier-enterprise" ? (
<a
href={tier.href}
aria-describedby={tier.id}
className="block w-full rounded-md border border-dark-200 bg-transparent px-4 py-2 text-center text-sm font-medium text-dark-50 transition-colors hover:bg-dark-50 hover:text-light-50 dark:border-dark-300 dark:text-dark-1000 dark:hover:bg-dark-200"
>
{tier.buttonText}
</a>
) : (
<>
<Link
href={tier.href}
aria-describedby={tier.id}
className={twMerge(
"block w-full rounded-md px-4 py-2 text-center text-sm font-medium transition-colors",
tier.mostPopular
? "bg-dark-50 text-light-50 shadow-sm hover:bg-dark-100 dark:bg-dark-1000 dark:text-dark-50 dark:hover:bg-dark-900"
: "border border-dark-200 bg-transparent text-dark-50 transition-colors hover:bg-dark-50 hover:text-light-50 dark:border-dark-300 dark:text-dark-1000 dark:hover:bg-dark-200",
)}
>
{tier.buttonText}
</Link>
</>
)} )}
</div> </div>
<p <ul role="list" className="space-y-2.5">
className={twMerge( {tier.features.map((feature, index) => (
"mt-4 text-sm/6 text-dark-950 dark:text-dark-900", <li
tier.highlighted ? "text-light-100" : "text-dark-50", key={`${tier.id}-feature-${index}`}
)} className="flex items-start gap-x-2.5"
> >
{tier.description} <HiCheckCircle
</p> className="mt-0.5 h-4 w-4 shrink-0 text-light-1000 dark:text-dark-1000"
<p className="mt-6 flex items-baseline gap-x-1"> />
<span <span className="text-sm text-dark-600 dark:text-dark-800">
className={twMerge( {feature.text}
"text-3xl font-semibold tracking-tight text-light-100", </span>
tier.highlighted
? "text-light-50 dark:text-dark-1000"
: "text-gray-900 dark:text-dark-1000",
!tier.showPrice && "opacity-0",
)}
>
{tier.price[frequency?.value ?? "monthly"]}
</span>
{tier.showPriceSuffix && (
<span className="text-sm/6 font-semibold text-light-50 dark:text-dark-900">
{frequency?.priceSuffix}
</span>
)}
</p>
<Link
href={tier.href}
aria-describedby={tier.id}
className={twMerge(
tier.highlighted
? "bg-light-50 text-dark-50 shadow-sm dark:bg-dark-1000 dark:text-dark-50"
: "bg-dark-50 text-light-50 dark:bg-dark-200 dark:text-dark-1000",
"mt-6 block rounded-md px-3 py-2 text-center text-sm/6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600",
)}
>
{tier.buttonText}
</Link>
<p
className={twMerge(
"mt-8 text-sm/6 font-bold",
tier.highlighted
? "text-light-100 dark:text-dark-1000"
: "text-dark-50 dark:text-dark-1000",
)}
>
{tier.featureHeader}
</p>
<ul
role="list"
className={twMerge(
"mt-2 space-y-3 text-sm/6 text-light-600",
tier.highlighted
? "text-light-100 dark:text-dark-1000"
: "text-dark-50 dark:text-dark-1000",
)}
>
{tier.features.map((feature) => (
<li key={feature} className="flex items-center gap-x-3">
<HiCheckCircle className="h-5 w-5" />
{feature}
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
))} ))}
</div>
<div className="mx-auto mt-8 max-w-3xl px-4 text-center">
<div className="inline-flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
<p>{t`14 day free trial · Switch plans or cancel anytime`}</p>
</div>
</div>
</div> </div>
</> </>
); );

View File

@@ -43,8 +43,8 @@ export default function PricingView() {
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl"> <p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">
{t`Simple pricing`} {t`Simple pricing`}
</p> </p>
<p className="text:md lg:text-md mt-6 max-w-[500px] text-center text-light-950 dark:text-dark-900"> <p className="text:md lg:text-md mt-6 max-w-[300px] text-center text-light-950 dark:text-dark-900">
{t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`} {t`Start free. Upgrade as your team grows. No hidden fees, no contracts.`}
</p> </p>
</div> </div>
<PricingTiers <PricingTiers
@@ -59,23 +59,9 @@ export default function PricingView() {
</div> </div>
<div className="pb-22 flex flex-col items-center justify-center px-4"> <div className="pb-22 flex flex-col items-center justify-center px-4">
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
<p>{t`Features`}</p>
</div>
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
{t`Feature breakdown`}
</p>
<p className="text-md lg:text-md my-4 max-w-[500px] text-center text-light-950 dark:text-dark-900">
{t`Compare our features to see why Kan is the best choice.`}
</p>
<div className="mt-2">
<Button variant="primary" href="/signup">
{t`Get started`}
</Button>
</div>
<div className="mt-10 w-full max-w-[900px]"> <div className="mt-10 w-full">
<FeatureComparisonTable <FeatureComparisonTable
frequencyValue={frequency?.value ?? "annually"} frequencyValue={frequency?.value ?? "annually"}
/> />

View File

@@ -33,7 +33,7 @@ const UpdateWorkspaceUrlForm = ({
.min(3, { .min(3, {
message: t`URL must be at least 3 characters long`, message: t`URL must be at least 3 characters long`,
}) })
.max(24, { message: t`URL cannot exceed 24 characters` }) .max(64, { message: t`URL cannot exceed 64 characters` })
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, { .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: t`URL can only contain letters, numbers, and hyphens`, message: t`URL can only contain letters, numbers, and hyphens`,
}), }),

View File

@@ -1,4 +1,17 @@
services: services:
migrator:
image: ghcr.io/kanbn/kan-migrate:latest
container_name: ${MIGRATOR_CONTAINER_NAME:-kan-migrate}
build:
context: ..
dockerfile: apps/web/Dockerfile
target: migrate
networks:
- dokploy-network
environment:
- POSTGRES_URL=${POSTGRES_URL}
restart: "no"
web: web:
image: ghcr.io/kanbn/kan:latest image: ghcr.io/kanbn/kan:latest
container_name: ${CONTAINER_NAME:-kan-web} container_name: ${CONTAINER_NAME:-kan-web}
@@ -9,6 +22,7 @@ services:
build: build:
context: .. context: ..
dockerfile: apps/web/Dockerfile dockerfile: apps/web/Dockerfile
target: web
args: args:
APP_VERSION: ${APP_VERSION:-} APP_VERSION: ${APP_VERSION:-}
env_file: env_file:
@@ -79,6 +93,9 @@ services:
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID} - NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY} - NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST} - NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
depends_on:
migrator:
condition: service_completed_successfully
networks: networks:
dokploy-network: dokploy-network:

View File

@@ -1,4 +1,20 @@
services: services:
migrate:
image: ghcr.io/kanbn/kan-migrate:latest
container_name: ${CONTAINER_NAME:-kan-migrate}
networks:
- kan-network
build:
context: .
dockerfile: ./apps/web/Dockerfile
target: migrate
environment:
- POSTGRES_URL=${POSTGRES_URL}
depends_on:
postgres:
condition: service_healthy
restart: "no"
web: web:
image: ghcr.io/kanbn/kan:latest image: ghcr.io/kanbn/kan:latest
container_name: ${CONTAINER_NAME:-kan-web} container_name: ${CONTAINER_NAME:-kan-web}
@@ -6,10 +22,10 @@ services:
- "${WEB_PORT:-3000}:3000" - "${WEB_PORT:-3000}:3000"
networks: networks:
- kan-network - kan-network
- dokploy-network
build: build:
context: . context: .
dockerfile: ./apps/web/Dockerfile.new dockerfile: ./apps/web/Dockerfile
target: web
env_file: env_file:
- .env - .env
environment: environment:
@@ -107,7 +123,8 @@ services:
- APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET} - APPLE_CLIENT_SECRET=${APPLE_CLIENT_SECRET}
- APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER} - APPLE_APP_BUNDLE_IDENTIFIER=${APPLE_APP_BUNDLE_IDENTIFIER}
depends_on: depends_on:
- postgres migrate:
condition: service_completed_successfully
restart: unless-stopped restart: unless-stopped
postgres: postgres:
@@ -121,14 +138,17 @@ services:
- 5432:5432 - 5432:5432
volumes: volumes:
- kan_postgres_data:/var/lib/postgresql/data - kan_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U kan -d kan_db"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped restart: unless-stopped
networks: networks:
- kan-network - kan-network
networks: networks:
kan-network: kan-network:
dokploy-network:
external: true
volumes: volumes:
kan_postgres_data: kan_postgres_data:

View File

@@ -218,7 +218,7 @@ export const boardRouter = createTRPCRouter({
workspaceSlug: z workspaceSlug: z
.string() .string()
.min(3) .min(3)
.max(24) .max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/), .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
boardSlug: z boardSlug: z
.string() .string()

View File

@@ -160,7 +160,7 @@ export const workspaceRouter = createTRPCRouter({
workspaceSlug: z workspaceSlug: z
.string() .string()
.min(3) .min(3)
.max(24) .max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/), .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
}), }),
) )
@@ -207,7 +207,7 @@ export const workspaceRouter = createTRPCRouter({
slug: z slug: z
.string() .string()
.min(3) .min(3)
.max(24) .max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/) .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(), .optional(),
}), }),
@@ -290,7 +290,7 @@ export const workspaceRouter = createTRPCRouter({
slug: z slug: z
.string() .string()
.min(3) .min(3)
.max(24) .max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/) .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(), .optional(),
description: z.string().min(3).max(280).optional(), description: z.string().min(3).max(280).optional(),
@@ -424,7 +424,7 @@ export const workspaceRouter = createTRPCRouter({
workspaceSlug: z workspaceSlug: z
.string() .string()
.min(3) .min(3)
.max(24) .max(64)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/), .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
}), }),
) )

View File

@@ -53,7 +53,7 @@ export async function sendMentionEmails({
const commenter = await userRepo.getById(db, commenterUserId); const commenter = await userRepo.getById(db, commenterUserId);
if (!commenter) return; if (!commenter) return;
const commenterName = commenter.name ?? commenter.email; const commenterName = commenter.name?.trim() || commenter.email;
// Get mentioned members with full details (filtered by workspace) // Get mentioned members with full details (filtered by workspace)
const membersWithDetails = await memberRepo.getByPublicIdsWithUsers( const membersWithDetails = await memberRepo.getByPublicIdsWithUsers(

View File

@@ -12,15 +12,13 @@ import { configuredProviders } from "./providers";
export const initAuth = (db: dbClient) => { export const initAuth = (db: dbClient) => {
const baseURL = env("NEXT_PUBLIC_BASE_URL") || env("BETTER_AUTH_URL"); const baseURL = env("NEXT_PUBLIC_BASE_URL") || env("BETTER_AUTH_URL");
const trustedOrigins = env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",") ?? []; const trustedOrigins =
env("BETTER_AUTH_TRUSTED_ORIGINS")?.split(",").filter(Boolean) ?? [];
return betterAuth({ return betterAuth({
secret: env("BETTER_AUTH_SECRET"), secret: env("BETTER_AUTH_SECRET"),
baseURL, baseURL,
trustedOrigins: [ trustedOrigins: [...(baseURL ? [baseURL] : []), ...trustedOrigins],
...(baseURL ? [baseURL] : []),
...trustedOrigins,
],
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
provider: "pg", provider: "pg",
schema: { schema: {

View File

@@ -7,8 +7,8 @@ import * as memberRepo from "@kan/db/repository/member.repo";
import * as subscriptionRepo from "@kan/db/repository/subscription.repo"; import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as userRepo from "@kan/db/repository/user.repo"; import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { generateUID } from "@kan/shared/utils";
import { sendEmail } from "@kan/email"; import { sendEmail } from "@kan/email";
import { generateUID } from "@kan/shared/utils";
import { createStripeClient } from "@kan/stripe"; import { createStripeClient } from "@kan/stripe";
import { socialProvidersPlugin } from "./providers"; import { socialProvidersPlugin } from "./providers";
@@ -139,10 +139,11 @@ export function createPlugins(db: dbClient) {
let newSlug = workspace.publicId; let newSlug = workspace.publicId;
if (workspace.slug !== workspace.publicId) { if (workspace.slug !== workspace.publicId) {
const isPublicIdAvailable = await workspaceRepo.isWorkspaceSlugAvailable( const isPublicIdAvailable =
db, await workspaceRepo.isWorkspaceSlugAvailable(
workspace.publicId, db,
); workspace.publicId,
);
if (!isPublicIdAvailable) { if (!isPublicIdAvailable) {
newSlug = generateUID(); newSlug = generateUID();
} }
@@ -172,69 +173,77 @@ export function createPlugins(db: dbClient) {
magicLink({ magicLink({
expiresIn: 60 * 60 * 24 * 7, // 7 days expiresIn: 60 * 60 * 24 * 7, // 7 days
sendMagicLink: async ({ email, url }) => { sendMagicLink: async ({ email, url }) => {
const decodedUrl = decodeURIComponent(url); try {
console.log("Sending magic link to:", email, "URL:", url); const decodedUrl = decodeURIComponent(url);
console.log( console.log("Sending magic link to:", email, "URL:", url);
"Magic link contains invite:", console.log(
decodedUrl.includes("type=invite"), "Magic link contains invite:",
); decodedUrl.includes("type=invite"),
if (decodedUrl.includes("type=invite")) { );
let inviterName = ""; if (decodedUrl.includes("type=invite")) {
let workspaceName = ""; let inviterName = "";
let workspaceName = "";
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
const callbackUrl = urlObj.searchParams.get("callbackURL"); const callbackUrl = urlObj.searchParams.get("callbackURL");
if (callbackUrl) { if (callbackUrl) {
const callbackParams = new URL( const callbackParams = new URL(
callbackUrl, callbackUrl,
process.env.NEXT_PUBLIC_BASE_URL, process.env.NEXT_PUBLIC_BASE_URL,
).searchParams; ).searchParams;
const memberPublicId = callbackParams.get("memberPublicId"); const memberPublicId = callbackParams.get("memberPublicId");
if (memberPublicId) { if (memberPublicId) {
const member = await memberRepo.getByPublicId( const member = await memberRepo.getByPublicId(
db, db,
memberPublicId, memberPublicId,
); );
if (member) { if (member) {
const [workspace, inviter] = await Promise.all([ const [workspace, inviter] = await Promise.all([
workspaceRepo.getById(db, member.workspaceId), workspaceRepo.getById(db, member.workspaceId),
userRepo.getById(db, member.createdBy), userRepo.getById(db, member.createdBy),
]); ]);
if (workspace) workspaceName = workspace.name; if (workspace) workspaceName = workspace.name;
if (inviter) inviterName = inviter.name ?? ""; if (inviter) inviterName = inviter.name ?? "";
}
} }
} }
} catch (error) {
console.error("Failed to fetch invite details:", error);
} }
} catch (error) {
console.error("Failed to fetch invite details:", error);
}
await sendEmail( await sendEmail(
email,
workspaceName
? `Invitation to join the workspace ${workspaceName}`
: "Invitation to join workspace",
"JOIN_WORKSPACE",
{
magicLoginUrl: url,
inviterName,
workspaceName,
},
);
} else {
await sendEmail(
email,
process.env.NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY === "true"
? "Sign in to your account"
: "Sign in to Kan",
"MAGIC_LINK",
{
magicLoginUrl: url,
},
);
}
} catch (error) {
console.error("Error sending magic link:", {
email, email,
workspaceName url,
? `Invitation to join the workspace ${workspaceName}` error,
: "Invitation to join workspace", });
"JOIN_WORKSPACE",
{
magicLoginUrl: url,
inviterName,
workspaceName,
},
);
} else {
await sendEmail(
email,
process.env.NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY === "true"
? "Sign in to your account"
: "Sign in to Kan",
"MAGIC_LINK",
{
magicLoginUrl: url,
},
);
} }
}, },
}), }),

View File

@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS "workspace_webhooks" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"workspaceId" bigint NOT NULL,
"name" varchar(255) NOT NULL,
"url" varchar(2048) NOT NULL,
"secret" text,
"events" text NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"createdBy" uuid NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
CONSTRAINT "workspace_webhooks_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
ALTER TABLE "workspace_webhooks" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "workspace_webhooks_workspace_idx" ON "workspace_webhooks" USING btree ("workspaceId");--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_webhooks" ADD CONSTRAINT "workspace_webhooks_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -190,6 +190,13 @@
"when": 1770521594167, "when": 1770521594167,
"tag": "20260208033314_AddAttachmentToActivity", "tag": "20260208033314_AddAttachmentToActivity",
"breakpoints": true "breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1771192000000,
"tag": "20260129210000_AddWorkspaceWebhooks",
"breakpoints": true
} }
] ]
} }

View File

@@ -945,12 +945,14 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)), where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
with: { with: {
list: { list: {
columns: {}, columns: { name: true },
with: { with: {
board: { board: {
columns: { columns: {
publicId: true,
workspaceId: true, workspaceId: true,
visibility: true, visibility: true,
name: true,
}, },
}, },
}, },
@@ -964,6 +966,9 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
createdBy: result.createdBy, createdBy: result.createdBy,
workspaceId: result.list.board.workspaceId, workspaceId: result.list.board.workspaceId,
workspaceVisibility: result.list.board.visibility, workspaceVisibility: result.list.board.visibility,
listName: result.list.name,
boardPublicId: result.list.board.publicId,
boardName: result.list.board.name,
} }
: null; : null;
}; };

View File

@@ -419,12 +419,14 @@ export const getWorkspaceAndListIdByListPublicId = async (
listPublicId: string, listPublicId: string,
) => { ) => {
const result = await db.query.lists.findFirst({ const result = await db.query.lists.findFirst({
columns: { id: true, createdBy: true }, columns: { id: true, name: true, createdBy: true },
where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)), where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
with: { with: {
board: { board: {
columns: { columns: {
publicId: true,
workspaceId: true, workspaceId: true,
name: true,
}, },
}, },
}, },
@@ -433,8 +435,11 @@ export const getWorkspaceAndListIdByListPublicId = async (
return result return result
? { ? {
id: result.id, id: result.id,
name: result.name,
createdBy: result.createdBy, createdBy: result.createdBy,
workspaceId: result.board.workspaceId, workspaceId: result.board.workspaceId,
boardPublicId: result.board.publicId,
boardName: result.board.name,
} }
: null; : null;
}; };

View File

@@ -0,0 +1,175 @@
import { and, eq } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import { workspaceWebhooks } from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
import type { WebhookEvent } from "../schema/webhooks";
/** Parse JSON-encoded events column into typed array */
function parseEvents(raw: string): WebhookEvent[] {
return JSON.parse(raw) as WebhookEvent[];
}
export const create = async (
db: dbClient,
webhookInput: {
workspaceId: number;
name: string;
url: string;
secret?: string;
events: WebhookEvent[];
createdBy: string;
},
) => {
const [webhook] = await db
.insert(workspaceWebhooks)
.values({
publicId: generateUID(),
workspaceId: webhookInput.workspaceId,
name: webhookInput.name,
url: webhookInput.url,
secret: webhookInput.secret,
events: JSON.stringify(webhookInput.events),
createdBy: webhookInput.createdBy,
})
.returning({
publicId: workspaceWebhooks.publicId,
name: workspaceWebhooks.name,
url: workspaceWebhooks.url,
events: workspaceWebhooks.events,
active: workspaceWebhooks.active,
createdAt: workspaceWebhooks.createdAt,
});
return webhook
? {
...webhook,
events: parseEvents(webhook.events),
}
: null;
};
export const update = async (
db: dbClient,
webhookPublicId: string,
webhookInput: {
name?: string;
url?: string;
secret?: string;
events?: WebhookEvent[];
active?: boolean;
},
) => {
const [result] = await db
.update(workspaceWebhooks)
.set({
name: webhookInput.name,
url: webhookInput.url,
secret: webhookInput.secret,
events: webhookInput.events
? JSON.stringify(webhookInput.events)
: undefined,
active: webhookInput.active,
updatedAt: new Date(),
})
.where(eq(workspaceWebhooks.publicId, webhookPublicId))
.returning({
publicId: workspaceWebhooks.publicId,
name: workspaceWebhooks.name,
url: workspaceWebhooks.url,
events: workspaceWebhooks.events,
active: workspaceWebhooks.active,
createdAt: workspaceWebhooks.createdAt,
updatedAt: workspaceWebhooks.updatedAt,
});
return result
? {
...result,
events: parseEvents(result.events),
}
: null;
};
export const getByPublicId = async (db: dbClient, webhookPublicId: string) => {
const result = await db.query.workspaceWebhooks.findFirst({
columns: {
id: true,
publicId: true,
workspaceId: true,
name: true,
url: true,
secret: true,
events: true,
active: true,
createdAt: true,
updatedAt: true,
},
where: eq(workspaceWebhooks.publicId, webhookPublicId),
});
return result
? {
...result,
events: parseEvents(result.events),
}
: null;
};
export const getAllByWorkspaceId = async (
db: dbClient,
workspaceId: number,
) => {
const results = await db.query.workspaceWebhooks.findMany({
columns: {
publicId: true,
name: true,
url: true,
events: true,
active: true,
createdAt: true,
updatedAt: true,
},
where: eq(workspaceWebhooks.workspaceId, workspaceId),
});
return results.map((webhook) => ({
...webhook,
events: parseEvents(webhook.events),
}));
};
/**
* Returns active webhooks with secrets for server-side delivery (HMAC signing).
* DO NOT expose this via tRPC or any client-facing endpoint.
* Use getAllByWorkspaceId (which omits secrets) for the admin list endpoint.
*/
export const getActiveByWorkspaceId = async (
db: dbClient,
workspaceId: number,
) => {
const results = await db.query.workspaceWebhooks.findMany({
columns: {
publicId: true,
url: true,
secret: true,
events: true,
},
where: and(
eq(workspaceWebhooks.workspaceId, workspaceId),
eq(workspaceWebhooks.active, true),
),
});
return results.map((webhook) => ({
...webhook,
events: parseEvents(webhook.events),
}));
};
export const hardDelete = (db: dbClient, webhookPublicId: string) => {
return db
.delete(workspaceWebhooks)
.where(eq(workspaceWebhooks.publicId, webhookPublicId));
};

View File

@@ -14,3 +14,4 @@ export * from "./subscriptions";
export * from "./workspaceInviteLinks"; export * from "./workspaceInviteLinks";
export * from "./permissions"; export * from "./permissions";
export * from "./notifications"; export * from "./notifications";
export * from "./webhooks";

View File

@@ -0,0 +1,59 @@
import { relations } from "drizzle-orm";
import {
bigint,
bigserial,
boolean,
index,
pgTable,
text,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core";
import { users } from "./users";
import { workspaces } from "./workspaces";
export const webhookEvents = [
"card.created",
"card.updated",
"card.moved",
"card.deleted",
] as const;
export type WebhookEvent = (typeof webhookEvents)[number];
export const workspaceWebhooks = pgTable("workspace_webhooks", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
workspaceId: bigint("workspaceId", { mode: "number" })
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
url: varchar("url", { length: 2048 }).notNull(),
secret: text("secret"),
events: text("events").notNull(), // JSON array of webhook events
active: boolean("active").notNull().default(true),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
}, (table) => [
index("workspace_webhooks_workspace_idx").on(table.workspaceId),
]).enableRLS();
export const workspaceWebhooksRelations = relations(
workspaceWebhooks,
({ one }) => ({
workspace: one(workspaces, {
fields: [workspaceWebhooks.workspaceId],
references: [workspaces.id],
relationName: "workspaceWebhooksWorkspace",
}),
createdByUser: one(users, {
fields: [workspaceWebhooks.createdBy],
references: [users.id],
relationName: "workspaceWebhooksCreatedByUser",
}),
}),
);

143
pnpm-lock.yaml generated
View File

@@ -254,6 +254,9 @@ importers:
'@lingui/cli': '@lingui/cli':
specifier: ^5.3.2 specifier: ^5.3.2
version: 5.4.1(typescript@5.9.2) version: 5.4.1(typescript@5.9.2)
'@lingui/format-json':
specifier: ^5.9.1
version: 5.9.1(typescript@5.9.2)
'@lingui/loader': '@lingui/loader':
specifier: ^5.3.2 specifier: ^5.3.2
version: 5.4.1(typescript@5.9.2)(webpack@5.101.3) version: 5.4.1(typescript@5.9.2)(webpack@5.101.3)
@@ -341,7 +344,7 @@ importers:
version: link:../../tooling/typescript version: link:../../tooling/typescript
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.34.0(jiti@2.4.2) version: 9.34.0(jiti@2.6.1)
prettier: prettier:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.6.2 version: 3.6.2
@@ -381,7 +384,7 @@ importers:
version: link:../../tooling/typescript version: link:../../tooling/typescript
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.34.0(jiti@2.4.2) version: 9.34.0(jiti@2.6.1)
prettier: prettier:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.6.2 version: 3.6.2
@@ -436,7 +439,7 @@ importers:
version: 0.28.1 version: 0.28.1
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.34.0(jiti@2.4.2) version: 9.34.0(jiti@2.6.1)
prettier: prettier:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.6.2 version: 3.6.2
@@ -473,7 +476,7 @@ importers:
version: 6.4.19 version: 6.4.19
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.34.0(jiti@2.4.2) version: 9.34.0(jiti@2.6.1)
prettier: prettier:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.6.2 version: 3.6.2
@@ -513,7 +516,7 @@ importers:
version: link:../../tooling/typescript version: link:../../tooling/typescript
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.34.0(jiti@2.4.2) version: 9.34.0(jiti@2.6.1)
prettier: prettier:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.6.2 version: 3.6.2
@@ -538,7 +541,7 @@ importers:
version: link:../../tooling/typescript version: link:../../tooling/typescript
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.34.0(jiti@2.4.2) version: 9.34.0(jiti@2.6.1)
prettier: prettier:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.6.2 version: 3.6.2
@@ -550,28 +553,28 @@ importers:
dependencies: dependencies:
'@eslint/compat': '@eslint/compat':
specifier: ^1.2.3 specifier: ^1.2.3
version: 1.3.2(eslint@9.34.0(jiti@2.4.2)) version: 1.3.2(eslint@9.34.0(jiti@2.6.1))
'@next/eslint-plugin-next': '@next/eslint-plugin-next':
specifier: ^14.2.15 specifier: ^14.2.15
version: 14.2.32 version: 14.2.32
eslint-plugin-import: eslint-plugin-import:
specifier: ^2.31.0 specifier: ^2.31.0
version: 2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.34.0(jiti@2.4.2)) version: 2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1))
eslint-plugin-jsx-a11y: eslint-plugin-jsx-a11y:
specifier: ^6.10.2 specifier: ^6.10.2
version: 6.10.2(eslint@9.34.0(jiti@2.4.2)) version: 6.10.2(eslint@9.34.0(jiti@2.6.1))
eslint-plugin-react: eslint-plugin-react:
specifier: ^7.37.2 specifier: ^7.37.2
version: 7.37.5(eslint@9.34.0(jiti@2.4.2)) version: 7.37.5(eslint@9.34.0(jiti@2.6.1))
eslint-plugin-react-hooks: eslint-plugin-react-hooks:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.2.0(eslint@9.34.0(jiti@2.4.2)) version: 5.2.0(eslint@9.34.0(jiti@2.6.1))
eslint-plugin-turbo: eslint-plugin-turbo:
specifier: ^2.3.1 specifier: ^2.3.1
version: 2.5.6(eslint@9.34.0(jiti@2.4.2))(turbo@2.5.6) version: 2.5.6(eslint@9.34.0(jiti@2.6.1))(turbo@2.5.6)
typescript-eslint: typescript-eslint:
specifier: ^8.15.0 specifier: ^8.15.0
version: 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2) version: 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)
devDependencies: devDependencies:
'@kan/prettier-config': '@kan/prettier-config':
specifier: workspace:* specifier: workspace:*
@@ -584,7 +587,7 @@ importers:
version: 8.42.3 version: 8.42.3
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.34.0(jiti@2.4.2) version: 9.34.0(jiti@2.6.1)
prettier: prettier:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.6.2 version: 3.6.2
@@ -637,7 +640,7 @@ importers:
version: link:../typescript version: link:../typescript
eslint: eslint:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.34.0(jiti@2.4.2) version: 9.34.0(jiti@2.6.1)
prettier: prettier:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.6.2 version: 3.6.2
@@ -2673,6 +2676,10 @@ packages:
resolution: {integrity: sha512-aDkj/bMSr/mCL8Nr1TS52v0GLCuVa4YqtRz+WvUCFZw/ovVInX0hKq1TClx/bSlhu60FzB/CbclxFMBw8aLVUg==} resolution: {integrity: sha512-aDkj/bMSr/mCL8Nr1TS52v0GLCuVa4YqtRz+WvUCFZw/ovVInX0hKq1TClx/bSlhu60FzB/CbclxFMBw8aLVUg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
'@lingui/conf@5.9.1':
resolution: {integrity: sha512-Dr1CV4P3INJZZzvLcksgML4kusHQS3pJyONcZt40l/T74LOETUSk2cxFW5epizWU46FpN2ThusvC2yb0L8o1jA==}
engines: {node: '>=20.0.0'}
'@lingui/core@5.4.1': '@lingui/core@5.4.1':
resolution: {integrity: sha512-4FeIh56PH5vziPg2BYo4XYWWOHE4XaY/XR8Jakwn0/qwtLpydWMNVpZOpGWi7nfPZtcLaJLmZKup6UNxEl1Pfw==} resolution: {integrity: sha512-4FeIh56PH5vziPg2BYo4XYWWOHE4XaY/XR8Jakwn0/qwtLpydWMNVpZOpGWi7nfPZtcLaJLmZKup6UNxEl1Pfw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -2685,6 +2692,10 @@ packages:
babel-plugin-macros: babel-plugin-macros:
optional: true optional: true
'@lingui/format-json@5.9.1':
resolution: {integrity: sha512-Kp/saH3jKJPlAzjcDJd+yqePoC6PZOc8cbO1mL/mN8mOz5bF9w+LnX4KntgZz1MjJwP12fqtGbr6AxDevwnmWw==}
engines: {node: '>=20.0.0'}
'@lingui/format-po@5.4.1': '@lingui/format-po@5.4.1':
resolution: {integrity: sha512-IBVq3RRLNEVRzNZcdEw0qpM5NKX4e9wDmvJMorkR2OYrgTbhWx5gDYhXpEZ9yqtuEVhILMdriVNjAAUnDAJibA==} resolution: {integrity: sha512-IBVq3RRLNEVRzNZcdEw0qpM5NKX4e9wDmvJMorkR2OYrgTbhWx5gDYhXpEZ9yqtuEVhILMdriVNjAAUnDAJibA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -6157,6 +6168,10 @@ packages:
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
hasBin: true hasBin: true
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jose@6.1.2: jose@6.1.2:
resolution: {integrity: sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==} resolution: {integrity: sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ==}
@@ -10451,16 +10466,16 @@ snapshots:
eslint: 9.34.0(jiti@1.21.7) eslint: 9.34.0(jiti@1.21.7)
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.7.0(eslint@9.34.0(jiti@2.4.2))': '@eslint-community/eslint-utils@4.7.0(eslint@9.34.0(jiti@2.6.1))':
dependencies: dependencies:
eslint: 9.34.0(jiti@2.4.2) eslint: 9.34.0(jiti@2.6.1)
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.1': {} '@eslint-community/regexpp@4.12.1': {}
'@eslint/compat@1.3.2(eslint@9.34.0(jiti@2.4.2))': '@eslint/compat@1.3.2(eslint@9.34.0(jiti@2.6.1))':
optionalDependencies: optionalDependencies:
eslint: 9.34.0(jiti@2.4.2) eslint: 9.34.0(jiti@2.6.1)
'@eslint/config-array@0.21.0': '@eslint/config-array@0.21.0':
dependencies: dependencies:
@@ -10871,6 +10886,16 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
'@lingui/conf@5.9.1(typescript@5.9.2)':
dependencies:
'@babel/runtime': 7.28.3
cosmiconfig: 8.3.6(typescript@5.9.2)
jest-validate: 29.7.0
jiti: 2.6.1
picocolors: 1.1.1
transitivePeerDependencies:
- typescript
'@lingui/core@5.4.1(@lingui/babel-plugin-lingui-macro@5.4.1(typescript@5.9.2))': '@lingui/core@5.4.1(@lingui/babel-plugin-lingui-macro@5.4.1(typescript@5.9.2))':
dependencies: dependencies:
'@babel/runtime': 7.28.3 '@babel/runtime': 7.28.3
@@ -10878,6 +10903,12 @@ snapshots:
optionalDependencies: optionalDependencies:
'@lingui/babel-plugin-lingui-macro': 5.4.1(typescript@5.9.2) '@lingui/babel-plugin-lingui-macro': 5.4.1(typescript@5.9.2)
'@lingui/format-json@5.9.1(typescript@5.9.2)':
dependencies:
'@lingui/conf': 5.9.1(typescript@5.9.2)
transitivePeerDependencies:
- typescript
'@lingui/format-po@5.4.1(typescript@5.9.2)': '@lingui/format-po@5.4.1(typescript@5.9.2)':
dependencies: dependencies:
'@lingui/conf': 5.4.1(typescript@5.9.2) '@lingui/conf': 5.4.1(typescript@5.9.2)
@@ -12539,15 +12570,15 @@ snapshots:
dependencies: dependencies:
'@types/yargs-parser': 21.0.3 '@types/yargs-parser': 21.0.3
'@typescript-eslint/eslint-plugin@8.41.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)': '@typescript-eslint/eslint-plugin@8.41.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.1 '@eslint-community/regexpp': 4.12.1
'@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2) '@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)
'@typescript-eslint/scope-manager': 8.41.0 '@typescript-eslint/scope-manager': 8.41.0
'@typescript-eslint/type-utils': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2) '@typescript-eslint/type-utils': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)
'@typescript-eslint/utils': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2) '@typescript-eslint/utils': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)
'@typescript-eslint/visitor-keys': 8.41.0 '@typescript-eslint/visitor-keys': 8.41.0
eslint: 9.34.0(jiti@2.4.2) eslint: 9.34.0(jiti@2.6.1)
graphemer: 1.4.0 graphemer: 1.4.0
ignore: 7.0.5 ignore: 7.0.5
natural-compare: 1.4.0 natural-compare: 1.4.0
@@ -12556,14 +12587,14 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)': '@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.41.0 '@typescript-eslint/scope-manager': 8.41.0
'@typescript-eslint/types': 8.41.0 '@typescript-eslint/types': 8.41.0
'@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2) '@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2)
'@typescript-eslint/visitor-keys': 8.41.0 '@typescript-eslint/visitor-keys': 8.41.0
debug: 4.4.3 debug: 4.4.3
eslint: 9.34.0(jiti@2.4.2) eslint: 9.34.0(jiti@2.6.1)
typescript: 5.9.2 typescript: 5.9.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -12586,13 +12617,13 @@ snapshots:
dependencies: dependencies:
typescript: 5.9.2 typescript: 5.9.2
'@typescript-eslint/type-utils@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)': '@typescript-eslint/type-utils@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)':
dependencies: dependencies:
'@typescript-eslint/types': 8.41.0 '@typescript-eslint/types': 8.41.0
'@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2) '@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2)
'@typescript-eslint/utils': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2) '@typescript-eslint/utils': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)
debug: 4.4.3 debug: 4.4.3
eslint: 9.34.0(jiti@2.4.2) eslint: 9.34.0(jiti@2.6.1)
ts-api-utils: 2.1.0(typescript@5.9.2) ts-api-utils: 2.1.0(typescript@5.9.2)
typescript: 5.9.2 typescript: 5.9.2
transitivePeerDependencies: transitivePeerDependencies:
@@ -12616,13 +12647,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2)': '@typescript-eslint/utils@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0(jiti@2.4.2)) '@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0(jiti@2.6.1))
'@typescript-eslint/scope-manager': 8.41.0 '@typescript-eslint/scope-manager': 8.41.0
'@typescript-eslint/types': 8.41.0 '@typescript-eslint/types': 8.41.0
'@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2) '@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2)
eslint: 9.34.0(jiti@2.4.2) eslint: 9.34.0(jiti@2.6.1)
typescript: 5.9.2 typescript: 5.9.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -13948,17 +13979,17 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.34.0(jiti@2.4.2)): eslint-module-utils@2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.34.0(jiti@2.6.1)):
dependencies: dependencies:
debug: 3.2.7 debug: 3.2.7
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2) '@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)
eslint: 9.34.0(jiti@2.4.2) eslint: 9.34.0(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.34.0(jiti@2.4.2)): eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)):
dependencies: dependencies:
'@rtsao/scc': 1.1.0 '@rtsao/scc': 1.1.0
array-includes: 3.1.9 array-includes: 3.1.9
@@ -13967,9 +13998,9 @@ snapshots:
array.prototype.flatmap: 1.3.3 array.prototype.flatmap: 1.3.3
debug: 3.2.7 debug: 3.2.7
doctrine: 2.1.0 doctrine: 2.1.0
eslint: 9.34.0(jiti@2.4.2) eslint: 9.34.0(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.34.0(jiti@2.4.2)) eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint@9.34.0(jiti@2.6.1))
hasown: 2.0.2 hasown: 2.0.2
is-core-module: 2.16.1 is-core-module: 2.16.1
is-glob: 4.0.3 is-glob: 4.0.3
@@ -13981,13 +14012,13 @@ snapshots:
string.prototype.trimend: 1.0.9 string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0 tsconfig-paths: 3.15.0
optionalDependencies: optionalDependencies:
'@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2) '@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)
transitivePeerDependencies: transitivePeerDependencies:
- eslint-import-resolver-typescript - eslint-import-resolver-typescript
- eslint-import-resolver-webpack - eslint-import-resolver-webpack
- supports-color - supports-color
eslint-plugin-jsx-a11y@6.10.2(eslint@9.34.0(jiti@2.4.2)): eslint-plugin-jsx-a11y@6.10.2(eslint@9.34.0(jiti@2.6.1)):
dependencies: dependencies:
aria-query: 5.3.2 aria-query: 5.3.2
array-includes: 3.1.9 array-includes: 3.1.9
@@ -13997,7 +14028,7 @@ snapshots:
axobject-query: 4.1.0 axobject-query: 4.1.0
damerau-levenshtein: 1.0.8 damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2 emoji-regex: 9.2.2
eslint: 9.34.0(jiti@2.4.2) eslint: 9.34.0(jiti@2.6.1)
hasown: 2.0.2 hasown: 2.0.2
jsx-ast-utils: 3.3.5 jsx-ast-utils: 3.3.5
language-tags: 1.0.9 language-tags: 1.0.9
@@ -14006,11 +14037,11 @@ snapshots:
safe-regex-test: 1.1.0 safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1 string.prototype.includes: 2.0.1
eslint-plugin-react-hooks@5.2.0(eslint@9.34.0(jiti@2.4.2)): eslint-plugin-react-hooks@5.2.0(eslint@9.34.0(jiti@2.6.1)):
dependencies: dependencies:
eslint: 9.34.0(jiti@2.4.2) eslint: 9.34.0(jiti@2.6.1)
eslint-plugin-react@7.37.5(eslint@9.34.0(jiti@2.4.2)): eslint-plugin-react@7.37.5(eslint@9.34.0(jiti@2.6.1)):
dependencies: dependencies:
array-includes: 3.1.9 array-includes: 3.1.9
array.prototype.findlast: 1.2.5 array.prototype.findlast: 1.2.5
@@ -14018,7 +14049,7 @@ snapshots:
array.prototype.tosorted: 1.1.4 array.prototype.tosorted: 1.1.4
doctrine: 2.1.0 doctrine: 2.1.0
es-iterator-helpers: 1.2.1 es-iterator-helpers: 1.2.1
eslint: 9.34.0(jiti@2.4.2) eslint: 9.34.0(jiti@2.6.1)
estraverse: 5.3.0 estraverse: 5.3.0
hasown: 2.0.2 hasown: 2.0.2
jsx-ast-utils: 3.3.5 jsx-ast-utils: 3.3.5
@@ -14032,10 +14063,10 @@ snapshots:
string.prototype.matchall: 4.0.12 string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0 string.prototype.repeat: 1.0.0
eslint-plugin-turbo@2.5.6(eslint@9.34.0(jiti@2.4.2))(turbo@2.5.6): eslint-plugin-turbo@2.5.6(eslint@9.34.0(jiti@2.6.1))(turbo@2.5.6):
dependencies: dependencies:
dotenv: 16.0.3 dotenv: 16.0.3
eslint: 9.34.0(jiti@2.4.2) eslint: 9.34.0(jiti@2.6.1)
turbo: 2.5.6 turbo: 2.5.6
eslint-scope@5.1.1: eslint-scope@5.1.1:
@@ -14094,9 +14125,9 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint@9.34.0(jiti@2.4.2): eslint@9.34.0(jiti@2.6.1):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0(jiti@2.4.2)) '@eslint-community/eslint-utils': 4.7.0(eslint@9.34.0(jiti@2.6.1))
'@eslint-community/regexpp': 4.12.1 '@eslint-community/regexpp': 4.12.1
'@eslint/config-array': 0.21.0 '@eslint/config-array': 0.21.0
'@eslint/config-helpers': 0.3.1 '@eslint/config-helpers': 0.3.1
@@ -14132,7 +14163,7 @@ snapshots:
natural-compare: 1.4.0 natural-compare: 1.4.0
optionator: 0.9.4 optionator: 0.9.4
optionalDependencies: optionalDependencies:
jiti: 2.4.2 jiti: 2.6.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -14923,6 +14954,8 @@ snapshots:
jiti@2.4.2: {} jiti@2.4.2: {}
jiti@2.6.1: {}
jose@6.1.2: {} jose@6.1.2: {}
jose@6.1.3: {} jose@6.1.3: {}
@@ -17685,13 +17718,13 @@ snapshots:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10 reflect.getprototypeof: 1.0.10
typescript-eslint@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2): typescript-eslint@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.41.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2))(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2) '@typescript-eslint/eslint-plugin': 8.41.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)
'@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2) '@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)
'@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2) '@typescript-eslint/typescript-estree': 8.41.0(typescript@5.9.2)
'@typescript-eslint/utils': 8.41.0(eslint@9.34.0(jiti@2.4.2))(typescript@5.9.2) '@typescript-eslint/utils': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2)
eslint: 9.34.0(jiti@2.4.2) eslint: 9.34.0(jiti@2.6.1)
typescript: 5.9.2 typescript: 5.9.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color