Compare commits
8 Commits
feat/impro
...
feat/publi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be8795e34f | ||
|
|
cbf347dce0 | ||
|
|
cb9736134d | ||
|
|
05a0245988 | ||
|
|
b6b9423823 | ||
|
|
1696aab43b | ||
|
|
33d2f23955 | ||
|
|
961219835e |
@@ -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
|
|
||||||
|
|||||||
74
.github/workflows/docker-publish.yml
vendored
74
.github/workflows/docker-publish.yml
vendored
@@ -61,21 +61,6 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# Extract metadata (tags, labels) for Docker
|
|
||||||
# https://github.com/docker/metadata-action
|
|
||||||
- name: Extract Docker metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=pr
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=semver,pattern={{major}}
|
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
|
|
||||||
# Extract version from git tag or ref
|
# Extract version from git tag or ref
|
||||||
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
# Uses git describe to get latest tag + commit hash in SemVer format: 1.2.3+abc1234
|
||||||
- name: Extract version
|
- name: Extract version
|
||||||
@@ -112,6 +97,39 @@ jobs:
|
|||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "Version: $VERSION"
|
echo "Version: $VERSION"
|
||||||
|
|
||||||
|
# Extract metadata (tags, labels) for Docker
|
||||||
|
# https://github.com/docker/metadata-action
|
||||||
|
- name: Extract Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.version=${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
|
# Extract metadata for migrate image
|
||||||
|
- name: Extract Docker metadata for migrate
|
||||||
|
id: meta-migrate
|
||||||
|
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-migrate
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=semver,pattern={{major}}
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.version=${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
# Build and push Docker image with Buildx (don't push on PR)
|
# Build and push Docker image with Buildx (don't push on PR)
|
||||||
# https://github.com/docker/build-push-action
|
# https://github.com/docker/build-push-action
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
@@ -129,6 +147,24 @@ jobs:
|
|||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
# Build and push migrate Docker image with Buildx (don't push on PR)
|
||||||
|
# https://github.com/docker/build-push-action
|
||||||
|
- name: Build and push migrate Docker image
|
||||||
|
id: build-and-push-migrate
|
||||||
|
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: apps/web/Dockerfile
|
||||||
|
target: migrate
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: ${{ steps.meta-migrate.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta-migrate.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ steps.version.outputs.version }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
# Sign the resulting Docker image digest except on PRs.
|
# Sign the resulting Docker image digest except on PRs.
|
||||||
# This will only write to the public Rekor transparency log when the Docker
|
# This will only write to the public Rekor transparency log when the Docker
|
||||||
# repository is public to avoid leaking data. If you would like to publish
|
# repository is public to avoid leaking data. If you would like to publish
|
||||||
@@ -143,3 +179,11 @@ jobs:
|
|||||||
# This step uses the identity token to provision an ephemeral certificate
|
# This step uses the identity token to provision an ephemeral certificate
|
||||||
# against the sigstore community Fulcio instance.
|
# against the sigstore community Fulcio instance.
|
||||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||||
|
|
||||||
|
# Sign the migrate Docker image digest except on PRs.
|
||||||
|
- name: Sign the published migrate Docker image
|
||||||
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
|
env:
|
||||||
|
TAGS: ${{ steps.meta-migrate.outputs.tags }}
|
||||||
|
DIGEST: ${{ steps.build-and-push-migrate.outputs.digest }}
|
||||||
|
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||||
|
|||||||
@@ -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,92 @@ 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/ .
|
|
||||||
|
|
||||||
# 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')}" && \
|
# Stage 4: Build the web application
|
||||||
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build --filter=${PROJECT}
|
# ============================================
|
||||||
|
FROM alpine AS builder
|
||||||
# # Copy static files to standalone directory
|
ARG APP_VERSION
|
||||||
# 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)
|
||||||
|
# Note: Use a Debian-based Node image instead of Alpine here because
|
||||||
|
# Alpine under QEMU has caused Illegal instruction (signal 4) crashes
|
||||||
|
# during npm installs on arm64 multi-arch builds.
|
||||||
|
# ============================================
|
||||||
|
FROM node:${NODE_VERSION}-bullseye-slim 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
44
apps/web/bootstrap.cjs
Normal 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");
|
||||||
@@ -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",
|
||||||
@@ -51,8 +63,8 @@ const config = {
|
|||||||
hostname: "*.googleusercontent.com",
|
hostname: "*.googleusercontent.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
protocol: 'https',
|
protocol: "https",
|
||||||
hostname: 'cdn.discordapp.com',
|
hostname: "cdn.discordapp.com",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
<div className="mt-10 w-full">
|
||||||
{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]">
|
|
||||||
<FeatureComparisonTable
|
<FeatureComparisonTable
|
||||||
frequencyValue={frequency?.value ?? "annually"}
|
frequencyValue={frequency?.value ?? "annually"}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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`,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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-]+$/),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 $$;
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
175
packages/db/src/repository/webhook.repo.ts
Normal file
175
packages/db/src/repository/webhook.repo.ts
Normal 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));
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
|||||||
59
packages/db/src/schema/webhooks.ts
Normal file
59
packages/db/src/schema/webhooks.ts
Normal 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",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user