Merge pull request #61 from artslidd/develop
Implement Solo/Teams/Founder billing model
This commit is contained in:
commit
dcfa7c9989
67 changed files with 4262 additions and 2775 deletions
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
executor:
|
||||
name: node/default
|
||||
resource_class: small
|
||||
tag: '16'
|
||||
tag: 'lts'
|
||||
steps:
|
||||
- checkout
|
||||
- node/install-packages:
|
||||
|
|
@ -22,15 +22,12 @@ jobs:
|
|||
- run:
|
||||
name: Run linting
|
||||
command: pnpm run lint
|
||||
- run:
|
||||
name: Check formatting
|
||||
command: pnpm run format --check || echo "Format check complete"
|
||||
|
||||
test-typecheck:
|
||||
executor:
|
||||
name: node/default
|
||||
resource_class: small
|
||||
tag: '16'
|
||||
tag: 'lts'
|
||||
steps:
|
||||
- checkout
|
||||
- node/install-packages:
|
||||
|
|
@ -44,7 +41,7 @@ jobs:
|
|||
executor:
|
||||
name: node/default
|
||||
resource_class: medium
|
||||
tag: '16'
|
||||
tag: 'lts'
|
||||
steps:
|
||||
- checkout
|
||||
- node/install-packages:
|
||||
|
|
@ -52,44 +49,27 @@ jobs:
|
|||
cache-path: ~/.pnpm-store
|
||||
- run:
|
||||
name: Run unit tests
|
||||
command: pnpm run test
|
||||
- store_test_results:
|
||||
path: apps/main/coverage
|
||||
- store_artifacts:
|
||||
path: apps/main/coverage
|
||||
destination: coverage
|
||||
command: pnpm --filter @xtablo/main run test
|
||||
|
||||
test-api:
|
||||
executor:
|
||||
name: node/default
|
||||
tag: '16'
|
||||
tag: 'lts'
|
||||
resource_class: small
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
name: Restore npm API Cache
|
||||
keys:
|
||||
- npm-api-{{ checksum "api/package-lock.json" }}
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
cache-path: ~/.pnpm-store
|
||||
- run:
|
||||
name: Install API dependencies
|
||||
name: Run API checks
|
||||
command: |
|
||||
cd api
|
||||
npm ci
|
||||
- save_cache:
|
||||
name: Save npm API Cache
|
||||
key: npm-api-{{ checksum "api/package-lock.json" }}
|
||||
paths:
|
||||
- api/node_modules
|
||||
- run:
|
||||
name: Lint API
|
||||
command: |
|
||||
cd api
|
||||
npm run lint
|
||||
- run:
|
||||
name: Run API tests
|
||||
command: |
|
||||
cd api
|
||||
npm run test
|
||||
if [ "${RUN_API_INTEGRATION_TESTS:-0}" = "1" ]; then
|
||||
pnpm --filter @xtablo/api run test
|
||||
else
|
||||
echo "Skipping API integration tests (set RUN_API_INTEGRATION_TESTS=1 to enable)."
|
||||
pnpm --filter @xtablo/api run build
|
||||
fi
|
||||
|
||||
# ============================================
|
||||
# BUILD PHASE
|
||||
|
|
@ -123,8 +103,6 @@ jobs:
|
|||
paths:
|
||||
- apps/main/dist
|
||||
- apps/external/dist
|
||||
- packages/ui/dist
|
||||
- packages/shared/dist
|
||||
- store_artifacts:
|
||||
path: apps/main/dist
|
||||
destination: main-app-<< parameters.environment >>
|
||||
|
|
@ -138,26 +116,18 @@ jobs:
|
|||
resource_class: small
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
name: Restore npm API Cache
|
||||
keys:
|
||||
- npm-api-{{ checksum "api/package-lock.json" }}
|
||||
- run:
|
||||
name: Install API dependencies
|
||||
command: |
|
||||
cd api
|
||||
npm ci
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
cache-path: ~/.pnpm-store
|
||||
- run:
|
||||
name: Build API
|
||||
command: |
|
||||
cd api
|
||||
npm run build
|
||||
command: pnpm --filter @xtablo/api run build
|
||||
- persist_to_workspace:
|
||||
root: .
|
||||
paths:
|
||||
- api/dist
|
||||
- apps/api/dist
|
||||
- store_artifacts:
|
||||
path: api/dist
|
||||
path: apps/api/dist
|
||||
destination: api
|
||||
|
||||
# ============================================
|
||||
|
|
@ -174,9 +144,7 @@ jobs:
|
|||
at: .
|
||||
- run:
|
||||
name: Build API Docker image
|
||||
command: |
|
||||
cd api
|
||||
docker build -t xtablo-api:${CIRCLE_SHA1} -t xtablo-api:latest .
|
||||
command: docker build -f apps/api/Dockerfile -t xtablo-api:${CIRCLE_SHA1} -t xtablo-api:latest .
|
||||
- run:
|
||||
name: Save Docker image
|
||||
command: |
|
||||
|
|
|
|||
|
|
@ -9,4 +9,11 @@ R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee"
|
|||
TASKS_SECRET="gT3BAytmNwhe1wKmvgREBlWcqK0="
|
||||
|
||||
EMAIL_USER="baptiste@xtablo.com"
|
||||
EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com"
|
||||
EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com"
|
||||
|
||||
STRIPE_SOLO_PRICE_ID=price_solo_placeholder
|
||||
STRIPE_TEAM_MONTHLY_PRICE_ID=price_team_placeholder
|
||||
STRIPE_FOUNDER_PRICE_ID=price_founder_placeholder
|
||||
|
||||
BILLING_TRIAL_DAYS=14
|
||||
BILLING_TRIAL_ROLLOUT_AT=2026-03-08T00:00:00.000Z
|
||||
|
|
|
|||
94
apps/api/src/__tests__/helpers/billing.test.ts
Normal file
94
apps/api/src/__tests__/helpers/billing.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getBillableMemberCount,
|
||||
getOrganizationOwner,
|
||||
getTrialWindow,
|
||||
parseTrialRolloutDate,
|
||||
} from "../../helpers/billing.js";
|
||||
|
||||
describe("billing helpers", () => {
|
||||
it("falls back to default rollout date when env value is missing", () => {
|
||||
const rolloutAt = parseTrialRolloutDate(undefined);
|
||||
expect(rolloutAt?.toISOString()).toBe("2026-03-08T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("falls back to default rollout date when env value is invalid", () => {
|
||||
const rolloutAt = parseTrialRolloutDate("not-a-date");
|
||||
expect(rolloutAt?.toISOString()).toBe("2026-03-08T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("uses configured rollout date when env value is valid", () => {
|
||||
const rolloutAt = parseTrialRolloutDate("2026-03-10T00:00:00.000Z");
|
||||
expect(rolloutAt?.toISOString()).toBe("2026-03-10T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("returns the earliest organization member as billing owner", () => {
|
||||
const owner = getOrganizationOwner([
|
||||
{
|
||||
id: "owner-user",
|
||||
created_at: "2026-01-01T10:00:00.000Z",
|
||||
is_temporary: false,
|
||||
plan: "annual",
|
||||
},
|
||||
{
|
||||
id: "late-user",
|
||||
created_at: "2026-01-02T10:00:00.000Z",
|
||||
is_temporary: false,
|
||||
plan: "solo",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(owner?.id).toBe("owner-user");
|
||||
});
|
||||
|
||||
it("excludes temporary users from billable seat count", () => {
|
||||
const count = getBillableMemberCount([
|
||||
{
|
||||
id: "user-1",
|
||||
created_at: "2026-01-01T10:00:00.000Z",
|
||||
is_temporary: false,
|
||||
plan: "solo",
|
||||
},
|
||||
{
|
||||
id: "temp-1",
|
||||
created_at: "2026-01-02T10:00:00.000Z",
|
||||
is_temporary: true,
|
||||
plan: "solo",
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
created_at: "2026-01-03T10:00:00.000Z",
|
||||
is_temporary: null,
|
||||
plan: "team",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it("uses rollout date as trial start for older organizations", () => {
|
||||
const result = getTrialWindow({
|
||||
ownerCreatedAt: new Date("2025-12-01T00:00:00.000Z"),
|
||||
now: new Date("2026-03-10T00:00:00.000Z"),
|
||||
trialDays: 14,
|
||||
rolloutAt: new Date("2026-03-08T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.trialStartDate.toISOString()).toBe("2026-03-08T00:00:00.000Z");
|
||||
expect(result.trialEndDate.toISOString()).toBe("2026-03-22T00:00:00.000Z");
|
||||
expect(result.isTrialExpired).toBe(false);
|
||||
});
|
||||
|
||||
it("uses owner creation date as trial start for new organizations", () => {
|
||||
const result = getTrialWindow({
|
||||
ownerCreatedAt: new Date("2026-03-12T00:00:00.000Z"),
|
||||
now: new Date("2026-03-28T00:00:00.000Z"),
|
||||
trialDays: 14,
|
||||
rolloutAt: new Date("2026-03-08T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.trialStartDate.toISOString()).toBe("2026-03-12T00:00:00.000Z");
|
||||
expect(result.trialEndDate.toISOString()).toBe("2026-03-26T00:00:00.000Z");
|
||||
expect(result.isTrialExpired).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,8 +6,8 @@ import {
|
|||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import type { TabloFoldersMetadata } from "@xtablo/shared-types";
|
||||
import { sdkStreamMixin } from "@smithy/util-stream";
|
||||
import type { TabloFoldersMetadata } from "@xtablo/shared-types";
|
||||
import { mockClient } from "aws-sdk-client-mock";
|
||||
import { testClient } from "hono/testing";
|
||||
import { Readable } from "stream";
|
||||
|
|
@ -664,12 +664,7 @@ describe("TabloData Endpoint", () => {
|
|||
});
|
||||
|
||||
it("should return 400 if folder name is empty string", async () => {
|
||||
const res = await createFolderRequest(
|
||||
ownerUser,
|
||||
client,
|
||||
"test_tablo_owner_private",
|
||||
" "
|
||||
);
|
||||
const res = await createFolderRequest(ownerUser, client, "test_tablo_owner_private", " ");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const data = await res.json();
|
||||
|
|
@ -1110,9 +1105,7 @@ describe("TabloData Endpoint", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe(
|
||||
"DELETE /tablo-data/:tabloId/file/:path - Delete File with Nested Path (Admin or Uploader)",
|
||||
() => {
|
||||
describe("DELETE /tablo-data/:tabloId/file/:path - Delete File with Nested Path (Admin or Uploader)", () => {
|
||||
it("should allow admin to delete file with nested path", async () => {
|
||||
const res = await deleteNestedFileRequest(
|
||||
ownerUser,
|
||||
|
|
|
|||
403
apps/api/src/helpers/billing.ts
Normal file
403
apps/api/src/helpers/billing.ts
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
export type BillingPlan = "solo" | "team" | "annual";
|
||||
export type RequiredBillingPlan = "solo" | "team";
|
||||
|
||||
type BillingProfileRow = {
|
||||
id: string;
|
||||
created_at: string | null;
|
||||
is_temporary: boolean | null;
|
||||
plan: string | null;
|
||||
};
|
||||
|
||||
type StripeCustomerRow = {
|
||||
id: string;
|
||||
metadata: Record<string, string | null> | null;
|
||||
};
|
||||
|
||||
type StripeSubscriptionRow = {
|
||||
id: string;
|
||||
status: string | null;
|
||||
created: number | null;
|
||||
current_period_end: number | null;
|
||||
};
|
||||
|
||||
type StripeSubscriptionItemRow = {
|
||||
subscription: string;
|
||||
price: string | null;
|
||||
quantity: number | null;
|
||||
};
|
||||
|
||||
type StripePriceRow = {
|
||||
id: string;
|
||||
lookup_key: string | null;
|
||||
metadata: Record<string, string | null> | null;
|
||||
product: string | null;
|
||||
};
|
||||
|
||||
type StripeProductRow = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
metadata: Record<string, string | null> | null;
|
||||
};
|
||||
|
||||
export type OrganizationBillingState = {
|
||||
owner_user_id: string;
|
||||
member_count: number;
|
||||
trial_starts_at: string;
|
||||
trial_ends_at: string;
|
||||
is_trial_expired: boolean;
|
||||
required_plan: RequiredBillingPlan;
|
||||
required_team_quantity: number;
|
||||
active_subscription_plan: BillingPlan | null;
|
||||
active_subscription_quantity: number;
|
||||
};
|
||||
|
||||
const ACTIVE_BILLING_STATUSES = ["active", "trialing", "past_due"];
|
||||
const DEFAULT_BILLING_TRIAL_DAYS = 14;
|
||||
const DEFAULT_BILLING_TRIAL_ROLLOUT_AT = "2026-03-08T00:00:00.000Z";
|
||||
|
||||
const parseTrialDays = () => {
|
||||
const parsed = Number.parseInt(process.env.BILLING_TRIAL_DAYS ?? "", 10);
|
||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||
return DEFAULT_BILLING_TRIAL_DAYS;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const parseTrialRolloutDate = (
|
||||
rawInput: string | null | undefined = process.env.BILLING_TRIAL_ROLLOUT_AT
|
||||
) => {
|
||||
const raw = rawInput?.trim();
|
||||
const fallback = new Date(DEFAULT_BILLING_TRIAL_ROLLOUT_AT);
|
||||
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = new Date(raw);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const getOrganizationOwner = (profiles: BillingProfileRow[]) => profiles[0] ?? null;
|
||||
|
||||
export const getBillableMemberCount = (profiles: BillingProfileRow[]) =>
|
||||
profiles.filter((profile) => profile.is_temporary !== true).length;
|
||||
|
||||
export const getTrialWindow = (input: {
|
||||
ownerCreatedAt: Date;
|
||||
now: Date;
|
||||
trialDays: number;
|
||||
rolloutAt: Date | null;
|
||||
}) => {
|
||||
const { ownerCreatedAt, now, trialDays, rolloutAt } = input;
|
||||
const trialStartDate = rolloutAt && rolloutAt > ownerCreatedAt ? rolloutAt : ownerCreatedAt;
|
||||
const trialEndDate = new Date(trialStartDate);
|
||||
trialEndDate.setUTCDate(trialEndDate.getUTCDate() + trialDays);
|
||||
|
||||
return {
|
||||
trialStartDate,
|
||||
trialEndDate,
|
||||
isTrialExpired: now.getTime() > trialEndDate.getTime(),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveRequiredPlan = (memberCount: number): RequiredBillingPlan =>
|
||||
memberCount <= 1 ? "solo" : "team";
|
||||
|
||||
const statusWeight = (status: string | null | undefined) => {
|
||||
if (status === "active") return 3;
|
||||
if (status === "past_due") return 2;
|
||||
if (status === "trialing") return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const planWeight = (plan: BillingPlan | null) => {
|
||||
if (plan === "annual") return 3;
|
||||
if (plan === "team") return 2;
|
||||
if (plan === "solo") return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const inferBillingPlan = (planHint: string | null | undefined): BillingPlan | null => {
|
||||
const hint = (planHint ?? "").toLowerCase();
|
||||
|
||||
if (
|
||||
hint.includes("founder") ||
|
||||
hint.includes("annual") ||
|
||||
hint.includes("beta") ||
|
||||
hint.includes("infinite")
|
||||
) {
|
||||
return "annual";
|
||||
}
|
||||
|
||||
if (hint.includes("team") || hint.includes("standard")) {
|
||||
return "team";
|
||||
}
|
||||
|
||||
if (hint.includes("solo") || hint.includes("free")) {
|
||||
return "solo";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeProfilePlan = (plan: string | null | undefined): BillingPlan => {
|
||||
const normalized = (plan ?? "").toLowerCase();
|
||||
|
||||
if (normalized === "annual" || normalized === "beta" || normalized === "founder") {
|
||||
return "annual";
|
||||
}
|
||||
|
||||
if (normalized === "team" || normalized === "standard") {
|
||||
return "team";
|
||||
}
|
||||
|
||||
return "solo";
|
||||
};
|
||||
|
||||
const getPlanHint = (price: StripePriceRow | undefined, product: StripeProductRow | undefined) => {
|
||||
const parts = [
|
||||
price?.metadata?.plan,
|
||||
price?.metadata?.tier,
|
||||
price?.lookup_key,
|
||||
product?.metadata?.plan,
|
||||
product?.metadata?.tier,
|
||||
product?.name,
|
||||
]
|
||||
.map((part) => part?.toLowerCase().trim())
|
||||
.filter((part): part is string => Boolean(part && part.length > 0));
|
||||
|
||||
return parts[0] ?? null;
|
||||
};
|
||||
|
||||
const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId: number) => {
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, created_at, is_temporary, plan")
|
||||
.eq("organization_id", organizationId)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
if (error) {
|
||||
return { error: error.message, profiles: null };
|
||||
}
|
||||
|
||||
const profiles = (data ?? []) as BillingProfileRow[];
|
||||
if (profiles.length === 0) {
|
||||
return { error: "Organization has no members", profiles: null };
|
||||
}
|
||||
|
||||
return { error: null, profiles };
|
||||
};
|
||||
|
||||
const resolveActiveSubscription = async (
|
||||
supabase: SupabaseClient,
|
||||
ownerUserId: string,
|
||||
ownerProfilePlan: string | null
|
||||
): Promise<{ plan: BillingPlan | null; quantity: number }> => {
|
||||
const { data: customers, error: customersError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata")
|
||||
.limit(1000);
|
||||
|
||||
if (customersError) {
|
||||
throw new Error(customersError.message);
|
||||
}
|
||||
|
||||
const customer = (customers as StripeCustomerRow[] | null)?.find(
|
||||
(candidate) => candidate.metadata?.user_id === ownerUserId
|
||||
);
|
||||
|
||||
if (!customer) {
|
||||
return { plan: null, quantity: 0 };
|
||||
}
|
||||
|
||||
const { data: subscriptions, error: subscriptionsError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("subscriptions")
|
||||
.select("id, status, created, current_period_end")
|
||||
.eq("customer", customer.id)
|
||||
.in("status", ACTIVE_BILLING_STATUSES);
|
||||
|
||||
if (subscriptionsError) {
|
||||
throw new Error(subscriptionsError.message);
|
||||
}
|
||||
|
||||
const normalizedSubscriptions = (subscriptions ?? []) as StripeSubscriptionRow[];
|
||||
if (normalizedSubscriptions.length === 0) {
|
||||
return { plan: null, quantity: 0 };
|
||||
}
|
||||
|
||||
const ownerFallbackPlan = normalizeProfilePlan(ownerProfilePlan);
|
||||
|
||||
const subscriptionIds = normalizedSubscriptions.map((subscription) => subscription.id);
|
||||
const { data: subscriptionItems, error: subscriptionItemsError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("subscription_items")
|
||||
.select("subscription, price, quantity")
|
||||
.in("subscription", subscriptionIds);
|
||||
|
||||
if (subscriptionItemsError) {
|
||||
throw new Error(subscriptionItemsError.message);
|
||||
}
|
||||
|
||||
const normalizedItems = (subscriptionItems ?? []) as StripeSubscriptionItemRow[];
|
||||
const priceIds = Array.from(
|
||||
new Set(normalizedItems.map((item) => item.price).filter((price): price is string => !!price))
|
||||
);
|
||||
|
||||
let pricesById = new Map<string, StripePriceRow>();
|
||||
let productsById = new Map<string, StripeProductRow>();
|
||||
|
||||
if (priceIds.length > 0) {
|
||||
const { data: prices, error: pricesError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("prices")
|
||||
.select("id, lookup_key, metadata, product")
|
||||
.in("id", priceIds);
|
||||
|
||||
if (pricesError) {
|
||||
throw new Error(pricesError.message);
|
||||
}
|
||||
|
||||
const normalizedPrices = (prices ?? []) as StripePriceRow[];
|
||||
pricesById = new Map(normalizedPrices.map((price) => [price.id, price]));
|
||||
|
||||
const productIds = Array.from(
|
||||
new Set(
|
||||
normalizedPrices
|
||||
.map((price) => price.product)
|
||||
.filter((product): product is string => Boolean(product))
|
||||
)
|
||||
);
|
||||
|
||||
if (productIds.length > 0) {
|
||||
const { data: products, error: productsError } = await supabase
|
||||
.schema("stripe")
|
||||
.from("products")
|
||||
.select("id, name, metadata")
|
||||
.in("id", productIds);
|
||||
|
||||
if (productsError) {
|
||||
throw new Error(productsError.message);
|
||||
}
|
||||
|
||||
const normalizedProducts = (products ?? []) as StripeProductRow[];
|
||||
productsById = new Map(normalizedProducts.map((product) => [product.id, product]));
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = normalizedSubscriptions.flatMap((subscription) => {
|
||||
const relatedItems = normalizedItems.filter((item) => item.subscription === subscription.id);
|
||||
|
||||
if (relatedItems.length === 0) {
|
||||
return [
|
||||
{
|
||||
subscription,
|
||||
plan: null as BillingPlan | null,
|
||||
quantity: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return relatedItems.map((item) => {
|
||||
const price = item.price ? pricesById.get(item.price) : undefined;
|
||||
const product = price?.product ? productsById.get(price.product) : undefined;
|
||||
const hint = getPlanHint(price, product);
|
||||
|
||||
return {
|
||||
subscription,
|
||||
plan: inferBillingPlan(hint),
|
||||
quantity: Math.max(1, item.quantity ?? 1),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { plan: ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan, quantity: 1 };
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => {
|
||||
const aPlan = a.plan ?? "team";
|
||||
const bPlan = b.plan ?? "team";
|
||||
|
||||
const byPlanWeight = planWeight(bPlan) - planWeight(aPlan);
|
||||
if (byPlanWeight !== 0) return byPlanWeight;
|
||||
|
||||
const byStatusWeight =
|
||||
statusWeight(b.subscription.status) - statusWeight(a.subscription.status);
|
||||
if (byStatusWeight !== 0) return byStatusWeight;
|
||||
|
||||
const aPeriodEnd = a.subscription.current_period_end ?? a.subscription.created ?? 0;
|
||||
const bPeriodEnd = b.subscription.current_period_end ?? b.subscription.created ?? 0;
|
||||
return bPeriodEnd - aPeriodEnd;
|
||||
});
|
||||
|
||||
const winner = candidates[0];
|
||||
return {
|
||||
// Keep legacy fallback for unknown paid subscriptions but prefer owner profile plan
|
||||
// so founder/beta organizations do not downgrade to team because of metadata gaps.
|
||||
plan: winner.plan ?? (ownerFallbackPlan === "solo" ? "team" : ownerFallbackPlan),
|
||||
quantity: winner.quantity,
|
||||
};
|
||||
};
|
||||
|
||||
export const getOrganizationBillingState = async (
|
||||
supabase: SupabaseClient,
|
||||
organizationId: number,
|
||||
nowInput: Date = new Date()
|
||||
): Promise<{ data: OrganizationBillingState | null; error: string | null }> => {
|
||||
const { profiles, error: profilesError } = await getOrganizationProfiles(
|
||||
supabase,
|
||||
organizationId
|
||||
);
|
||||
if (profilesError || !profiles) {
|
||||
return { data: null, error: profilesError ?? "Failed to load organization members" };
|
||||
}
|
||||
|
||||
const owner = getOrganizationOwner(profiles);
|
||||
if (!owner) {
|
||||
return { data: null, error: "Organization has no members" };
|
||||
}
|
||||
|
||||
const ownerCreatedAt = owner.created_at ? new Date(owner.created_at) : nowInput;
|
||||
const trialWindow = getTrialWindow({
|
||||
ownerCreatedAt,
|
||||
now: new Date(nowInput),
|
||||
trialDays: parseTrialDays(),
|
||||
rolloutAt: parseTrialRolloutDate(),
|
||||
});
|
||||
|
||||
const memberCount = getBillableMemberCount(profiles);
|
||||
const requiredPlan = resolveRequiredPlan(memberCount);
|
||||
|
||||
try {
|
||||
const activeSubscription = await resolveActiveSubscription(supabase, owner.id, owner.plan);
|
||||
|
||||
return {
|
||||
data: {
|
||||
owner_user_id: owner.id,
|
||||
member_count: memberCount,
|
||||
trial_starts_at: trialWindow.trialStartDate.toISOString(),
|
||||
trial_ends_at: trialWindow.trialEndDate.toISOString(),
|
||||
is_trial_expired: trialWindow.isTrialExpired,
|
||||
required_plan: requiredPlan,
|
||||
required_team_quantity: requiredPlan === "team" ? Math.max(1, memberCount) : 1,
|
||||
active_subscription_plan: activeSubscription.plan,
|
||||
active_subscription_quantity: activeSubscription.quantity,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : "Failed to resolve billing state",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -20,7 +20,9 @@ const PLAN_WEIGHT: Record<NormalizedPlan, number> = {
|
|||
export const normalizePlan = (plan: string | null | undefined): NormalizedPlan => {
|
||||
if (!plan) return "solo";
|
||||
|
||||
if (plan === "annual" || plan === "beta") return "annual";
|
||||
if (plan === "annual" || plan === "beta" || plan === "founder" || plan === "infinite") {
|
||||
return "annual";
|
||||
}
|
||||
if (plan === "team" || plan === "standard") return "team";
|
||||
return "solo";
|
||||
};
|
||||
|
|
@ -291,8 +293,12 @@ export const createInvitedUser = async (
|
|||
streamServerClient: StreamChat,
|
||||
transporter: Transporter,
|
||||
recipientEmail: string,
|
||||
senderEmail: string
|
||||
senderEmail: string,
|
||||
options?: {
|
||||
isTemporary?: boolean;
|
||||
}
|
||||
): Promise<{ success: boolean; error?: string; userId?: string }> => {
|
||||
const isTemporary = options?.isTemporary ?? true;
|
||||
const xtabloUrl = process.env.XTABLO_URL || "https://app.xtablo.com";
|
||||
|
||||
// Create a new user account for the invited email
|
||||
|
|
@ -318,6 +324,16 @@ export const createInvitedUser = async (
|
|||
return { success: false, error: createUserError.message };
|
||||
}
|
||||
|
||||
const { error: updateProfileError } = await supabase
|
||||
.from("profiles")
|
||||
.update({ is_temporary: isTemporary })
|
||||
.eq("id", newUser.user.id);
|
||||
|
||||
if (updateProfileError) {
|
||||
console.error("Error setting invited user temporary status:", updateProfileError);
|
||||
return { success: false, error: updateProfileError.message };
|
||||
}
|
||||
|
||||
await streamServerClient.upsertUser({
|
||||
id: newUser.user.id,
|
||||
name: recipientEmail.split("@")[0],
|
||||
|
|
|
|||
|
|
@ -58,7 +58,8 @@ const bookSlot = factory.createHandlers(async (c) => {
|
|||
streamServerClient,
|
||||
transporter,
|
||||
data.user_details.email,
|
||||
ownerData.email
|
||||
ownerData.email,
|
||||
{ isTemporary: true }
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,162 @@
|
|||
import { Hono } from "hono";
|
||||
import { createFactory } from "hono/factory";
|
||||
import Stripe from "stripe";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import { getOrganizationBillingState } from "../helpers/billing.js";
|
||||
import { MiddlewareManager } from "../middlewares/middleware.js";
|
||||
import type { AuthEnv, BaseEnv } from "../types/app.types.js";
|
||||
|
||||
const DEFAULT_INFINITE_SUBSCRIPTION_ALLOWLIST = new Set([
|
||||
"arbelleville@gmail.com",
|
||||
"baptiste.belleville74@gmail.com",
|
||||
"hugo@xtablo.com",
|
||||
]);
|
||||
|
||||
const BILLING_OWNER_ONLY_ERROR = "Only the organization billing owner can manage billing";
|
||||
|
||||
type CheckoutPlan = "solo" | "team" | "founder";
|
||||
|
||||
type StripePriceRow = {
|
||||
id: string;
|
||||
unit_amount: number | null;
|
||||
lookup_key: string | null;
|
||||
metadata: Record<string, string | null> | null;
|
||||
};
|
||||
|
||||
type CreateCheckoutSessionBody = {
|
||||
plan?: CheckoutPlan;
|
||||
priceId?: string;
|
||||
price_id?: string;
|
||||
successUrl?: string;
|
||||
success_url?: string;
|
||||
cancelUrl?: string;
|
||||
cancel_url?: string;
|
||||
};
|
||||
|
||||
const parseCsvList = (value: string | undefined): string[] =>
|
||||
(value ?? "")
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
|
||||
const getInfinitePriceAllowlist = (): Set<string> => {
|
||||
const configuredAllowlist = parseCsvList(process.env.STRIPE_INFINITE_ALLOWED_EMAILS).map(
|
||||
(email) => email.toLowerCase()
|
||||
);
|
||||
|
||||
return configuredAllowlist.length > 0
|
||||
? new Set(configuredAllowlist)
|
||||
: DEFAULT_INFINITE_SUBSCRIPTION_ALLOWLIST;
|
||||
};
|
||||
|
||||
const getInfinitePriceIds = (): Set<string> => {
|
||||
const configuredIds = [
|
||||
...parseCsvList(process.env.STRIPE_INFINITE_PRICE_IDS),
|
||||
...(process.env.STRIPE_INFINITE_PRICE_ID ? [process.env.STRIPE_INFINITE_PRICE_ID] : []),
|
||||
].map((id) => id.trim());
|
||||
|
||||
return new Set(configuredIds.filter((id) => id.length > 0));
|
||||
};
|
||||
|
||||
const isInfinitePrice = (price: StripePriceRow): boolean => {
|
||||
const configuredPriceIds = getInfinitePriceIds();
|
||||
if (configuredPriceIds.size > 0) {
|
||||
return configuredPriceIds.has(price.id);
|
||||
}
|
||||
|
||||
const lookupKey = price.lookup_key?.toLowerCase() ?? "";
|
||||
const metadataPlan = price.metadata?.plan?.toLowerCase() ?? "";
|
||||
const metadataTier = price.metadata?.tier?.toLowerCase() ?? "";
|
||||
|
||||
// Backward-compatible fallback for existing infinite setup.
|
||||
return (
|
||||
lookupKey === "infinite" ||
|
||||
lookupKey === "infinite_subscription" ||
|
||||
metadataPlan === "infinite" ||
|
||||
metadataTier === "infinite" ||
|
||||
price.unit_amount === 0
|
||||
);
|
||||
};
|
||||
|
||||
const getPlanPriceId = (plan: CheckoutPlan): string | null => {
|
||||
if (plan === "solo") {
|
||||
return process.env.STRIPE_SOLO_PRICE_ID ?? null;
|
||||
}
|
||||
|
||||
if (plan === "team") {
|
||||
return process.env.STRIPE_TEAM_MONTHLY_PRICE_ID ?? null;
|
||||
}
|
||||
|
||||
return process.env.STRIPE_FOUNDER_PRICE_ID ?? null;
|
||||
};
|
||||
|
||||
const findStripeCustomerByUserId = async (
|
||||
supabase: AuthEnv["Variables"]["supabase"],
|
||||
userId: string
|
||||
) => {
|
||||
const { data: customers, error } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata")
|
||||
.limit(1000);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return (
|
||||
(
|
||||
customers as Array<{ id: string; metadata: Record<string, string | null> | null }> | null
|
||||
)?.find((customer) => customer.metadata?.user_id === userId) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const resolveBillingOwnerContext = async (
|
||||
supabase: AuthEnv["Variables"]["supabase"],
|
||||
userId: string
|
||||
) => {
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from("profiles")
|
||||
.select("organization_id")
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
|
||||
if (profileError || !profile?.organization_id) {
|
||||
return { error: "Failed to resolve your organization", data: null };
|
||||
}
|
||||
|
||||
const { data: billingState, error: billingError } = await getOrganizationBillingState(
|
||||
supabase,
|
||||
profile.organization_id
|
||||
);
|
||||
|
||||
if (billingError || !billingState) {
|
||||
return { error: "Failed to resolve organization billing state", data: null };
|
||||
}
|
||||
|
||||
return {
|
||||
error: null,
|
||||
data: {
|
||||
organizationId: profile.organization_id,
|
||||
billingState,
|
||||
isBillingOwner: billingState.owner_user_id === userId,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getCheckoutPrice = async (
|
||||
supabase: AuthEnv["Variables"]["supabase"],
|
||||
priceId: string
|
||||
): Promise<StripePriceRow | null> => {
|
||||
const { data: price } = await supabase
|
||||
.schema("stripe")
|
||||
.from("prices")
|
||||
.select("id, unit_amount, lookup_key, metadata")
|
||||
.eq("id", priceId)
|
||||
.maybeSingle();
|
||||
|
||||
return (price as StripePriceRow | null) ?? null;
|
||||
};
|
||||
|
||||
const webhookFactory = createFactory<BaseEnv>();
|
||||
|
||||
/**
|
||||
|
|
@ -61,83 +213,107 @@ const createCheckoutSession = (
|
|||
const supabase = c.get("supabase");
|
||||
const stripe = c.get("stripe");
|
||||
|
||||
const body = await c.req.json();
|
||||
const { priceId, successUrl, cancelUrl } = body;
|
||||
const body = (await c.req.json()) as CreateCheckoutSessionBody;
|
||||
const plan = body.plan;
|
||||
const legacyPriceId = body.priceId ?? body.price_id;
|
||||
const successUrl = body.successUrl ?? body.success_url;
|
||||
const cancelUrl = body.cancelUrl ?? body.cancel_url;
|
||||
|
||||
if (!plan && !legacyPriceId) {
|
||||
return c.json({ error: "plan or priceId is required" }, 400);
|
||||
}
|
||||
|
||||
if (plan && !["solo", "team", "founder"].includes(plan)) {
|
||||
return c.json({ error: "Invalid plan" }, 400);
|
||||
}
|
||||
|
||||
const { data: ownerContext, error: ownerError } = await resolveBillingOwnerContext(
|
||||
supabase,
|
||||
user.id
|
||||
);
|
||||
|
||||
if (ownerError || !ownerContext) {
|
||||
return c.json({ error: ownerError ?? "Failed to resolve billing owner" }, 500);
|
||||
}
|
||||
|
||||
if (!ownerContext.isBillingOwner) {
|
||||
return c.json({ error: BILLING_OWNER_ONLY_ERROR }, 403);
|
||||
}
|
||||
|
||||
let priceId = legacyPriceId ?? null;
|
||||
let quantity = 1;
|
||||
|
||||
if (plan) {
|
||||
priceId = getPlanPriceId(plan);
|
||||
if (!priceId) {
|
||||
return c.json({ error: `Missing Stripe price configuration for plan: ${plan}` }, 500);
|
||||
}
|
||||
|
||||
if (plan === "team") {
|
||||
quantity = Math.max(1, ownerContext.billingState.member_count);
|
||||
}
|
||||
}
|
||||
|
||||
if (!priceId) {
|
||||
return c.json({ error: "priceId is required" }, 400);
|
||||
}
|
||||
|
||||
const { data: price } = await supabase
|
||||
.schema("stripe")
|
||||
.from("prices")
|
||||
.select("*")
|
||||
.eq("id", priceId)
|
||||
.maybeSingle();
|
||||
const price = await getCheckoutPrice(supabase, priceId);
|
||||
|
||||
if (!price) {
|
||||
return c.json({ error: "Price not found" }, 404);
|
||||
}
|
||||
|
||||
const allowedInfiniteUsers = [
|
||||
"arbelleville@gmail.com",
|
||||
"baptiste.belleville74@gmail.com",
|
||||
"hugo@xtablo.com",
|
||||
];
|
||||
|
||||
if (price.unit_amount === 0 && !allowedInfiniteUsers.includes(user.email!)) {
|
||||
return c.json({ error: "This price is not available" }, 400);
|
||||
const normalizedEmail = user.email?.toLowerCase() ?? "";
|
||||
const isRestrictedInfinitePrice = isInfinitePrice(price);
|
||||
const infinitePriceAllowlist = getInfinitePriceAllowlist();
|
||||
if (isRestrictedInfinitePrice && !infinitePriceAllowlist.has(normalizedEmail)) {
|
||||
return c.json({ error: "This price is not available" }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get or create Stripe customer
|
||||
let customerId: string;
|
||||
const ownerUserId = ownerContext.billingState.owner_user_id;
|
||||
const ownerCustomer = await findStripeCustomerByUserId(supabase, ownerUserId);
|
||||
|
||||
// Check if customer already exists by querying stripe schema with metadata filter
|
||||
// Note: Using service role, so we filter manually by metadata
|
||||
const { data: customers } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata")
|
||||
.limit(1000); // Get all customers to filter by metadata
|
||||
let customerId = ownerCustomer?.id ?? "";
|
||||
if (!customerId) {
|
||||
if (!user.email) {
|
||||
return c.json({ error: "Billing owner email is required" }, 400);
|
||||
}
|
||||
|
||||
const existingCustomer = customers?.find(
|
||||
(c: Stripe.Customer) => c.metadata?.user_id === user.id
|
||||
);
|
||||
|
||||
if (existingCustomer) {
|
||||
customerId = existingCustomer.id;
|
||||
} else {
|
||||
// Create new Stripe customer with user_id in metadata
|
||||
// stripe-sync-engine will automatically sync this to the database via webhook
|
||||
// Create new Stripe customer with user_id in metadata.
|
||||
// stripe-sync-engine syncs this customer to Supabase via webhook.
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email!,
|
||||
email: user.email,
|
||||
metadata: {
|
||||
user_id: user.id, // Stored in metadata for tracking
|
||||
user_id: ownerUserId,
|
||||
},
|
||||
});
|
||||
|
||||
customerId = customer.id;
|
||||
}
|
||||
|
||||
// Create Checkout Session
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: 1,
|
||||
quantity,
|
||||
},
|
||||
],
|
||||
mode: "subscription",
|
||||
success_url: successUrl || `${config.XTABLO_URL}/settings?success=true`,
|
||||
cancel_url: cancelUrl || `${config.XTABLO_URL}/settings?canceled=true`,
|
||||
metadata: {
|
||||
user_id: user.id,
|
||||
user_id: ownerUserId,
|
||||
organization_id: String(ownerContext.organizationId),
|
||||
checkout_plan: plan ?? "legacy_price",
|
||||
},
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
user_id: user.id,
|
||||
user_id: ownerUserId,
|
||||
organization_id: String(ownerContext.organizationId),
|
||||
checkout_plan: plan ?? "legacy_price",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -166,16 +342,26 @@ const createPortalSession = (
|
|||
const stripe = c.get("stripe");
|
||||
|
||||
const body = await c.req.json();
|
||||
const { returnUrl } = body;
|
||||
const returnUrl = body.returnUrl ?? body.return_url;
|
||||
|
||||
const { data: ownerContext, error: ownerError } = await resolveBillingOwnerContext(
|
||||
supabase,
|
||||
user.id
|
||||
);
|
||||
|
||||
if (ownerError || !ownerContext) {
|
||||
return c.json({ error: ownerError ?? "Failed to resolve billing owner" }, 500);
|
||||
}
|
||||
|
||||
if (!ownerContext.isBillingOwner) {
|
||||
return c.json({ error: BILLING_OWNER_ONLY_ERROR }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get Stripe customer ID by filtering metadata
|
||||
const { data: customers } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata");
|
||||
|
||||
const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id);
|
||||
const customer = await findStripeCustomerByUserId(
|
||||
supabase,
|
||||
ownerContext.billingState.owner_user_id
|
||||
);
|
||||
|
||||
if (!customer) {
|
||||
return c.json({ error: "No Stripe customer found" }, 404);
|
||||
|
|
@ -210,14 +396,24 @@ const cancelSubscription = (middlewareManager: ReturnType<typeof MiddlewareManag
|
|||
const supabase = c.get("supabase");
|
||||
const stripe = c.get("stripe");
|
||||
|
||||
try {
|
||||
// Get user's Stripe customer first
|
||||
const { data: customers } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata");
|
||||
const { data: ownerContext, error: ownerError } = await resolveBillingOwnerContext(
|
||||
supabase,
|
||||
user.id
|
||||
);
|
||||
|
||||
const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id);
|
||||
if (ownerError || !ownerContext) {
|
||||
return c.json({ error: ownerError ?? "Failed to resolve billing owner" }, 500);
|
||||
}
|
||||
|
||||
if (!ownerContext.isBillingOwner) {
|
||||
return c.json({ error: BILLING_OWNER_ONLY_ERROR }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const customer = await findStripeCustomerByUserId(
|
||||
supabase,
|
||||
ownerContext.billingState.owner_user_id
|
||||
);
|
||||
|
||||
if (!customer) {
|
||||
return c.json({ error: "Customer not found" }, 404);
|
||||
|
|
@ -265,14 +461,24 @@ const reactivateSubscription = (
|
|||
const supabase = c.get("supabase");
|
||||
const stripe = c.get("stripe");
|
||||
|
||||
try {
|
||||
// Get user's Stripe customer first
|
||||
const { data: customers } = await supabase
|
||||
.schema("stripe")
|
||||
.from("customers")
|
||||
.select("id, metadata");
|
||||
const { data: ownerContext, error: ownerError } = await resolveBillingOwnerContext(
|
||||
supabase,
|
||||
user.id
|
||||
);
|
||||
|
||||
const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id);
|
||||
if (ownerError || !ownerContext) {
|
||||
return c.json({ error: ownerError ?? "Failed to resolve billing owner" }, 500);
|
||||
}
|
||||
|
||||
if (!ownerContext.isBillingOwner) {
|
||||
return c.json({ error: BILLING_OWNER_ONLY_ERROR }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const customer = await findStripeCustomerByUserId(
|
||||
supabase,
|
||||
ownerContext.billingState.owner_user_id
|
||||
);
|
||||
|
||||
if (!customer) {
|
||||
return c.json({ error: "No subscription found to reactivate" }, 404);
|
||||
|
|
|
|||
|
|
@ -33,7 +33,11 @@ const upsertStreamUserFromProfile = async (
|
|||
streamServerClient: AuthEnv["Variables"]["streamServerClient"],
|
||||
userId: string
|
||||
) => {
|
||||
const { data: profile } = await supabase.from("profiles").select("name").eq("id", userId).maybeSingle();
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("name")
|
||||
.eq("id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
await streamServerClient.upsertUser({
|
||||
id: userId,
|
||||
|
|
@ -255,10 +259,7 @@ const deleteTablo = factory.createHandlers(async (c) => {
|
|||
}
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("tablos")
|
||||
.update({ deleted_at: deletedAt })
|
||||
.eq("id", id);
|
||||
const { error } = await supabase.from("tablos").update({ deleted_at: deletedAt }).eq("id", id);
|
||||
|
||||
if (error) {
|
||||
return c.json({ error: error.message }, 500);
|
||||
|
|
@ -352,7 +353,8 @@ const inviteToTablo = (
|
|||
streamServerClient,
|
||||
transporter,
|
||||
recipientEmail,
|
||||
sender.email
|
||||
sender.email,
|
||||
{ isTemporary: true }
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -427,9 +429,7 @@ ${introEmail ? `<p>${introEmail}</p>` : ""}
|
|||
});
|
||||
});
|
||||
|
||||
const cancelPendingInvite = (
|
||||
middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>
|
||||
) =>
|
||||
const cancelPendingInvite = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
|
||||
factory.createHandlers(middlewareManager.regularUserCheck, async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
|
|
|
|||
|
|
@ -230,9 +230,7 @@ const deleteTabloFile = (middlewareManager: ReturnType<typeof MiddlewareManager.
|
|||
);
|
||||
|
||||
const uploadedBy =
|
||||
headResponse.Metadata?.["uploaded-by"] ??
|
||||
headResponse.Metadata?.uploaded_by ??
|
||||
null;
|
||||
headResponse.Metadata?.["uploaded-by"] ?? headResponse.Metadata?.uploaded_by ?? null;
|
||||
|
||||
if (uploadedBy !== user.id) {
|
||||
return c.json({ error: "You can only delete files you uploaded" }, 403);
|
||||
|
|
|
|||
|
|
@ -2,15 +2,13 @@ import { DeleteObjectsCommand, ListObjectsV2Command, PutObjectCommand } from "@a
|
|||
import type { Tables } from "@xtablo/shared-types";
|
||||
import { Hono } from "hono";
|
||||
import { createFactory } from "hono/factory";
|
||||
import {
|
||||
createInvitedUser,
|
||||
getOrganizationPlan,
|
||||
MAX_TABLO_LIMIT,
|
||||
MAX_TEAM_MEMBER_LIMIT,
|
||||
} from "../helpers/helpers.js";
|
||||
import { getOrganizationBillingState } from "../helpers/billing.js";
|
||||
import { createInvitedUser, getOrganizationPlan, MAX_TABLO_LIMIT } from "../helpers/helpers.js";
|
||||
import type { AuthEnv } from "../types/app.types.js";
|
||||
|
||||
const factory = createFactory<AuthEnv>();
|
||||
const isMissingRelationError = (code: string | undefined) =>
|
||||
code === "42P01" || code === "PGRST205";
|
||||
|
||||
const signUpToStream = factory.createHandlers(async (c) => {
|
||||
const { id } = c.get("user");
|
||||
|
|
@ -39,7 +37,10 @@ const getMe = factory.createHandlers(async (c) => {
|
|||
|
||||
const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single();
|
||||
|
||||
const userData = data as Tables<"profiles">;
|
||||
const userData = data as Tables<"profiles"> & {
|
||||
organization_id: number | null;
|
||||
plan: string | null;
|
||||
};
|
||||
|
||||
if (!userData) {
|
||||
return c.json({ error: "User not found" }, 404);
|
||||
|
|
@ -49,11 +50,21 @@ const getMe = factory.createHandlers(async (c) => {
|
|||
return c.json({ error: error.message }, 500);
|
||||
}
|
||||
|
||||
let effectivePlan: string | null = userData.plan;
|
||||
if (userData.organization_id) {
|
||||
const { plan: organizationPlan } = await getOrganizationPlan(
|
||||
supabase,
|
||||
userData.organization_id
|
||||
);
|
||||
effectivePlan = organizationPlan;
|
||||
}
|
||||
|
||||
const user_id = data.id;
|
||||
const token = streamServerClient.createToken(user_id);
|
||||
|
||||
return c.json({
|
||||
...userData,
|
||||
plan: effectivePlan,
|
||||
streamToken: token,
|
||||
});
|
||||
});
|
||||
|
|
@ -317,6 +328,93 @@ const getOrganization = factory.createHandlers(async (c) => {
|
|||
return c.json({ error: "Failed to resolve organization plan" }, 500);
|
||||
}
|
||||
|
||||
const membersWithEffectivePlan = (members || []).map((member) => ({
|
||||
...member,
|
||||
plan,
|
||||
}));
|
||||
|
||||
const { data: billingState, error: billingError } = await getOrganizationBillingState(
|
||||
supabase,
|
||||
organizationId
|
||||
);
|
||||
if (billingError || !billingState) {
|
||||
return c.json({ error: "Failed to resolve organization billing state" }, 500);
|
||||
}
|
||||
|
||||
let invitesSent: Array<{
|
||||
id: number;
|
||||
invited_email: string;
|
||||
invited_user_id: string | null;
|
||||
created_at: string;
|
||||
invited_member: {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
avatar_url: string | null;
|
||||
} | null;
|
||||
}> = [];
|
||||
|
||||
const { data: invitesData, error: invitesError } = await supabase
|
||||
.from("organization_invites")
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
invited_email,
|
||||
invited_user_id,
|
||||
created_at,
|
||||
invited_member:profiles!organization_invites_invited_user_id_fkey(
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
first_name,
|
||||
last_name,
|
||||
avatar_url
|
||||
)
|
||||
`
|
||||
)
|
||||
.eq("organization_id", organizationId)
|
||||
.eq("invited_by", user.id)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (invitesError && !isMissingRelationError(invitesError.code)) {
|
||||
return c.json({ error: "Failed to load organization invites" }, 500);
|
||||
}
|
||||
|
||||
if (!invitesError && invitesData) {
|
||||
invitesSent = invitesData.map((invite) => {
|
||||
const invitedMemberRaw = invite.invited_member as
|
||||
| {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
avatar_url: string | null;
|
||||
}[]
|
||||
| null;
|
||||
|
||||
return {
|
||||
id: invite.id as number,
|
||||
invited_email: (invite.invited_email as string) ?? "",
|
||||
invited_user_id: (invite.invited_user_id as string | null) ?? null,
|
||||
created_at: (invite.created_at as string) ?? new Date().toISOString(),
|
||||
invited_member: Array.isArray(invitedMemberRaw)
|
||||
? (invitedMemberRaw[0] ?? null)
|
||||
: invitedMemberRaw,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({
|
||||
organization: {
|
||||
id: organization.id,
|
||||
|
|
@ -325,7 +423,16 @@ const getOrganization = factory.createHandlers(async (c) => {
|
|||
member_count: members?.length || 0,
|
||||
tablo_count: tabloCount || 0,
|
||||
},
|
||||
members: members || [],
|
||||
members: membersWithEffectivePlan,
|
||||
trial_starts_at: billingState.trial_starts_at,
|
||||
trial_ends_at: billingState.trial_ends_at,
|
||||
is_trial_expired: billingState.is_trial_expired,
|
||||
required_plan: billingState.required_plan,
|
||||
required_team_quantity: billingState.required_team_quantity,
|
||||
active_subscription_plan: billingState.active_subscription_plan,
|
||||
active_subscription_quantity: billingState.active_subscription_quantity,
|
||||
is_billing_owner: billingState.owner_user_id === user.id,
|
||||
invites_sent: invitesSent,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -398,10 +505,12 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
|
|||
}
|
||||
|
||||
const organizationId = senderProfile.organization_id;
|
||||
const { plan, error: planError } = await getOrganizationPlan(supabase, organizationId);
|
||||
|
||||
if (planError) {
|
||||
return c.json({ error: "Failed to resolve organization plan" }, 500);
|
||||
const { data: billingState, error: billingError } = await getOrganizationBillingState(
|
||||
supabase,
|
||||
organizationId
|
||||
);
|
||||
if (billingError || !billingState) {
|
||||
return c.json({ error: "Failed to resolve organization billing state" }, 500);
|
||||
}
|
||||
|
||||
const { data: existingUser, error: existingUserError } = await supabase
|
||||
|
|
@ -422,31 +531,32 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
|
|||
return c.json({ error: "This email already belongs to another organization" }, 409);
|
||||
}
|
||||
|
||||
const { count: membersCount, error: membersCountError } = await supabase
|
||||
.from("profiles")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("organization_id", organizationId);
|
||||
if (billingState.is_trial_expired && billingState.active_subscription_plan !== "annual") {
|
||||
const requiredSeatsForInvite = billingState.member_count + 1;
|
||||
|
||||
if (membersCountError) {
|
||||
return c.json({ error: "Failed to check organization size" }, 500);
|
||||
}
|
||||
if (billingState.active_subscription_plan === "team") {
|
||||
if (billingState.active_subscription_quantity < requiredSeatsForInvite) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
"Your Teams subscription does not have enough seats for this invite. Please increase seats in billing and try again.",
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const noSubscriptionMessage =
|
||||
billingState.required_plan === "solo"
|
||||
? "Your trial has ended. Solo allows 1 member only. Upgrade to Teams to invite more members."
|
||||
: "Your trial has ended. An active Teams subscription is required to invite members. Please subscribe or increase seats in billing.";
|
||||
|
||||
if (plan === "solo" && (membersCount || 0) >= 1) {
|
||||
return c.json(
|
||||
{
|
||||
error: `Solo plan allows a single user only. Upgrade to Team to invite collaborators.`,
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
if (plan === "team" && (membersCount || 0) >= MAX_TEAM_MEMBER_LIMIT) {
|
||||
return c.json(
|
||||
{
|
||||
error: `Team plan allows up to ${MAX_TEAM_MEMBER_LIMIT} users. Upgrade to Annual to invite more.`,
|
||||
},
|
||||
403
|
||||
);
|
||||
return c.json(
|
||||
{
|
||||
error: noSubscriptionMessage,
|
||||
},
|
||||
403
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const invitedUser = await createInvitedUser(
|
||||
|
|
@ -454,7 +564,8 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
|
|||
streamServerClient,
|
||||
transporter,
|
||||
recipientEmail,
|
||||
senderProfile.email
|
||||
senderProfile.email,
|
||||
{ isTemporary: false }
|
||||
);
|
||||
|
||||
if (!invitedUser.success || !invitedUser.userId) {
|
||||
|
|
@ -475,7 +586,7 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
|
|||
|
||||
const { error: assignOrganizationError } = await supabase
|
||||
.from("profiles")
|
||||
.update({ organization_id: organizationId })
|
||||
.update({ organization_id: organizationId, is_temporary: false })
|
||||
.eq("id", invitedUser.userId);
|
||||
|
||||
if (assignOrganizationError) {
|
||||
|
|
@ -530,16 +641,162 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
|
|||
}
|
||||
}
|
||||
|
||||
const { error: inviteHistoryError } = await supabase.from("organization_invites").insert({
|
||||
organization_id: organizationId,
|
||||
invited_by: user.id,
|
||||
invited_email: recipientEmail,
|
||||
invited_user_id: invitedUser.userId,
|
||||
});
|
||||
|
||||
if (inviteHistoryError && !isMissingRelationError(inviteHistoryError.code)) {
|
||||
console.error("Failed to store organization invite history:", inviteHistoryError);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
message: "Invitation sent successfully",
|
||||
limits: {
|
||||
plan,
|
||||
plan: billingState.active_subscription_plan ?? billingState.required_plan,
|
||||
max_tablos_for_solo: MAX_TABLO_LIMIT,
|
||||
max_members_for_team: MAX_TEAM_MEMBER_LIMIT,
|
||||
required_team_quantity_for_next_invite: billingState.member_count + 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const removeOrganizationMember = factory.createHandlers(async (c) => {
|
||||
const user = c.get("user");
|
||||
const supabase = c.get("supabase");
|
||||
const streamServerClient = c.get("streamServerClient");
|
||||
const memberId = c.req.param("memberId");
|
||||
|
||||
if (!memberId) {
|
||||
return c.json({ error: "Member id is required" }, 400);
|
||||
}
|
||||
|
||||
const { data: actorProfile, error: actorProfileError } = await supabase
|
||||
.from("profiles")
|
||||
.select("organization_id, is_temporary")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
|
||||
if (actorProfileError || !actorProfile?.organization_id) {
|
||||
return c.json({ error: "Failed to resolve your organization" }, 500);
|
||||
}
|
||||
|
||||
if (actorProfile.is_temporary) {
|
||||
return c.json({ error: "Temporary users cannot manage organization members" }, 403);
|
||||
}
|
||||
|
||||
const organizationId = actorProfile.organization_id;
|
||||
const { data: billingState, error: billingError } = await getOrganizationBillingState(
|
||||
supabase,
|
||||
organizationId
|
||||
);
|
||||
|
||||
if (billingError || !billingState) {
|
||||
return c.json({ error: "Failed to resolve organization billing state" }, 500);
|
||||
}
|
||||
|
||||
if (billingState.owner_user_id !== user.id) {
|
||||
return c.json({ error: "Only the organization creator can remove members" }, 403);
|
||||
}
|
||||
|
||||
if (memberId === billingState.owner_user_id) {
|
||||
return c.json({ error: "The organization creator cannot be removed" }, 400);
|
||||
}
|
||||
|
||||
const { data: memberProfile, error: memberProfileError } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, email, name, first_name, last_name, organization_id")
|
||||
.eq("id", memberId)
|
||||
.maybeSingle();
|
||||
|
||||
if (memberProfileError) {
|
||||
return c.json({ error: memberProfileError.message }, 500);
|
||||
}
|
||||
|
||||
if (!memberProfile || memberProfile.organization_id !== organizationId) {
|
||||
return c.json({ error: "Member not found in your organization" }, 404);
|
||||
}
|
||||
|
||||
const baseName =
|
||||
[memberProfile.first_name, memberProfile.last_name].filter(Boolean).join(" ").trim() ||
|
||||
memberProfile.name?.trim() ||
|
||||
memberProfile.email?.split("@")[0]?.trim() ||
|
||||
"Personal";
|
||||
|
||||
const { data: newOrganization, error: newOrganizationError } = await supabase
|
||||
.from("organizations")
|
||||
.insert({ name: `${baseName}'s Workspace` })
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
if (newOrganizationError || !newOrganization) {
|
||||
return c.json({ error: "Failed to create a workspace for this member" }, 500);
|
||||
}
|
||||
|
||||
const { error: assignError } = await supabase
|
||||
.from("profiles")
|
||||
.update({ organization_id: newOrganization.id })
|
||||
.eq("id", memberId);
|
||||
|
||||
if (assignError) {
|
||||
return c.json({ error: "Failed to remove member from organization" }, 500);
|
||||
}
|
||||
|
||||
const { error: transferOwnershipError } = await supabase
|
||||
.from("tablos")
|
||||
.update({ owner_id: user.id })
|
||||
.eq("organization_id", organizationId)
|
||||
.eq("owner_id", memberId);
|
||||
|
||||
if (transferOwnershipError) {
|
||||
return c.json({ error: "Failed to transfer ownership for member tablos" }, 500);
|
||||
}
|
||||
|
||||
const { data: organizationTablos, error: tablosError } = await supabase
|
||||
.from("tablos")
|
||||
.select("id")
|
||||
.eq("organization_id", organizationId);
|
||||
|
||||
if (tablosError) {
|
||||
return c.json({ error: "Failed to synchronize organization access" }, 500);
|
||||
}
|
||||
|
||||
const tabloIds = (organizationTablos || []).map((tablo) => tablo.id);
|
||||
if (tabloIds.length > 0) {
|
||||
const { error: removeAccessError } = await supabase
|
||||
.from("tablo_access")
|
||||
.delete()
|
||||
.eq("user_id", memberId)
|
||||
.in("tablo_id", tabloIds);
|
||||
|
||||
if (removeAccessError) {
|
||||
return c.json({ error: "Failed to revoke member tablo permissions" }, 500);
|
||||
}
|
||||
|
||||
for (const tabloId of tabloIds) {
|
||||
try {
|
||||
const channel = streamServerClient.channel("messaging", tabloId);
|
||||
await channel.removeMembers([memberId]);
|
||||
} catch (error) {
|
||||
console.error("Failed to remove organization member from Stream channel:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { error: inviteCleanupError } = await supabase
|
||||
.from("organization_invites")
|
||||
.delete()
|
||||
.eq("organization_id", organizationId)
|
||||
.eq("invited_user_id", memberId);
|
||||
|
||||
if (inviteCleanupError && !isMissingRelationError(inviteCleanupError.code)) {
|
||||
console.error("Failed to clean organization invite history:", inviteCleanupError);
|
||||
}
|
||||
|
||||
return c.json({ message: "Member removed successfully" });
|
||||
});
|
||||
|
||||
export const getUserRouter = () => {
|
||||
const userRouter = new Hono();
|
||||
|
||||
|
|
@ -551,6 +808,7 @@ export const getUserRouter = () => {
|
|||
userRouter.get("/organization", ...getOrganization);
|
||||
userRouter.patch("/organization", ...updateOrganization);
|
||||
userRouter.post("/organization/invite", ...inviteToOrganization);
|
||||
userRouter.delete("/organization/members/:memberId", ...removeOrganizationMember);
|
||||
|
||||
return userRouter;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,4 +10,9 @@ VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51Qc159AmcXPHW4mTHUTW6it2mdZ3KQTxZGXZ188DKpX
|
|||
VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_1SO0HAAmcXPHW4mTkFIh3CvF
|
||||
VITE_STRIPE_INFINITE_PRICE_ID=price_1SXHp8AmcXPHW4mTbus6j4Za
|
||||
|
||||
VITE_API_URL=https://xablo-api-636270553187.europe-west1.run.app
|
||||
VITE_API_URL=https://xablo-api-636270553187.europe-west1.run.app
|
||||
VITE_STRIPE_TEAM_MONTHLY_PRICE_ID=price_team_placeholder
|
||||
|
||||
VITE_STRIPE_FOUNDER_PRICE_ID=price_annual_placeholder
|
||||
|
||||
VITE_STRIPE_SOLO_PRICE_ID=price_solo_placeholder
|
||||
|
|
|
|||
|
|
@ -9,4 +9,10 @@ VITE_STREAM_CHAT_API_KEY="t5vvvddteapa"
|
|||
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51SPKLPAto3YQ7YhIrM5ViAUXWuSwKJeHyOyOINVg9cnwxxOcbMlyhxQcDYWDSLNQJukafxbc7kqpkGI82lFezaiM00rgcALKB0
|
||||
VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_1SPr3qAto3YQ7YhIALNeFBva
|
||||
|
||||
VITE_API_URL=https://xablo-api-staging-636270553187.europe-west1.run.app
|
||||
VITE_API_URL=https://xablo-api-staging-636270553187.europe-west1.run.app
|
||||
|
||||
VITE_STRIPE_SOLO_PRICE_ID=price_1T8iT4Ato3YQ7YhIYCIIk0RA
|
||||
VITE_STRIPE_TEAM_MONTHLY_PRICE_ID=price_1T8hZfAto3YQ7YhIRK9YUSub
|
||||
VITE_STRIPE_FOUNDER_PRICE_ID=price_1T8hawAto3YQ7YhIrVyKHggH
|
||||
|
||||
VITE_STRIPE_INFINITE_PRICE_ID=price_infinite_placeholder
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
|
|||
import { Toaster } from "@xtablo/ui/components/sonner";
|
||||
import { BrowserRouter as Router, useRoutes } from "react-router-dom";
|
||||
import { CookieBanner } from "./components/CookieBanner";
|
||||
import { PendingFounderCheckout } from "./components/PendingFounderCheckout";
|
||||
import { TrialUpsellModal } from "./components/TrialUpsellModal";
|
||||
import { UpgradePanel } from "./components/UpgradePanel";
|
||||
import { UpgradeBlockProvider } from "./contexts/UpgradeBlockContext";
|
||||
|
|
@ -29,6 +30,7 @@ const Routes = () => {
|
|||
return (
|
||||
<UserStoreProvider>
|
||||
<UpgradeBlockProvider>
|
||||
<PendingFounderCheckout />
|
||||
<UpgradePanel />
|
||||
<TrialUpsellModal />
|
||||
{appElement}
|
||||
|
|
|
|||
|
|
@ -40,20 +40,20 @@ export function ActionCard({
|
|||
: isPrimary
|
||||
? "bg-primary text-white hover:shadow-lg"
|
||||
: "bg-white dark:bg-gray-800 border border-[#EAECF0] dark:border-gray-700 hover:shadow-md",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-[8px] flex items-center justify-center flex-shrink-0",
|
||||
isActive ? "bg-white/20" : "bg-[#F4F3FF] dark:bg-purple-900/20",
|
||||
isActive ? "bg-white/20" : "bg-[#F4F3FF] dark:bg-purple-900/20"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"w-6 h-6 flex items-center justify-center",
|
||||
isActive ? "text-white" : "text-[#7F56D9]",
|
||||
isActive ? "text-white" : "text-[#7F56D9]"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
|
|
@ -65,7 +65,7 @@ export function ActionCard({
|
|||
<span
|
||||
className={cn(
|
||||
"font-semibold text-lg leading-tight",
|
||||
isActive ? "text-white" : "text-gray-900 dark:text-gray-100",
|
||||
isActive ? "text-white" : "text-gray-900 dark:text-gray-100"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
|
|
@ -79,7 +79,7 @@ export function ActionCard({
|
|||
<p
|
||||
className={cn(
|
||||
"text-sm mt-0.5",
|
||||
isActive ? "text-purple-100" : "text-gray-500 dark:text-gray-400",
|
||||
isActive ? "text-purple-100" : "text-gray-500 dark:text-gray-400"
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import { ActionCard } from "./ActionCard";
|
|||
export interface DashboardActionCardsProps {
|
||||
onCreateProject?: () => void;
|
||||
onCreateTask?: () => void;
|
||||
onInviteTeam?: () => void;
|
||||
onSendMessage?: () => void;
|
||||
}
|
||||
|
||||
export function DashboardActionCards({
|
||||
onCreateProject,
|
||||
onCreateTask,
|
||||
onInviteTeam,
|
||||
onSendMessage,
|
||||
}: DashboardActionCardsProps) {
|
||||
const { t } = useTranslation("pages");
|
||||
|
|
@ -35,8 +37,7 @@ export function DashboardActionCards({
|
|||
icon={<UserPlus className="w-6 h-6" />}
|
||||
label={t("dashboard.actionCards.inviteTeam.label")}
|
||||
description={t("dashboard.actionCards.inviteTeam.description")}
|
||||
disabled
|
||||
badge="Bientôt"
|
||||
onClick={onInviteTeam}
|
||||
/>
|
||||
|
||||
<ActionCard
|
||||
|
|
|
|||
|
|
@ -13,28 +13,21 @@ type TaskWithTablo = KanbanTask & {
|
|||
tablos: { id: string; name: string; color: string | null } | null;
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<
|
||||
TaskStatus,
|
||||
{ className: string; labelKey: string }
|
||||
> = {
|
||||
const STATUS_BADGE: Record<TaskStatus, { className: string; labelKey: string }> = {
|
||||
todo: {
|
||||
className:
|
||||
"bg-blue-50 text-blue-600 dark:bg-blue-950/30 dark:text-blue-400",
|
||||
className: "bg-blue-50 text-blue-600 dark:bg-blue-950/30 dark:text-blue-400",
|
||||
labelKey: "dashboard.taskList.status.todo",
|
||||
},
|
||||
in_progress: {
|
||||
className:
|
||||
"bg-yellow-50 text-yellow-600 dark:bg-yellow-950/30 dark:text-yellow-400",
|
||||
className: "bg-yellow-50 text-yellow-600 dark:bg-yellow-950/30 dark:text-yellow-400",
|
||||
labelKey: "dashboard.taskList.status.inProgress",
|
||||
},
|
||||
in_review: {
|
||||
className:
|
||||
"bg-purple-50 text-purple-600 dark:bg-purple-950/30 dark:text-purple-400",
|
||||
className: "bg-purple-50 text-purple-600 dark:bg-purple-950/30 dark:text-purple-400",
|
||||
labelKey: "dashboard.taskList.status.inReview",
|
||||
},
|
||||
done: {
|
||||
className:
|
||||
"bg-green-50 text-green-600 dark:bg-green-950/30 dark:text-green-400",
|
||||
className: "bg-green-50 text-green-600 dark:bg-green-950/30 dark:text-green-400",
|
||||
labelKey: "dashboard.taskList.status.done",
|
||||
},
|
||||
};
|
||||
|
|
@ -76,7 +69,7 @@ function TaskRow({
|
|||
"w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0",
|
||||
isDone
|
||||
? "bg-purple-600 border-purple-600"
|
||||
: "border-gray-300 hover:border-purple-400 dark:border-gray-600 dark:hover:border-purple-500",
|
||||
: "border-gray-300 hover:border-purple-400 dark:border-gray-600 dark:hover:border-purple-500"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -92,7 +85,7 @@ function TaskRow({
|
|||
"text-sm font-medium truncate",
|
||||
isDone
|
||||
? "line-through text-gray-400 dark:text-gray-500"
|
||||
: "text-gray-900 dark:text-gray-100",
|
||||
: "text-gray-900 dark:text-gray-100"
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
|
|
@ -105,7 +98,7 @@ function TaskRow({
|
|||
<div
|
||||
className={cn(
|
||||
"w-6 h-6 rounded-lg flex items-center justify-center text-xs shrink-0",
|
||||
task.tablos.color || "bg-gray-400",
|
||||
task.tablos.color || "bg-gray-400"
|
||||
)}
|
||||
>
|
||||
<span className="text-white font-bold text-[10px]">
|
||||
|
|
@ -128,7 +121,7 @@ function TaskRow({
|
|||
<span
|
||||
className={cn(
|
||||
"px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap",
|
||||
badge.className,
|
||||
badge.className
|
||||
)}
|
||||
>
|
||||
{t(badge.labelKey)}
|
||||
|
|
@ -146,14 +139,10 @@ export function DashboardTaskList() {
|
|||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
|
||||
// Filter to tasks assigned to the current user, limited to recent ones
|
||||
const myTasks =
|
||||
allTasks
|
||||
?.filter((task) => task.assignee_id === user.id)
|
||||
.slice(0, 7) ?? [];
|
||||
const myTasks = allTasks?.filter((task) => task.assignee_id === user.id).slice(0, 7) ?? [];
|
||||
|
||||
const handleToggleDone = (task: TaskWithTablo) => {
|
||||
const newStatus: TaskStatus =
|
||||
task.status === "done" ? "todo" : "done";
|
||||
const newStatus: TaskStatus = task.status === "done" ? "todo" : "done";
|
||||
updateTask.mutate({ id: task.id, status: newStatus });
|
||||
};
|
||||
|
||||
|
|
@ -177,11 +166,7 @@ export function DashboardTaskList() {
|
|||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[600px]">
|
||||
{myTasks.map((task) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
onToggleDone={handleToggleDone}
|
||||
/>
|
||||
<TaskRow key={task.id} task={task} onToggleDone={handleToggleDone} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export const ExceptionModal = ({
|
|||
}) => {
|
||||
const { t } = useTranslation("components");
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// biome-ignore lint/suspicious/noExplicitAny: zodResolver typing is incompatible with current zod type in this package.
|
||||
resolver: zodResolver(formSchema as any),
|
||||
defaultValues: {
|
||||
exceptionType: "day",
|
||||
|
|
|
|||
89
apps/main/src/components/InviteOrganizationModal.tsx
Normal file
89
apps/main/src/components/InviteOrganizationModal.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@xtablo/ui/components/dialog";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
import { Label } from "@xtablo/ui/components/label";
|
||||
import { Loader2Icon, UserPlus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useInviteOrganizationUser } from "../hooks/organization";
|
||||
|
||||
const isEmailValid = (email: string) => /\S+@\S+\.\S+/.test(email.trim());
|
||||
|
||||
export function InviteOrganizationModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (nextOpen: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["settings", "common"]);
|
||||
const { mutate: inviteOrganizationUser, isPending } = useInviteOrganizationUser();
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
|
||||
const close = () => {
|
||||
setInviteEmail("");
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const email = inviteEmail.trim();
|
||||
if (!isEmailValid(email)) {
|
||||
return;
|
||||
}
|
||||
|
||||
inviteOrganizationUser(email, {
|
||||
onSuccess: () => {
|
||||
close();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="w-5 h-5" />
|
||||
{t("settings:teamInvite.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("settings:teamInvite.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="inviteOrganizationEmail">{t("settings:teamInvite.emailLabel")}</Label>
|
||||
<Input
|
||||
id="inviteOrganizationEmail"
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder={t("settings:teamInvite.emailPlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("settings:teamInvite.hint")}</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={close} disabled={isPending}>
|
||||
{t("common:buttons.cancel")}
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={isPending || !isEmailValid(inviteEmail)}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2Icon className="w-4 h-4 mr-1 animate-spin" />
|
||||
{t("settings:teamInvite.inviting")}
|
||||
</>
|
||||
) : (
|
||||
t("settings:teamInvite.invite")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// shadcn components
|
||||
|
||||
import { cn } from "@xtablo/shared/lib/cn.ts";
|
||||
import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -13,8 +13,6 @@ import {
|
|||
import { TypographyLarge, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import {
|
||||
CalendarIcon,
|
||||
Circle,
|
||||
Compass,
|
||||
ConstructionIcon,
|
||||
CreditCard,
|
||||
|
|
@ -38,6 +36,7 @@ import {
|
|||
SquareKanban,
|
||||
Star,
|
||||
Sun,
|
||||
UserMinus,
|
||||
Waves,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
|
|
@ -47,7 +46,8 @@ import { useTranslation } from "react-i18next";
|
|||
import { Link as RouterLink, useLocation } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useLogout } from "../hooks/auth";
|
||||
import { normalizeBillingPlan, useCreateCheckoutSession } from "../hooks/stripe";
|
||||
import { useOrganization, useRemoveOrganizationMember } from "../hooks/organization";
|
||||
import { useCreateCheckoutSession } from "../hooks/stripe";
|
||||
import { useTablosList } from "../hooks/tablos";
|
||||
import { isProd, isStaging } from "../lib/env";
|
||||
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
|
||||
|
|
@ -91,7 +91,28 @@ function NavLink({ isActive, children }: NavLinkProps) {
|
|||
export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
||||
const user = useUser();
|
||||
const { mutate: logout } = useLogout();
|
||||
const { data: organizationData } = useOrganization();
|
||||
const { mutate: removeOrganizationMember, isPending: isRemovingMember } =
|
||||
useRemoveOrganizationMember();
|
||||
const { t } = useTranslation("navigation");
|
||||
const members = organizationData?.members ?? [];
|
||||
const canRemoveMembers = organizationData?.is_billing_owner ?? false;
|
||||
|
||||
const getDisplayName = (input: {
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
}) => {
|
||||
const combined = [input.first_name, input.last_name].filter(Boolean).join(" ").trim();
|
||||
if (combined) {
|
||||
return combined;
|
||||
}
|
||||
if (input.name) {
|
||||
return input.name;
|
||||
}
|
||||
return input.email || t("organizationMenu.unknownUser", "Utilisateur");
|
||||
};
|
||||
|
||||
const MenuSeparator = () => {
|
||||
return <DropdownMenuSeparator className="bg-gray-300 dark:bg-gray-500!" />;
|
||||
|
|
@ -134,7 +155,7 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="User menu"
|
||||
aria-label="Organization menu"
|
||||
variant="ghost"
|
||||
className={twMerge(
|
||||
"flex items-center justify-start hover:bg-navbar-darker w-full h-auto pl-2 py-1.5 gap-1",
|
||||
|
|
@ -142,16 +163,20 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
)}
|
||||
>
|
||||
<Avatar className="size-7">
|
||||
<AvatarImage src={user.avatar_url ?? undefined} alt="Avatar" />
|
||||
<AvatarFallback>{user.name?.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
<AvatarFallback>
|
||||
{organizationData?.organization?.name?.charAt(0).toUpperCase() ?? "O"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{!isCollapsed && (
|
||||
<div className="flex flex-col items-start">
|
||||
<TypographyMuted className="text-gray-700 dark:text-gray-300/90 transition-all duration-300 ml-1 truncate font-medium overflow-hidden text-ellipsis">
|
||||
{user.first_name} {user.last_name}
|
||||
{organizationData?.organization?.name ||
|
||||
t("organizationMenu.title", "Organisation")}
|
||||
</TypographyMuted>
|
||||
<TypographyMuted className="text-gray-500 dark:text-gray-400/90 transition-all duration-300 ml-1 text-xs truncate overflow-hidden text-ellipsis">
|
||||
{user.email}
|
||||
{t("organizationMenu.memberCount", {
|
||||
count: organizationData?.organization?.member_count ?? 0,
|
||||
})}
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -163,34 +188,73 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
align="end"
|
||||
sideOffset={-8}
|
||||
>
|
||||
<div className="flex gap-2 p-1">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={user.avatar_url ?? undefined} alt={user.name ?? "User avatar"} />
|
||||
<AvatarFallback className="bg-gray-300 dark:bg-gray-700 text-gray-700 dark:text-white">
|
||||
{user.name?.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
<AvatarBadge className="size-3">
|
||||
<Circle className="text-emerald-600 fill-current size-2" aria-label="Available" />
|
||||
</AvatarBadge>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<TypographyMuted className="font-bold text-gray-800 dark:text-gray-100 text-sm truncate">
|
||||
{user.name}
|
||||
</TypographyMuted>
|
||||
<TypographyMuted className="text-gray-500 dark:text-gray-300 text-xs truncate">
|
||||
{user.email}
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1 px-2 py-1">
|
||||
<TypographyMuted className="font-bold text-gray-800 dark:text-gray-100 text-sm truncate">
|
||||
{organizationData?.organization?.name || t("organizationMenu.title", "Organisation")}
|
||||
</TypographyMuted>
|
||||
<TypographyMuted className="text-gray-500 dark:text-gray-300 text-xs truncate">
|
||||
{t("organizationMenu.memberCount", {
|
||||
count: organizationData?.organization?.member_count ?? 0,
|
||||
})}
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
|
||||
<MenuSeparator />
|
||||
|
||||
<MenuDropdownItem
|
||||
icon={<LogOutIcon className="w-5 h-5" aria-hidden="true" />}
|
||||
label={t("userMenu.logout")}
|
||||
variant="destructive"
|
||||
onClick={logout}
|
||||
/>
|
||||
<div className="max-h-64 overflow-y-auto px-2 py-1 space-y-2">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
{t("organizationMenu.members", "Membres")}
|
||||
</p>
|
||||
{members.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("organizationMenu.noMembers", "Aucun membre")}
|
||||
</p>
|
||||
) : (
|
||||
members.map((member) => (
|
||||
<div key={member.id} className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage
|
||||
src={member.avatar_url ?? undefined}
|
||||
alt={member.name ?? "Avatar"}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{(member.name || member.email || "U").charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium truncate">
|
||||
{getDisplayName({
|
||||
first_name: member.first_name,
|
||||
last_name: member.last_name,
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
{canRemoveMembers && member.id !== user.id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-red-500 hover:text-red-600"
|
||||
disabled={isRemovingMember}
|
||||
onClick={() => {
|
||||
const confirmed = window.confirm(
|
||||
t("organizationMenu.removeConfirm", "Retirer ce membre de l'organisation ?")
|
||||
);
|
||||
if (!confirmed) return;
|
||||
removeOrganizationMember(member.id);
|
||||
}}
|
||||
>
|
||||
<UserMinus className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MenuSeparator />
|
||||
|
||||
|
|
@ -202,13 +266,12 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
/>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/availabilities">
|
||||
<MenuDropdownItem
|
||||
icon={<CalendarIcon className="w-5 h-5" aria-hidden="true" />}
|
||||
label={t("userMenu.availabilities")}
|
||||
variant="default"
|
||||
/>
|
||||
</RouterLink>
|
||||
<MenuDropdownItem
|
||||
icon={<LogOutIcon className="w-5 h-5" aria-hidden="true" />}
|
||||
label={t("userMenu.logout")}
|
||||
variant="destructive"
|
||||
onClick={logout}
|
||||
/>
|
||||
|
||||
<MenuSeparator />
|
||||
<div className="flex flex-row my-2 ml-1 items-center">
|
||||
|
|
@ -378,16 +441,18 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
const user = useUser();
|
||||
const { t } = useTranslation("navigation");
|
||||
const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
|
||||
const { data: organizationData } = useOrganization();
|
||||
|
||||
const TEAM_MONTHLY_PRICE_ID =
|
||||
import.meta.env.VITE_STRIPE_TEAM_MONTHLY_PRICE_ID ||
|
||||
import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID ||
|
||||
"";
|
||||
const hasCompliantTeamPlan =
|
||||
organizationData?.active_subscription_plan === "annual" ||
|
||||
(organizationData?.active_subscription_plan === "team" &&
|
||||
organizationData.active_subscription_quantity >= organizationData.required_team_quantity);
|
||||
|
||||
const currentPlan = normalizeBillingPlan(user.plan);
|
||||
|
||||
// Show upsell when organization is on Solo plan
|
||||
const shouldShowSoloUpsell = currentPlan === "solo" && !user.is_temporary;
|
||||
const shouldShowSoloUpsell =
|
||||
!user.is_temporary &&
|
||||
!!organizationData &&
|
||||
organizationData.required_plan === "team" &&
|
||||
!hasCompliantTeamPlan;
|
||||
|
||||
type List<T> = T[];
|
||||
|
||||
|
|
@ -530,10 +595,10 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-purple-900 dark:text-purple-100">
|
||||
Plan Solo
|
||||
Plan Teams
|
||||
</p>
|
||||
<p className="text-xs mt-0.5 text-purple-700 dark:text-purple-300">
|
||||
Passez au plan Team pour inviter jusqu'à 3 utilisateurs.
|
||||
Ajoutez des sièges Teams pour inviter des membres.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -541,12 +606,12 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
size="sm"
|
||||
onClick={() =>
|
||||
createCheckout({
|
||||
priceId: TEAM_MONTHLY_PRICE_ID,
|
||||
plan: "team",
|
||||
successUrl: `${window.location.origin}?upgraded=true`,
|
||||
cancelUrl: `${window.location.origin}?canceled=true`,
|
||||
})
|
||||
}
|
||||
disabled={checkoutPending || !TEAM_MONTHLY_PRICE_ID}
|
||||
disabled={checkoutPending}
|
||||
className="w-full h-7 text-xs gap-1 bg-linear-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
|
||||
>
|
||||
{checkoutPending ? (
|
||||
|
|
|
|||
49
apps/main/src/components/PendingFounderCheckout.tsx
Normal file
49
apps/main/src/components/PendingFounderCheckout.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { useOrganization } from "../hooks/organization";
|
||||
import { useCreateCheckoutSession } from "../hooks/stripe";
|
||||
import { PENDING_BILLING_CHECKOUT_PLAN_KEY } from "../lib/billing";
|
||||
import { useMaybeUser } from "../providers/UserStoreProvider";
|
||||
|
||||
export function PendingFounderCheckout() {
|
||||
const user = useMaybeUser();
|
||||
const { data: organizationData, isLoading } = useOrganization();
|
||||
const { mutate: createCheckout } = useCreateCheckoutSession();
|
||||
const hasTriggered = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasTriggered.current) return;
|
||||
if (!user || isLoading || !organizationData) return;
|
||||
|
||||
const pendingCheckout = localStorage.getItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
if (pendingCheckout !== "founder") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (organizationData.active_subscription_plan === "annual") {
|
||||
localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!organizationData.is_billing_owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasTriggered.current = true;
|
||||
localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
createCheckout(
|
||||
{
|
||||
plan: "founder",
|
||||
successUrl: `${window.location.origin}/settings?success=true`,
|
||||
cancelUrl: `${window.location.origin}/settings?canceled=true`,
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
localStorage.setItem(PENDING_BILLING_CHECKOUT_PLAN_KEY, "founder");
|
||||
hasTriggered.current = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
}, [createCheckout, isLoading, organizationData, user]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -64,12 +64,7 @@ export interface ProjectCardProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
export function ProjectCard({
|
||||
tablo,
|
||||
onClick,
|
||||
onMenuClick,
|
||||
className,
|
||||
}: ProjectCardProps) {
|
||||
export function ProjectCard({ tablo, onClick, onMenuClick, className }: ProjectCardProps) {
|
||||
const { t } = useTranslation("pages");
|
||||
const statusConfig = useStatusConfig(tablo.status);
|
||||
const progress = getProgressFromStatus(tablo.status);
|
||||
|
|
@ -84,7 +79,7 @@ export function ProjectCard({
|
|||
<div
|
||||
className={cn(
|
||||
"bg-white dark:bg-gray-800 rounded-2xl p-4 border border-[#EAECF0] dark:border-gray-700 hover:shadow-md transition-shadow cursor-pointer",
|
||||
className,
|
||||
className
|
||||
)}
|
||||
onClick={() => onClick?.(tablo.id)}
|
||||
>
|
||||
|
|
@ -93,7 +88,7 @@ export function ProjectCard({
|
|||
<span
|
||||
className={cn(
|
||||
"px-3 py-1 rounded-full text-xs font-medium border",
|
||||
statusConfig.badgeClass,
|
||||
statusConfig.badgeClass
|
||||
)}
|
||||
>
|
||||
{statusConfig.label}
|
||||
|
|
@ -114,15 +109,11 @@ export function ProjectCard({
|
|||
<div
|
||||
className={cn(
|
||||
"w-12 h-12 rounded-xl flex items-center justify-center shrink-0 overflow-hidden",
|
||||
!tablo.image && (tablo.color || "bg-gray-400"),
|
||||
!tablo.image && (tablo.color || "bg-gray-400")
|
||||
)}
|
||||
>
|
||||
{tablo.image ? (
|
||||
<img
|
||||
src={tablo.image}
|
||||
alt={tablo.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-white font-bold text-lg">
|
||||
{tablo.name.charAt(0).toUpperCase()}
|
||||
|
|
@ -143,19 +134,12 @@ export function ProjectCard({
|
|||
{/* Progress */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{t("tablo.card.progress")}:
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{progress}%
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">{t("tablo.card.progress")}:</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">{progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-100 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 rounded-full transition-all",
|
||||
statusConfig.progressColor,
|
||||
)}
|
||||
className={cn("h-2 rounded-full transition-all", statusConfig.progressColor)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
CardTitle,
|
||||
} from "@xtablo/ui/components/card";
|
||||
import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react";
|
||||
import { useOrganization } from "../hooks/organization";
|
||||
import {
|
||||
normalizeBillingPlan,
|
||||
useCancelSubscription,
|
||||
|
|
@ -18,18 +19,12 @@ import {
|
|||
} from "../hooks/stripe";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
|
||||
const allowedInfiniteUsers = [
|
||||
"arbelleville@gmail.com",
|
||||
"baptiste.belleville74@gmail.com",
|
||||
"hugo@xtablo.com",
|
||||
];
|
||||
|
||||
/**
|
||||
* Subscription management card for Settings page
|
||||
* Shows current subscription status and allows users to upgrade/manage
|
||||
* Subscription management card for Settings page.
|
||||
*/
|
||||
export function SubscriptionCard() {
|
||||
const user = useUser();
|
||||
const { data: organizationData } = useOrganization();
|
||||
const { data: subscription, isLoading: subscriptionLoading } = useSubscription();
|
||||
const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
|
||||
const { mutate: openPortal, isPending: portalPending } = useCreatePortalSession();
|
||||
|
|
@ -37,33 +32,22 @@ export function SubscriptionCard() {
|
|||
const { mutate: reactivateSubscription, isPending: reactivatePending } =
|
||||
useReactivateSubscription();
|
||||
|
||||
const normalizedPlan = normalizeBillingPlan(user.plan);
|
||||
const isPaying = normalizedPlan === "team" || normalizedPlan === "annual";
|
||||
const isAnnual = normalizedPlan === "annual";
|
||||
const isSolo = normalizedPlan === "solo";
|
||||
const fallbackPlan = normalizeBillingPlan(user.plan);
|
||||
const activePlan = organizationData?.active_subscription_plan ?? fallbackPlan;
|
||||
const isBillingOwner = organizationData?.is_billing_owner ?? false;
|
||||
|
||||
// Replace with your actual price ID from Stripe Dashboard
|
||||
const isPaying = activePlan === "team" || activePlan === "annual";
|
||||
const isFounder = activePlan === "annual";
|
||||
|
||||
const infinitePriceId = import.meta.env.VITE_STRIPE_INFINITE_PRICE_ID || "";
|
||||
const teamPriceId =
|
||||
import.meta.env.VITE_STRIPE_TEAM_MONTHLY_PRICE_ID ||
|
||||
import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID ||
|
||||
"";
|
||||
const annualPriceId = import.meta.env.VITE_STRIPE_ANNUAL_PRICE_ID || "";
|
||||
const soloPriceId = import.meta.env.VITE_STRIPE_SOLO_PRICE_ID || "";
|
||||
|
||||
const priceId =
|
||||
allowedInfiniteUsers.includes(user.email!) && infinitePriceId
|
||||
? infinitePriceId
|
||||
: teamPriceId || annualPriceId || soloPriceId;
|
||||
const requiredPlan = organizationData?.required_plan ?? "solo";
|
||||
const requiredTeamQuantity = organizationData?.required_team_quantity ?? 1;
|
||||
|
||||
const getStatusBadge = () => {
|
||||
// Annual plan badge
|
||||
if (isAnnual) {
|
||||
if (isFounder) {
|
||||
return (
|
||||
<Badge className="gap-1.5 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Annual
|
||||
Founder
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -72,7 +56,7 @@ export function SubscriptionCard() {
|
|||
return (
|
||||
<Badge variant="secondary" className="gap-1.5">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Gratuit
|
||||
Sans abonnement
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -119,11 +103,11 @@ export function SubscriptionCard() {
|
|||
{getStatusBadge()}
|
||||
</div>
|
||||
<CardDescription>
|
||||
{isAnnual
|
||||
? "Vous disposez du plan Annual avec limites illimitées"
|
||||
{isFounder
|
||||
? "Vous disposez du plan Founder (annuel) avec limites illimitées"
|
||||
: isPaying
|
||||
? "Gérez votre abonnement et votre facturation"
|
||||
: "Passez au plan Team ou Annual pour travailler en équipe"}
|
||||
: "Choisissez Solo, Teams ou Founder"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
|
@ -133,76 +117,96 @@ export function SubscriptionCard() {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Annual Plan */}
|
||||
{isAnnual && (
|
||||
{isFounder && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
<p className="text-sm font-medium text-purple-900 dark:text-purple-100">
|
||||
Plan Annual
|
||||
Plan Founder (annuel)
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-purple-700 dark:text-purple-300">
|
||||
Utilisateurs et tablos illimités
|
||||
Utilisateurs et tablos illimités.
|
||||
</p>
|
||||
<div className="pt-2 border-t border-purple-200 dark:border-purple-800">
|
||||
<p className="text-xs text-purple-600 dark:text-purple-400">
|
||||
Votre organisation bénéficie des capacités maximales.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSolo && (
|
||||
<div className="space-y-4">
|
||||
{!isFounder && (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-gradient-to-br from-purple-50 to-blue-50 dark:from-purple-950/20 dark:to-blue-950/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-purple-900 dark:text-purple-100">
|
||||
Plan Solo
|
||||
</p>
|
||||
<p className="text-xs text-purple-700 dark:text-purple-300">
|
||||
1 utilisateur et 10 tablos maximum pour votre organisation.
|
||||
<p className="text-sm font-medium text-purple-900 dark:text-purple-100">
|
||||
Plan recommandé: {requiredPlan === "team" ? "Teams" : "Solo"}
|
||||
</p>
|
||||
<p className="text-xs text-purple-700 dark:text-purple-300 mt-1">
|
||||
{requiredPlan === "team"
|
||||
? `Votre organisation nécessite ${requiredTeamQuantity} siège${requiredTeamQuantity > 1 ? "s" : ""} Teams.`
|
||||
: "Votre organisation peut continuer avec Solo (1 utilisateur, 10 tablos actifs)."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isBillingOwner ? (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
createCheckout({
|
||||
plan: requiredPlan === "team" ? "team" : "solo",
|
||||
successUrl: `${window.location.origin}/settings?success=true`,
|
||||
cancelUrl: `${window.location.origin}/settings?canceled=true`,
|
||||
})
|
||||
}
|
||||
disabled={checkoutPending}
|
||||
className="w-full gap-2 bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
|
||||
>
|
||||
{checkoutPending ? (
|
||||
<>
|
||||
<Loader2Icon className="w-4 h-4 animate-spin" />
|
||||
Chargement...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Passer au plan {requiredPlan === "team" ? "Teams" : "Solo"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
createCheckout({
|
||||
plan: "founder",
|
||||
successUrl: `${window.location.origin}/settings?success=true`,
|
||||
cancelUrl: `${window.location.origin}/settings?canceled=true`,
|
||||
})
|
||||
}
|
||||
disabled={checkoutPending}
|
||||
>
|
||||
Devenir Founder (99€/an)
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 p-3 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-amber-600 dark:text-amber-400 shrink-0" />
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
Seul le propriétaire de facturation de l'organisation peut modifier
|
||||
l'abonnement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
createCheckout({
|
||||
priceId: priceId,
|
||||
successUrl: `${window.location.origin}/settings?success=true`,
|
||||
cancelUrl: `${window.location.origin}/settings?canceled=true`,
|
||||
})
|
||||
}
|
||||
disabled={checkoutPending || !priceId}
|
||||
className="w-full gap-2 bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
|
||||
>
|
||||
{checkoutPending ? (
|
||||
<>
|
||||
<Loader2Icon className="w-4 h-4 animate-spin" />
|
||||
Chargement...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Passer au plan Team
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Standard Tier - Active */}
|
||||
{isPaying && subscription && !subscription.cancel_at_period_end && (
|
||||
{isPaying && subscription && !subscription.cancel_at_period_end && isBillingOwner && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-900 dark:text-green-100">
|
||||
Plan {isAnnual ? "Annual" : "Team"}
|
||||
Plan {isFounder ? "Founder" : "Teams"}
|
||||
</p>
|
||||
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
|
||||
Collaboration d'organisation activée
|
||||
|
|
@ -254,8 +258,7 @@ export function SubscriptionCard() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Standard Tier - Canceling */}
|
||||
{isPaying && subscription?.cancel_at_period_end && (
|
||||
{isPaying && subscription?.cancel_at_period_end && isBillingOwner && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gradient-to-br from-orange-50 to-red-50 dark:from-orange-950/20 dark:to-red-950/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
|
|
@ -266,7 +269,7 @@ export function SubscriptionCard() {
|
|||
Abonnement en cours d'annulation
|
||||
</p>
|
||||
<p className="text-xs text-orange-700 dark:text-orange-300 mt-1">
|
||||
Votre abonnement {isAnnual ? "Annual" : "Team"} sera annulé le{" "}
|
||||
Votre abonnement {isFounder ? "Founder" : "Teams"} sera annulé le{" "}
|
||||
{subscription.current_period_end &&
|
||||
new Date(subscription.current_period_end * 1000).toLocaleDateString(
|
||||
"fr-FR",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
import { Calendar, Clock, Plus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEventsByTablo } from "../hooks/events";
|
||||
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
|
||||
import { TabloHeaderActions } from "./TabloHeaderActions";
|
||||
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TabloEventsSectionProps {
|
||||
tablo: UserTablo;
|
||||
|
|
@ -65,7 +65,10 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
|
|||
{t("tablo:events.description")}
|
||||
</TypographyMuted>
|
||||
{!isReadOnly && (
|
||||
<Button onClick={handleCreateEvent} className="flex items-center gap-2 mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white">
|
||||
<Button
|
||||
onClick={handleCreateEvent}
|
||||
className="flex items-center gap-2 mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t("tablo:events.createEvent")}
|
||||
</Button>
|
||||
|
|
@ -172,7 +175,10 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
|
|||
Aucun événement à venir pour ce tablo
|
||||
</p>
|
||||
{!isReadOnly && (
|
||||
<Button onClick={handleCreateEvent} className="mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white">
|
||||
<Button
|
||||
onClick={handleCreateEvent}
|
||||
className="mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Créer le premier événement
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -455,7 +455,11 @@ const FolderSection = ({
|
|||
|
||||
export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => {
|
||||
const currentUser = useUser();
|
||||
const { data: fileData, isLoading: filesLoading, error: filesError } = useTabloFileNames(tablo.id);
|
||||
const {
|
||||
data: fileData,
|
||||
isLoading: filesLoading,
|
||||
error: filesError,
|
||||
} = useTabloFileNames(tablo.id);
|
||||
const {
|
||||
data: foldersData,
|
||||
isLoading: foldersLoading,
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@ import { Input } from "@xtablo/ui/components/input";
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@xtablo/ui/components/popover";
|
||||
import { Loader2, Settings, Share2, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ClickOutside } from "./ClickOutside";
|
||||
import { ImageColorPicker } from "./ImageColorPicker";
|
||||
import { useInviteUser } from "../hooks/invite";
|
||||
import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites";
|
||||
import { useTabloMembers, useUpdateTablo } from "../hooks/tablos";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
import { ClickOutside } from "./ClickOutside";
|
||||
import { ImageColorPicker } from "./ImageColorPicker";
|
||||
|
||||
interface TabloHeaderActionsProps {
|
||||
tablo: UserTablo;
|
||||
|
|
@ -303,7 +303,8 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
|
|||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{filteredMembers.map((member, index) => {
|
||||
const avatarUrl =
|
||||
member.avatar_url ?? (member.id === currentUser.id ? currentUser.avatar_url : null);
|
||||
member.avatar_url ??
|
||||
(member.id === currentUser.id ? currentUser.avatar_url : null);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@ import { UserTablo } from "@xtablo/shared/types/tablos.types";
|
|||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { Users } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useCancelTabloInvite,
|
||||
usePendingTabloInvitesByTablo,
|
||||
} from "src/hooks/tablo_invites";
|
||||
import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "src/hooks/tablo_invites";
|
||||
import { useInviteUser } from "../hooks/invite";
|
||||
import { useTabloMembers } from "../hooks/tablos";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
|
|
@ -19,8 +16,7 @@ export const TabloMembersSection = ({ tablo, isAdmin }: TabloMembersSectionProps
|
|||
const currentUser = useUser();
|
||||
const { data: members } = useTabloMembers(tablo.id);
|
||||
const { data: pendingInvites } = usePendingTabloInvitesByTablo(tablo.id);
|
||||
const { mutate: cancelInvite, isPending: isCancellingInvite } =
|
||||
useCancelTabloInvite();
|
||||
const { mutate: cancelInvite, isPending: isCancellingInvite } = useCancelTabloInvite();
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
|
||||
|
|
@ -122,9 +118,7 @@ export const TabloMembersSection = ({ tablo, isAdmin }: TabloMembersSectionProps
|
|||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
cancelInvite({ tabloId: tablo.id, inviteId: invite.id })
|
||||
}
|
||||
onClick={() => cancelInvite({ tabloId: tablo.id, inviteId: invite.id })}
|
||||
disabled={isCancellingInvite}
|
||||
>
|
||||
Retirer
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { pluralize, toast } from "@xtablo/shared";
|
||||
import { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { KanbanColumn, KanbanTask, KanbanTaskInsert, TaskStatus } from "@xtablo/shared-types";
|
||||
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
import { AlertTriangle, ListChecks } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTabloMembers } from "../hooks/tablos";
|
||||
|
|
@ -13,7 +14,6 @@ import {
|
|||
import { KanbanBoard } from "./kanban/KanbanBoard";
|
||||
import { TaskModal } from "./kanban/TaskModal";
|
||||
import { TabloHeaderActions } from "./TabloHeaderActions";
|
||||
import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
|
||||
interface TabloTasksSectionProps {
|
||||
tablo: UserTablo;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Database } from "@xtablo/shared-types";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
|
||||
import { Badge } from "@xtablo/ui/components/badge";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
|
|
@ -8,15 +9,7 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@xtablo/ui/components/dropdown-menu";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@xtablo/ui/components/avatar";
|
||||
import {
|
||||
TypographyMuted,
|
||||
TypographySmall,
|
||||
} from "@xtablo/ui/components/typography";
|
||||
import { TypographyMuted, TypographySmall } from "@xtablo/ui/components/typography";
|
||||
import {
|
||||
BellIcon,
|
||||
CalendarCheckIcon,
|
||||
|
|
@ -36,10 +29,7 @@ import { useEffect } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useLogout } from "../hooks/auth";
|
||||
import {
|
||||
useNotifications,
|
||||
useNotificationsSubscription,
|
||||
} from "../hooks/notifications";
|
||||
import { useNotifications, useNotificationsSubscription } from "../hooks/notifications";
|
||||
import {
|
||||
useAcceptTabloInvite,
|
||||
useCancelTabloInvite,
|
||||
|
|
@ -171,8 +161,7 @@ function NotificationItem({
|
|||
|
||||
function NotificationDropdown() {
|
||||
const { t } = useTranslation("navigation");
|
||||
const { notifications, unreadCount, isLoading, markAsRead, markAllAsRead } =
|
||||
useNotifications();
|
||||
const { notifications, unreadCount, isLoading, markAsRead, markAllAsRead } = useNotifications();
|
||||
const { setupSubscription } = useNotificationsSubscription();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -209,9 +198,7 @@ function NotificationDropdown() {
|
|||
{t("notifications.title", "Notifications")}
|
||||
</TypographySmall>
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="bg-red-500 text-white text-xs">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
<Badge className="bg-red-500 text-white text-xs">{unreadCount}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
|
|
@ -250,10 +237,7 @@ function NotificationDropdown() {
|
|||
<div className="divide-y divide-gray-100">
|
||||
{notifications.map((notification) => (
|
||||
<div key={notification.id} className="group">
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
onMarkAsRead={markAsRead}
|
||||
/>
|
||||
<NotificationItem notification={notification} onMarkAsRead={markAsRead} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -298,12 +282,16 @@ function TabloInvitesDropdown() {
|
|||
sideOffset={8}
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<TypographySmall className="font-semibold text-gray-900">{t("invites.title")}</TypographySmall>
|
||||
<TypographySmall className="font-semibold text-gray-900">
|
||||
{t("invites.title")}
|
||||
</TypographySmall>
|
||||
</div>
|
||||
<div className="max-h-[340px] overflow-y-auto divide-y divide-gray-100">
|
||||
{invites.map((invite) => (
|
||||
<div key={invite.id} className="px-4 py-3">
|
||||
<TypographySmall className="font-medium text-gray-900">{invite.tablo_name}</TypographySmall>
|
||||
<TypographySmall className="font-medium text-gray-900">
|
||||
{invite.tablo_name}
|
||||
</TypographySmall>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -367,10 +355,7 @@ function ProfileDropdown() {
|
|||
>
|
||||
<div className="flex gap-2 p-2">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage
|
||||
src={user.avatar_url ?? undefined}
|
||||
alt={user.name ?? "User avatar"}
|
||||
/>
|
||||
<AvatarImage src={user.avatar_url ?? undefined} alt={user.name ?? "User avatar"} />
|
||||
<AvatarFallback className="bg-[#B8EAFF] text-gray-800 font-medium">
|
||||
{user.name?.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
|
|
|
|||
59
apps/main/src/components/TrialUpsellModal.test.ts
Normal file
59
apps/main/src/components/TrialUpsellModal.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { shouldShowTrialUpsell } from "./TrialUpsellModal";
|
||||
|
||||
describe("shouldShowTrialUpsell", () => {
|
||||
it("does not show when trial has more than 7 days remaining", () => {
|
||||
expect(
|
||||
shouldShowTrialUpsell({
|
||||
isTrialExpired: false,
|
||||
activeSubscriptionPlan: null,
|
||||
isTemporaryUser: false,
|
||||
daysRemaining: 14,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows when trial is within reminder window and user has no subscription", () => {
|
||||
expect(
|
||||
shouldShowTrialUpsell({
|
||||
isTrialExpired: false,
|
||||
activeSubscriptionPlan: null,
|
||||
isTemporaryUser: false,
|
||||
daysRemaining: 3,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not show when trial is expired", () => {
|
||||
expect(
|
||||
shouldShowTrialUpsell({
|
||||
isTrialExpired: true,
|
||||
activeSubscriptionPlan: null,
|
||||
isTemporaryUser: false,
|
||||
daysRemaining: null,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not show when organization already has a paid subscription", () => {
|
||||
expect(
|
||||
shouldShowTrialUpsell({
|
||||
isTrialExpired: false,
|
||||
activeSubscriptionPlan: "team",
|
||||
isTemporaryUser: false,
|
||||
daysRemaining: 2,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not show for temporary users", () => {
|
||||
expect(
|
||||
shouldShowTrialUpsell({
|
||||
isTrialExpired: false,
|
||||
activeSubscriptionPlan: null,
|
||||
isTemporaryUser: true,
|
||||
daysRemaining: 2,
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -9,26 +9,55 @@ import {
|
|||
} from "@xtablo/ui/components/dialog";
|
||||
import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useOrganization } from "../hooks/organization";
|
||||
import { useCreateCheckoutSession, useTrialExpiration } from "../hooks/stripe";
|
||||
import { useMaybeUser } from "../providers/UserStoreProvider";
|
||||
|
||||
const MODAL_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
|
||||
const LAST_SHOWN_KEY = "trial-upsell-modal-last-shown";
|
||||
const TRIAL_UPSELL_REMINDER_DAYS = 7;
|
||||
|
||||
export const shouldShowTrialUpsell = (input: {
|
||||
isTrialExpired: boolean;
|
||||
activeSubscriptionPlan: "solo" | "team" | "annual" | null;
|
||||
isTemporaryUser: boolean;
|
||||
daysRemaining: number | null;
|
||||
}) => {
|
||||
const { isTrialExpired, activeSubscriptionPlan, isTemporaryUser, daysRemaining } = input;
|
||||
|
||||
if (isTrialExpired || activeSubscriptionPlan || isTemporaryUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (daysRemaining === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return daysRemaining > 0 && daysRemaining <= TRIAL_UPSELL_REMINDER_DAYS;
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-opening modal that shows every 15 minutes to remind users about trial expiration
|
||||
* Only shows if daysRemaining is not null (user is in trial period)
|
||||
* Auto-opening modal that reminds users near trial expiration.
|
||||
*/
|
||||
export function TrialUpsellModal() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { daysRemaining } = useTrialExpiration();
|
||||
const { data: organizationData } = useOrganization();
|
||||
const user = useMaybeUser();
|
||||
const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
|
||||
|
||||
const STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || "";
|
||||
const shouldShowModal = Boolean(
|
||||
organizationData &&
|
||||
shouldShowTrialUpsell({
|
||||
isTrialExpired: organizationData.is_trial_expired,
|
||||
activeSubscriptionPlan: organizationData.active_subscription_plan,
|
||||
isTemporaryUser: Boolean(user?.is_temporary),
|
||||
daysRemaining,
|
||||
})
|
||||
);
|
||||
|
||||
// Only show modal for users in trial period (not beta, not paid, and daysRemaining exists)
|
||||
const shouldShowModal = daysRemaining !== null && user?.plan === "none" && !user?.is_temporary;
|
||||
const requiredPlan = organizationData?.required_plan ?? "solo";
|
||||
const checkoutPlan = requiredPlan === "team" ? "team" : "solo";
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowModal) return;
|
||||
|
|
@ -37,22 +66,19 @@ export function TrialUpsellModal() {
|
|||
const lastShown = localStorage.getItem(LAST_SHOWN_KEY);
|
||||
const now = Date.now();
|
||||
|
||||
if (!lastShown || now - parseInt(lastShown) >= MODAL_INTERVAL_MS) {
|
||||
if (!lastShown || now - Number.parseInt(lastShown, 10) >= MODAL_INTERVAL_MS) {
|
||||
setIsOpen(true);
|
||||
localStorage.setItem(LAST_SHOWN_KEY, now.toString());
|
||||
}
|
||||
};
|
||||
|
||||
// Check immediately on mount
|
||||
checkAndShowModal();
|
||||
|
||||
// Set up interval to check every 15 minutes
|
||||
const interval = setInterval(checkAndShowModal, MODAL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [shouldShowModal]);
|
||||
|
||||
if (!shouldShowModal) {
|
||||
if (!shouldShowModal || daysRemaining === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -75,22 +101,35 @@ export function TrialUpsellModal() {
|
|||
: `${daysRemaining} jour${daysRemaining > 1 ? "s" : ""} restants dans votre période d'essai`}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
{isUrgent
|
||||
? "Ne perdez pas l'accès à vos projets ! Passez au plan Starter pour continuer."
|
||||
: "Profitez de toutes les fonctionnalités sans limite en passant au plan Starter."}
|
||||
{requiredPlan === "team"
|
||||
? "Après l'essai, votre organisation passera sur Teams (facturation par siège)."
|
||||
: "Après l'essai, vous pourrez continuer avec Solo."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-4">
|
||||
<p className="text-sm font-medium">Avec Starter, vous bénéficiez de :</p>
|
||||
<p className="text-sm font-medium">
|
||||
{requiredPlan === "team"
|
||||
? "Avec Teams, vous bénéficiez de :"
|
||||
: "Avec Solo, vous bénéficiez de :"}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
"Tablos et projets illimités",
|
||||
"Planification avancée",
|
||||
"Chat en temps réel",
|
||||
"Stockage de fichiers",
|
||||
"Support prioritaire",
|
||||
].map((feature) => (
|
||||
{(requiredPlan === "team"
|
||||
? [
|
||||
"Utilisateurs multiples (par siège)",
|
||||
"Tablos illimités",
|
||||
"Planification avancée",
|
||||
"Chat en temps réel",
|
||||
"Support prioritaire",
|
||||
]
|
||||
: [
|
||||
"1 utilisateur",
|
||||
"10 tablos actifs",
|
||||
"Planification avancée",
|
||||
"Chat en temps réel",
|
||||
"Support prioritaire",
|
||||
]
|
||||
).map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
|
||||
<span>{feature}</span>
|
||||
|
|
@ -106,13 +145,13 @@ export function TrialUpsellModal() {
|
|||
<Button
|
||||
onClick={() => {
|
||||
createCheckout({
|
||||
priceId: STANDARD_MONTHLY_PRICE_ID,
|
||||
plan: checkoutPlan,
|
||||
successUrl: `${window.location.origin}?upgraded=true`,
|
||||
cancelUrl: `${window.location.origin}?canceled=true`,
|
||||
});
|
||||
setIsOpen(false);
|
||||
}}
|
||||
disabled={checkoutPending || !STANDARD_MONTHLY_PRICE_ID}
|
||||
disabled={checkoutPending}
|
||||
className="sm:flex-1 gap-2 bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
|
||||
>
|
||||
{checkoutPending ? (
|
||||
|
|
@ -123,7 +162,7 @@ export function TrialUpsellModal() {
|
|||
) : (
|
||||
<>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
Passer au plan Starter
|
||||
Passer au plan {requiredPlan === "team" ? "Teams" : "Solo"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { Text } from "@xtablo/ui/components/typography";
|
|||
import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react";
|
||||
import { useUpgradeBlock } from "../contexts/UpgradeBlockContext";
|
||||
import { useLogout } from "../hooks/auth";
|
||||
import { useOrganization } from "../hooks/organization";
|
||||
import { useCreateCheckoutSession } from "../hooks/stripe";
|
||||
|
||||
/**
|
||||
|
|
@ -20,13 +21,22 @@ export function UpgradePanel() {
|
|||
const { isBlocked } = useUpgradeBlock();
|
||||
const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
|
||||
const { mutate: signOut } = useLogout();
|
||||
|
||||
const STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || "";
|
||||
const { data: organizationData } = useOrganization();
|
||||
|
||||
if (!isBlocked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requiredPlan = organizationData?.required_plan ?? "solo";
|
||||
const isBillingOwner = organizationData?.is_billing_owner ?? false;
|
||||
const requiredTeamQuantity = organizationData?.required_team_quantity ?? 1;
|
||||
const checkoutPlan = requiredPlan === "team" ? "team" : "solo";
|
||||
|
||||
const checkoutLabel =
|
||||
checkoutPlan === "team"
|
||||
? `Passer au plan Teams (${requiredTeamQuantity} siège${requiredTeamQuantity > 1 ? "s" : ""})`
|
||||
: "Passer au plan Solo";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/95 backdrop-blur-sm">
|
||||
<div className="w-full max-w-lg mx-4">
|
||||
|
|
@ -37,22 +47,34 @@ export function UpgradePanel() {
|
|||
</div>
|
||||
<CardTitle className="text-2xl">Votre période d'essai est terminée</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Pour continuer à utiliser XTablo, passez au plan Starter et débloquez toutes les
|
||||
fonctionnalités
|
||||
Pour continuer à utiliser XTablo, activez votre abonnement{" "}
|
||||
{requiredPlan === "team" ? "Teams" : "Solo"}.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Features list */}
|
||||
<div className="space-y-3">
|
||||
<Text className="text-sm font-medium">Ce que vous obtenez avec Starter :</Text>
|
||||
<Text className="text-sm font-medium">
|
||||
{requiredPlan === "team"
|
||||
? "Ce que vous obtenez avec Teams :"
|
||||
: "Ce que vous obtenez avec Solo :"}
|
||||
</Text>
|
||||
<ul className="space-y-2">
|
||||
{[
|
||||
"Tablos illimités",
|
||||
"Planification avancée",
|
||||
"Chat en temps réel",
|
||||
"Stockage de fichiers",
|
||||
"Support prioritaire",
|
||||
].map((feature) => (
|
||||
{(requiredPlan === "team"
|
||||
? [
|
||||
"Utilisateurs multiples (facturation par siège)",
|
||||
"Tablos illimités",
|
||||
"Chat en temps réel",
|
||||
"Stockage de fichiers",
|
||||
"Support prioritaire",
|
||||
]
|
||||
: [
|
||||
"1 utilisateur",
|
||||
"10 tablos actifs",
|
||||
"Chat en temps réel",
|
||||
"Stockage de fichiers",
|
||||
"Support prioritaire",
|
||||
]
|
||||
).map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
|
||||
<span>{feature}</span>
|
||||
|
|
@ -61,17 +83,16 @@ export function UpgradePanel() {
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Upgrade button */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={() =>
|
||||
createCheckout({
|
||||
priceId: STANDARD_MONTHLY_PRICE_ID,
|
||||
plan: checkoutPlan,
|
||||
successUrl: `${window.location.origin}?upgraded=true`,
|
||||
cancelUrl: `${window.location.origin}?canceled=true`,
|
||||
})
|
||||
}
|
||||
disabled={checkoutPending || !STANDARD_MONTHLY_PRICE_ID}
|
||||
disabled={checkoutPending || !isBillingOwner}
|
||||
className="w-full gap-2 bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600 h-12 text-base"
|
||||
>
|
||||
{checkoutPending ? (
|
||||
|
|
@ -82,22 +103,37 @@ export function UpgradePanel() {
|
|||
) : (
|
||||
<>
|
||||
<CreditCard className="w-5 h-5" />
|
||||
Passer au plan Starter
|
||||
{checkoutLabel}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{!STANDARD_MONTHLY_PRICE_ID && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
createCheckout({
|
||||
plan: "founder",
|
||||
successUrl: `${window.location.origin}?upgraded=true`,
|
||||
cancelUrl: `${window.location.origin}?canceled=true`,
|
||||
})
|
||||
}
|
||||
disabled={checkoutPending || !isBillingOwner}
|
||||
className="w-full"
|
||||
>
|
||||
Devenir Founder (99€/an)
|
||||
</Button>
|
||||
|
||||
{!isBillingOwner && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 shrink-0" />
|
||||
<Text className="text-xs text-red-600 dark:text-red-400">
|
||||
Configuration Stripe requise. Veuillez contacter le support.
|
||||
Seul le propriétaire de facturation de l'organisation peut modifier
|
||||
l'abonnement.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help text */}
|
||||
<div className="text-center pt-2 border-t">
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
Des questions ?{" "}
|
||||
|
|
|
|||
|
|
@ -42,10 +42,7 @@ interface GanttChartProps {
|
|||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_STYLES: Record<
|
||||
string,
|
||||
{ bg: string; border: string; dot: string; label: string }
|
||||
> = {
|
||||
const STATUS_STYLES: Record<string, { bg: string; border: string; dot: string; label: string }> = {
|
||||
todo: {
|
||||
bg: "bg-[#EFF8FF]",
|
||||
border: "border-l-[#3B82F6]",
|
||||
|
|
@ -79,12 +76,7 @@ const STATUS_TEXT_COLORS: Record<string, string> = {
|
|||
done: "text-[#16B364]",
|
||||
};
|
||||
|
||||
const ROADMAP_TASK_STATUSES: TaskStatus[] = [
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
];
|
||||
const ROADMAP_TASK_STATUSES: TaskStatus[] = ["todo", "in_progress", "in_review", "done"];
|
||||
|
||||
function getTabloIcon(color: string | null | undefined) {
|
||||
switch (color) {
|
||||
|
|
@ -137,9 +129,7 @@ function isSameDay(a: Date, b: Date): boolean {
|
|||
}
|
||||
|
||||
function formatShortDay(date: Date): string {
|
||||
return date
|
||||
.toLocaleDateString("fr-FR", { weekday: "short" })
|
||||
.replace(".", "");
|
||||
return date.toLocaleDateString("fr-FR", { weekday: "short" }).replace(".", "");
|
||||
}
|
||||
|
||||
function formatDateRange(start: Date, end: Date): string {
|
||||
|
|
@ -161,12 +151,7 @@ const CARD_TOP_OFFSET = 20;
|
|||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function GanttChart({
|
||||
tasks,
|
||||
isLoading,
|
||||
onDateClick,
|
||||
onTaskStatusChange,
|
||||
}: GanttChartProps) {
|
||||
export function GanttChart({ tasks, isLoading, onDateClick, onTaskStatusChange }: GanttChartProps) {
|
||||
const [weekOffset, setWeekOffset] = useState(0);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("weekly");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -198,18 +183,12 @@ export function GanttChart({
|
|||
return d;
|
||||
}, []);
|
||||
|
||||
const periodStart = useMemo(
|
||||
() => addDays(getMonday(today), weekOffset * 7),
|
||||
[today, weekOffset],
|
||||
);
|
||||
const periodEnd = useMemo(
|
||||
() => addDays(periodStart, numDays - 1),
|
||||
[periodStart, numDays],
|
||||
);
|
||||
const periodStart = useMemo(() => addDays(getMonday(today), weekOffset * 7), [today, weekOffset]);
|
||||
const periodEnd = useMemo(() => addDays(periodStart, numDays - 1), [periodStart, numDays]);
|
||||
|
||||
const days = useMemo(
|
||||
() => Array.from({ length: numDays }, (_, i) => addDays(periodStart, i)),
|
||||
[periodStart, numDays],
|
||||
[periodStart, numDays]
|
||||
);
|
||||
|
||||
// Filter tasks with due_date in this period
|
||||
|
|
@ -260,10 +239,7 @@ export function GanttChart({
|
|||
|
||||
// Compute chart height
|
||||
const maxRow = positionedTasks.reduce((max, pt) => Math.max(max, pt.row), 0);
|
||||
const chartHeight = Math.max(
|
||||
400,
|
||||
(maxRow + 1) * (cardHeight + CARD_GAP) + CARD_TOP_OFFSET + 20,
|
||||
);
|
||||
const chartHeight = Math.max(400, (maxRow + 1) * (cardHeight + CARD_GAP) + CARD_TOP_OFFSET + 20);
|
||||
|
||||
// Today indicator position
|
||||
const todayIndex = days.findIndex((d) => isSameDay(d, today));
|
||||
|
|
@ -354,7 +330,7 @@ export function GanttChart({
|
|||
<span
|
||||
className={twMerge(
|
||||
"text-sm font-medium",
|
||||
isToday ? "text-primary" : "text-muted-foreground",
|
||||
isToday ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{formatShortDay(day)} {day.getDate()}
|
||||
|
|
@ -384,10 +360,7 @@ export function GanttChart({
|
|||
{days.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={twMerge(
|
||||
"border-border",
|
||||
i < numDays - 1 ? "border-r" : "",
|
||||
)}
|
||||
className={twMerge("border-border", i < numDays - 1 ? "border-r" : "")}
|
||||
style={{ width: colWidth }}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -412,27 +385,19 @@ export function GanttChart({
|
|||
|
||||
{/* Task cards */}
|
||||
{positionedTasks.map((pt) => {
|
||||
const status =
|
||||
STATUS_STYLES[pt.task.status ?? "todo"] ?? STATUS_STYLES.todo;
|
||||
const status = STATUS_STYLES[pt.task.status ?? "todo"] ?? STATUS_STYLES.todo;
|
||||
const textColor =
|
||||
STATUS_TEXT_COLORS[pt.task.status ?? "todo"] ??
|
||||
STATUS_TEXT_COLORS.todo;
|
||||
const TabloIcon = pt.task.tablos
|
||||
? getTabloIcon(pt.task.tablos.color)
|
||||
: null;
|
||||
STATUS_TEXT_COLORS[pt.task.status ?? "todo"] ?? STATUS_TEXT_COLORS.todo;
|
||||
const TabloIcon = pt.task.tablos ? getTabloIcon(pt.task.tablos.color) : null;
|
||||
|
||||
const isCompact = viewMode === "biweekly";
|
||||
const taskCardContent = (
|
||||
<>
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center gap-1.5 bg-white w-fit px-2 py-0.5 rounded-full shadow-sm">
|
||||
<span
|
||||
className={twMerge("w-2 h-2 rounded-full", status.dot)}
|
||||
/>
|
||||
<span className={twMerge("w-2 h-2 rounded-full", status.dot)} />
|
||||
{!isCompact && (
|
||||
<span
|
||||
className={twMerge("text-xs font-medium", textColor)}
|
||||
>
|
||||
<span className={twMerge("text-xs font-medium", textColor)}>
|
||||
{status.label}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -442,7 +407,7 @@ export function GanttChart({
|
|||
<h3
|
||||
className={twMerge(
|
||||
"font-semibold text-gray-900 leading-tight line-clamp-1",
|
||||
isCompact ? "mt-1 text-xs" : "mt-2 text-sm",
|
||||
isCompact ? "mt-1 text-xs" : "mt-2 text-sm"
|
||||
)}
|
||||
>
|
||||
{pt.task.title}
|
||||
|
|
@ -463,7 +428,7 @@ export function GanttChart({
|
|||
<div
|
||||
className={twMerge(
|
||||
"w-5 h-5 rounded-md flex items-center justify-center",
|
||||
pt.task.tablos.color || "bg-gray-400",
|
||||
pt.task.tablos.color || "bg-gray-400"
|
||||
)}
|
||||
>
|
||||
<TabloIcon className="w-3 h-3 text-white" />
|
||||
|
|
@ -484,7 +449,7 @@ export function GanttChart({
|
|||
"absolute z-30 rounded-lg border-l-4 shadow-sm transition-all hover:shadow-md overflow-hidden text-left cursor-default",
|
||||
isCompact ? "p-2" : "p-3",
|
||||
status.bg,
|
||||
status.border,
|
||||
status.border
|
||||
)}
|
||||
style={{
|
||||
left: pt.left,
|
||||
|
|
@ -509,7 +474,7 @@ export function GanttChart({
|
|||
isCompact ? "p-2" : "p-3",
|
||||
status.bg,
|
||||
status.border,
|
||||
"cursor-pointer",
|
||||
"cursor-pointer"
|
||||
)}
|
||||
style={{
|
||||
left: pt.left,
|
||||
|
|
@ -528,15 +493,13 @@ export function GanttChart({
|
|||
<DropdownMenuItem
|
||||
key={nextStatus}
|
||||
disabled={isCurrent}
|
||||
onClick={() =>
|
||||
onTaskStatusChange(pt.task.id, nextStatus)
|
||||
}
|
||||
onClick={() => onTaskStatusChange(pt.task.id, nextStatus)}
|
||||
className="gap-2"
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
"w-2 h-2 rounded-full",
|
||||
STATUS_STYLES[nextStatus]?.dot ?? "bg-gray-400",
|
||||
STATUS_STYLES[nextStatus]?.dot ?? "bg-gray-400"
|
||||
)}
|
||||
/>
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -16,12 +16,7 @@ import { TypographyH2 } from "@xtablo/ui/components/typography";
|
|||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTabloMembers } from "../../hooks/tablos";
|
||||
import {
|
||||
useCreateTask,
|
||||
useTabloEtapes,
|
||||
useTask,
|
||||
useUpdateTask,
|
||||
} from "../../hooks/tasks";
|
||||
import { useCreateTask, useTabloEtapes, useTask, useUpdateTask } from "../../hooks/tasks";
|
||||
import type { TabloMember } from "./types";
|
||||
|
||||
interface TaskModalProps {
|
||||
|
|
@ -56,26 +51,20 @@ export const TaskModal = ({
|
|||
const [etapeId, setEtapeId] = useState<string>("none");
|
||||
const [dueDate, setDueDate] = useState<Date | undefined>(undefined);
|
||||
const [selectedTabloId, setSelectedTabloId] = useState<string>(
|
||||
initialTabloId || tablos?.[0]?.id || "",
|
||||
initialTabloId || tablos?.[0]?.id || ""
|
||||
);
|
||||
|
||||
// Determine which tablo to use for fetching data
|
||||
const tabloIdForFetch = allowTabloSelection
|
||||
? selectedTabloId
|
||||
: initialTabloId || "";
|
||||
const tabloIdForFetch = allowTabloSelection ? selectedTabloId : initialTabloId || "";
|
||||
|
||||
// Fetch members and etapes for selected tablo if not provided
|
||||
const { data: fetchedMembers = [] } = useTabloMembers(tabloIdForFetch || "");
|
||||
const { data: fetchedEtapes = [] } = useTabloEtapes(
|
||||
tabloIdForFetch || undefined,
|
||||
);
|
||||
const { data: fetchedEtapes = [] } = useTabloEtapes(tabloIdForFetch || undefined);
|
||||
|
||||
// Use provided or fetched data
|
||||
const members = providedMembers || fetchedMembers;
|
||||
const etapes = providedEtapes || fetchedEtapes;
|
||||
const currentTabloId = allowTabloSelection
|
||||
? selectedTabloId
|
||||
: initialTabloId || "";
|
||||
const currentTabloId = allowTabloSelection ? selectedTabloId : initialTabloId || "";
|
||||
|
||||
useEffect(() => {
|
||||
if (task) {
|
||||
|
|
@ -174,10 +163,7 @@ export const TaskModal = ({
|
|||
{allowTabloSelection && !taskId && tablos && tablos.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tablo">Tablo *</Label>
|
||||
<Select
|
||||
value={selectedTabloId}
|
||||
onValueChange={setSelectedTabloId}
|
||||
>
|
||||
<Select value={selectedTabloId} onValueChange={setSelectedTabloId}>
|
||||
<SelectTrigger id="tablo" className="w-full">
|
||||
<SelectValue placeholder="Sélectionner un tablo" />
|
||||
</SelectTrigger>
|
||||
|
|
@ -225,11 +211,7 @@ export const TaskModal = ({
|
|||
{/* Due Date */}
|
||||
<div className="space-y-2">
|
||||
<Label>Échéance</Label>
|
||||
<DatePicker
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
placeholder="Choisir une date"
|
||||
/>
|
||||
<DatePicker value={dueDate} onChange={setDueDate} placeholder="Choisir une date" />
|
||||
</div>
|
||||
|
||||
{/* Assignee */}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ import { useState } from "react";
|
|||
import { useNavigate } from "react-router-dom";
|
||||
import { match } from "ts-pattern";
|
||||
import { api } from "../lib/api";
|
||||
import {
|
||||
PENDING_BILLING_CHECKOUT_PLAN_KEY,
|
||||
SIGNUP_BILLING_INTENT_KEY,
|
||||
SignupBillingIntent,
|
||||
} from "../lib/billing";
|
||||
import { supabase } from "../lib/supabase";
|
||||
|
||||
export type User = SupabaseUser & {
|
||||
|
|
@ -26,6 +31,7 @@ interface SignUpData {
|
|||
first_name: string;
|
||||
last_name: string;
|
||||
business_name: string;
|
||||
billing_intent?: SignupBillingIntent;
|
||||
}
|
||||
|
||||
interface LoginData {
|
||||
|
|
@ -57,6 +63,7 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) {
|
|||
first_name: data.first_name,
|
||||
last_name: data.last_name,
|
||||
business_name: data.business_name,
|
||||
billing_intent: data.billing_intent ?? "trial",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -68,18 +75,71 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) {
|
|||
}
|
||||
return response;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
onSuccess: async (data, variables) => {
|
||||
const wantsFounderCheckout = variables.billing_intent === "founder";
|
||||
|
||||
if (wantsFounderCheckout) {
|
||||
localStorage.setItem(SIGNUP_BILLING_INTENT_KEY, "founder");
|
||||
localStorage.setItem(PENDING_BILLING_CHECKOUT_PLAN_KEY, "founder");
|
||||
} else {
|
||||
localStorage.removeItem(SIGNUP_BILLING_INTENT_KEY);
|
||||
localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
}
|
||||
|
||||
// If there's no session, it means email confirmation is required
|
||||
if (!data.user?.email_confirmed_at) {
|
||||
// Store the email for the confirmation page
|
||||
localStorage.setItem("pendingConfirmationEmail", data.user?.email || "");
|
||||
navigate("/confirm-email");
|
||||
} else if (redirectUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (wantsFounderCheckout && data.session?.access_token) {
|
||||
try {
|
||||
const checkoutResponse = await api.post(
|
||||
"/api/v1/stripe/create-checkout-session",
|
||||
{
|
||||
plan: "founder",
|
||||
successUrl: `${window.location.origin}/settings?success=true`,
|
||||
cancelUrl: `${window.location.origin}/settings?canceled=true`,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${data.session.access_token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (checkoutResponse.data?.url) {
|
||||
localStorage.removeItem(SIGNUP_BILLING_INTENT_KEY);
|
||||
localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
window.location.href = checkoutResponse.data.url;
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Founder checkout bootstrap failed after signup:", error);
|
||||
toast.add({
|
||||
title: "Paiement Founder",
|
||||
description:
|
||||
"Votre compte est créé, mais la redirection vers le paiement a échoué. Vous pourrez finaliser le paiement dans l'application.",
|
||||
type: "warning",
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(SIGNUP_BILLING_INTENT_KEY);
|
||||
if (!wantsFounderCheckout) {
|
||||
localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY);
|
||||
}
|
||||
|
||||
if (redirectUrl) {
|
||||
localStorage.removeItem("redirectUrl");
|
||||
navigate(decodeURIComponent(redirectUrl));
|
||||
} else {
|
||||
navigate("/");
|
||||
return;
|
||||
}
|
||||
|
||||
navigate("/");
|
||||
},
|
||||
onError: (error) => {
|
||||
const errMap: Record<string, string> = {};
|
||||
|
|
|
|||
|
|
@ -25,6 +25,30 @@ export interface OrganizationMember {
|
|||
export interface OrganizationResponse {
|
||||
organization: OrganizationSummary;
|
||||
members: OrganizationMember[];
|
||||
invites_sent: OrganizationInvite[];
|
||||
trial_starts_at: string;
|
||||
trial_ends_at: string;
|
||||
is_trial_expired: boolean;
|
||||
required_plan: "solo" | "team";
|
||||
required_team_quantity: number;
|
||||
active_subscription_plan: "solo" | "team" | "annual" | null;
|
||||
active_subscription_quantity: number;
|
||||
is_billing_owner: boolean;
|
||||
}
|
||||
|
||||
export interface OrganizationInvite {
|
||||
id: number;
|
||||
invited_email: string;
|
||||
invited_user_id: string | null;
|
||||
created_at: string;
|
||||
invited_member: {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
avatar_url: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const useOrganization = () => {
|
||||
|
|
@ -92,3 +116,30 @@ export const useInviteOrganizationUser = () => {
|
|||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRemoveOrganizationMember = () => {
|
||||
const api = useAuthedApi();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (memberId: string) => {
|
||||
const { data } = await api.delete(`/api/v1/users/organization/members/${memberId}`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.add({
|
||||
title: "Membre retiré",
|
||||
description: "Le membre a été retiré de votre organisation",
|
||||
type: "success",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["organization"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.add({
|
||||
title: "Erreur",
|
||||
description: error.message || "Impossible de retirer ce membre",
|
||||
type: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
queryClient,
|
||||
|
|
@ -9,22 +8,55 @@ import {
|
|||
} from "@xtablo/shared";
|
||||
import { useMemo } from "react";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import { useMaybeUser, useUser } from "../providers/UserStoreProvider";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
import { useAuthedApi } from "./auth";
|
||||
|
||||
// Initialize Stripe
|
||||
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || "");
|
||||
import { useOrganization } from "./organization";
|
||||
|
||||
export type BillingPlan = "solo" | "team" | "annual";
|
||||
export type CheckoutPlan = "solo" | "team" | "founder";
|
||||
|
||||
export const normalizeBillingPlan = (plan: string | null | undefined): BillingPlan => {
|
||||
if (!plan) return "solo";
|
||||
|
||||
if (plan === "annual" || plan === "beta") return "annual";
|
||||
if (plan === "annual" || plan === "beta" || plan === "founder" || plan === "infinite") {
|
||||
return "annual";
|
||||
}
|
||||
if (plan === "team" || plan === "standard") return "team";
|
||||
return "solo";
|
||||
};
|
||||
|
||||
const hasCompliantPaidPlan = (input: {
|
||||
required_plan: "solo" | "team";
|
||||
required_team_quantity: number;
|
||||
active_subscription_plan: "solo" | "team" | "annual" | null;
|
||||
active_subscription_quantity: number;
|
||||
}) => {
|
||||
const {
|
||||
required_plan,
|
||||
required_team_quantity,
|
||||
active_subscription_plan,
|
||||
active_subscription_quantity,
|
||||
} = input;
|
||||
|
||||
if (!active_subscription_plan) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (active_subscription_plan === "annual") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (required_plan === "solo") {
|
||||
return active_subscription_plan === "solo" || active_subscription_plan === "team";
|
||||
}
|
||||
|
||||
if (active_subscription_plan !== "team") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return active_subscription_quantity >= required_team_quantity;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get user's subscription status from Supabase
|
||||
* Uses RPC function to access stripe data without modifying stripe schema
|
||||
|
|
@ -73,29 +105,52 @@ export function useIsPayingUser() {
|
|||
}
|
||||
|
||||
export const useIsPastTrial = () => {
|
||||
const user = useMaybeUser();
|
||||
const { data: organizationData, isLoading } = useOrganization();
|
||||
|
||||
// Trial model is deprecated in favor of explicit billing plans.
|
||||
// Keep this hook returning false to avoid blocking existing users unexpectedly.
|
||||
const isPastTrial = useMemo(() => {
|
||||
void user;
|
||||
return false;
|
||||
}, [user]);
|
||||
if (!organizationData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return { isPastTrial, isLoading: !user };
|
||||
if (!organizationData.is_trial_expired) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !hasCompliantPaidPlan({
|
||||
required_plan: organizationData.required_plan,
|
||||
required_team_quantity: organizationData.required_team_quantity,
|
||||
active_subscription_plan: organizationData.active_subscription_plan,
|
||||
active_subscription_quantity: organizationData.active_subscription_quantity,
|
||||
});
|
||||
}, [organizationData]);
|
||||
|
||||
return { isPastTrial, isLoading };
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get when the user's trial expires
|
||||
* Returns the trial expiration date (creation date + 7 days)
|
||||
* Hook to get when the organization's trial expires
|
||||
* Returns remaining trial days when trial is active, otherwise null.
|
||||
*/
|
||||
export const useTrialExpiration = () => {
|
||||
const user = useMaybeUser();
|
||||
const { data: organizationData, isLoading } = useOrganization();
|
||||
|
||||
// Trial model is deprecated in favor of explicit billing plans.
|
||||
const daysRemaining = useMemo(() => null as number | null, [user]);
|
||||
const daysRemaining = useMemo(() => {
|
||||
if (!organizationData || organizationData.is_trial_expired) {
|
||||
return null as number | null;
|
||||
}
|
||||
|
||||
return { daysRemaining, isLoading: !user };
|
||||
const trialEnd = new Date(organizationData.trial_ends_at).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
const diffMs = trialEnd - now;
|
||||
if (diffMs <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
}, [organizationData]);
|
||||
|
||||
return { daysRemaining, isLoading };
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -149,15 +204,22 @@ export function useCreateCheckoutSession() {
|
|||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: async ({
|
||||
plan,
|
||||
priceId,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
}: {
|
||||
priceId: string;
|
||||
plan?: CheckoutPlan;
|
||||
priceId?: string;
|
||||
successUrl?: string;
|
||||
cancelUrl?: string;
|
||||
}) => {
|
||||
if (!plan && !priceId) {
|
||||
throw new Error("A checkout plan or priceId is required");
|
||||
}
|
||||
|
||||
const response = await api.post("/api/v1/stripe/create-checkout-session", {
|
||||
plan,
|
||||
priceId,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
|
|
@ -167,12 +229,6 @@ export function useCreateCheckoutSession() {
|
|||
throw new Error("No checkout URL returned");
|
||||
}
|
||||
|
||||
// Redirect to Stripe Checkout
|
||||
const stripe = await stripePromise;
|
||||
if (!stripe) {
|
||||
throw new Error("Stripe failed to load");
|
||||
}
|
||||
|
||||
// Use the URL directly for redirect
|
||||
window.location.href = response.data.url;
|
||||
|
||||
|
|
@ -246,6 +302,7 @@ export function useCancelSubscription() {
|
|||
queryClient.invalidateQueries({ queryKey: ["subscription"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["is-paying"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["user"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["organization"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.add({
|
||||
|
|
@ -281,6 +338,7 @@ export function useReactivateSubscription() {
|
|||
queryClient.invalidateQueries({ queryKey: ["subscription"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["is-paying"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["user"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["organization"] });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.add({
|
||||
|
|
|
|||
|
|
@ -92,10 +92,7 @@ export const useAllTasks = () => {
|
|||
};
|
||||
|
||||
// Fetch all tasks for a specific tablo
|
||||
export const useTasksByTablo = (
|
||||
tabloId: string | undefined,
|
||||
options?: { assigneeId?: string }
|
||||
) => {
|
||||
export const useTasksByTablo = (tabloId: string | undefined, options?: { assigneeId?: string }) => {
|
||||
const assigneeId = options?.assigneeId;
|
||||
|
||||
return useQuery({
|
||||
|
|
|
|||
4
apps/main/src/lib/billing.ts
Normal file
4
apps/main/src/lib/billing.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export type SignupBillingIntent = "trial" | "founder";
|
||||
|
||||
export const SIGNUP_BILLING_INTENT_KEY = "signupBillingIntent";
|
||||
export const PENDING_BILLING_CHECKOUT_PLAN_KEY = "pendingBillingCheckoutPlan";
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { RouteObject } from "react-router-dom";
|
||||
import { EventsPage } from "src/pages/events";
|
||||
import { AuthenticationGateway } from "../components/AuthenticationGateway";
|
||||
import { EventModal } from "../components/EventModal";
|
||||
import { Layout } from "../components/Layout";
|
||||
|
|
@ -8,6 +9,7 @@ import { ChantiersPage } from "../pages/chantiers";
|
|||
import { ChatPage } from "../pages/chat";
|
||||
import { ConfirmEmailPage } from "../pages/confirm-email";
|
||||
import { FeedbackPage } from "../pages/feedback";
|
||||
import { FilesPage } from "../pages/files";
|
||||
import { JoinPage } from "../pages/join";
|
||||
import { LegalNoticePage } from "../pages/legal-notice";
|
||||
import { LoginPage } from "../pages/login";
|
||||
|
|
@ -25,10 +27,8 @@ import { TabloPage } from "../pages/tablo";
|
|||
import { TabloDetailsPage } from "../pages/tablo-details";
|
||||
import { TablosPage } from "../pages/tablos";
|
||||
import { TasksPage } from "../pages/tasks";
|
||||
import { FilesPage } from "../pages/files";
|
||||
import { UpdatePasswordPage } from "../pages/update-password";
|
||||
import ChatProvider from "../providers/ChatProvider";
|
||||
import { EventsPage } from "src/pages/events";
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
// Protected routes
|
||||
|
|
@ -98,10 +98,7 @@ export const routes: RouteObject[] = [
|
|||
{
|
||||
path: "events",
|
||||
element: <EventsPage />,
|
||||
children: [
|
||||
{ index: true },
|
||||
{ path: "create", element: <EventModal mode="create" /> },
|
||||
],
|
||||
children: [{ index: true }, { path: "create", element: <EventModal mode="create" /> }],
|
||||
},
|
||||
{
|
||||
path: "tasks",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { datadogRum } from "@datadog/browser-rum";
|
||||
import { reactPlugin } from "@datadog/browser-rum-react";
|
||||
|
||||
// import { getCookieConsent } from "../hooks/useCookieConsent";
|
||||
|
||||
// Check if user has consented to analytics cookies before initializing
|
||||
|
|
|
|||
|
|
@ -19,6 +19,15 @@
|
|||
"availabilities": "Availabilities",
|
||||
"logout": "Sign out"
|
||||
},
|
||||
"organizationMenu": {
|
||||
"title": "Organization",
|
||||
"members": "Members",
|
||||
"memberCount_one": "{{count}} member",
|
||||
"memberCount_other": "{{count}} members",
|
||||
"noMembers": "No members",
|
||||
"unknownUser": "User",
|
||||
"removeConfirm": "Remove this member from the organization?"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"markAllRead": "Mark all read",
|
||||
|
|
|
|||
|
|
@ -49,7 +49,17 @@
|
|||
"emailPlaceholder": "name@company.com",
|
||||
"hint": "The invited user will get credentials by email.",
|
||||
"invite": "Send invite",
|
||||
"inviting": "Sending..."
|
||||
"inviting": "Sending...",
|
||||
"invitedByYouTitle": "Users invited by you",
|
||||
"noInvitesYet": "You have not invited anyone yet.",
|
||||
"invitedOn": "Invited on {{date}}",
|
||||
"membersTitle": "Other organization members",
|
||||
"noOtherMembers": "No other members in your organization yet.",
|
||||
"joinedOn": "Joined on {{date}}",
|
||||
"remove": "Remove",
|
||||
"removeConfirm": "Remove this member from the organization?",
|
||||
"unknownDate": "unknown date",
|
||||
"unknownUser": "Unknown user"
|
||||
},
|
||||
"cookies": {
|
||||
"title": "Cookie Preferences",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,15 @@
|
|||
"availabilities": "Disponibilités",
|
||||
"logout": "Se déconnecter"
|
||||
},
|
||||
"organizationMenu": {
|
||||
"title": "Organisation",
|
||||
"members": "Membres",
|
||||
"memberCount_one": "{{count}} membre",
|
||||
"memberCount_other": "{{count}} membres",
|
||||
"noMembers": "Aucun membre",
|
||||
"unknownUser": "Utilisateur",
|
||||
"removeConfirm": "Retirer ce membre de l'organisation ?"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"markAllRead": "Tout marquer comme lu",
|
||||
|
|
|
|||
|
|
@ -49,7 +49,17 @@
|
|||
"emailPlaceholder": "nom@entreprise.com",
|
||||
"hint": "L'utilisateur invité recevra ses identifiants par email.",
|
||||
"invite": "Envoyer l'invitation",
|
||||
"inviting": "Envoi..."
|
||||
"inviting": "Envoi...",
|
||||
"invitedByYouTitle": "Utilisateurs invités par vous",
|
||||
"noInvitesYet": "Vous n'avez encore invité personne.",
|
||||
"invitedOn": "Invité le {{date}}",
|
||||
"membersTitle": "Autres membres de l'organisation",
|
||||
"noOtherMembers": "Aucun autre membre dans votre organisation pour le moment.",
|
||||
"joinedOn": "A rejoint le {{date}}",
|
||||
"remove": "Retirer",
|
||||
"removeConfirm": "Retirer ce membre de l'organisation ?",
|
||||
"unknownDate": "date inconnue",
|
||||
"unknownUser": "Utilisateur inconnu"
|
||||
},
|
||||
"cookies": {
|
||||
"title": "Préférences des cookies",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -28,13 +28,13 @@ import { useTranslation } from "react-i18next";
|
|||
import { EventTypeCard } from "../components/EventTypeCard";
|
||||
import { EventTypeModal } from "../components/EventTypeModal";
|
||||
import { ExceptionModal } from "../components/ExceptionModal";
|
||||
import { EventTypeConfig, useEventTypes } from "../hooks/event-types";
|
||||
import {
|
||||
DEFAULT_AVAILABILITIES,
|
||||
Exception,
|
||||
useAvailabilities,
|
||||
WeeklyAvailability,
|
||||
} from "../hooks/availabilities";
|
||||
import { EventTypeConfig, useEventTypes } from "../hooks/event-types";
|
||||
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
|
||||
|
||||
const DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6];
|
||||
|
|
@ -238,20 +238,20 @@ export function AvailabilitiesPage() {
|
|||
<SaveIcon /> {t("availabilities:actions.save")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateAvailabilities({
|
||||
updatedAvailabilities: DEFAULT_AVAILABILITIES,
|
||||
});
|
||||
}}
|
||||
className="py-1 border border-[#804EEC]/35 text-[#804EEC] bg-white hover:bg-[#804EEC]/10"
|
||||
>
|
||||
{t("availabilities:actions.businessHours")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateAvailabilities({
|
||||
updatedAvailabilities: DEFAULT_AVAILABILITIES,
|
||||
});
|
||||
}}
|
||||
className="py-1 border border-[#804EEC]/35 text-[#804EEC] bg-white hover:bg-[#804EEC]/10"
|
||||
>
|
||||
{t("availabilities:actions.businessHours")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newAvailabilities: WeeklyAvailability = {};
|
||||
DAYS_OF_WEEK.forEach((day) => {
|
||||
|
|
@ -479,9 +479,7 @@ export function AvailabilitiesPage() {
|
|||
<section className="rounded-2xl border border-[#804EEC]/25 bg-card p-6">
|
||||
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">
|
||||
{t("availabilities:callTypes.title")}
|
||||
</h3>
|
||||
<h3 className="text-xl font-semibold">{t("availabilities:callTypes.title")}</h3>
|
||||
<Text className="text-muted-foreground">
|
||||
{t("availabilities:callTypes.description")}
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -41,47 +41,47 @@ export function ChatPage() {
|
|||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Discussions</h1>
|
||||
</div>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div
|
||||
className={`border-r border-gray-200 dark:border-gray-600/50 bg-white dark:bg-gray-700/40 transition-all duration-300 ease-in-out overflow-hidden ${
|
||||
isChannelListExpanded ? "w-80" : "w-0"
|
||||
}`}
|
||||
>
|
||||
<ChannelList
|
||||
filters={filters}
|
||||
setActiveChannelOnMount={isChannelInUrl ? false : true}
|
||||
Preview={({
|
||||
displayTitle,
|
||||
channel,
|
||||
activeChannel,
|
||||
setActiveChannel,
|
||||
unread,
|
||||
latestMessagePreview,
|
||||
}) => (
|
||||
<ChannelPreview
|
||||
displayTitle={displayTitle}
|
||||
channel={channel}
|
||||
tablo={tablos?.find((t) => t.id === channel.id) ?? null}
|
||||
activeChannel={activeChannel}
|
||||
setActiveChannel={setActiveChannel}
|
||||
unreadCount={unread}
|
||||
latestMessagePreview={latestMessagePreview}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-white dark:bg-gray-700/40">
|
||||
<Channel channel={channel}>
|
||||
<Window>
|
||||
<CustomChannelHeader
|
||||
tablos={tablos ?? []}
|
||||
onToggleChannelList={toggleChannelList}
|
||||
isChannelListExpanded={isChannelListExpanded}
|
||||
/>
|
||||
<MessageList />
|
||||
<MessageInput />
|
||||
</Window>
|
||||
</Channel>
|
||||
</div>
|
||||
<div
|
||||
className={`border-r border-gray-200 dark:border-gray-600/50 bg-white dark:bg-gray-700/40 transition-all duration-300 ease-in-out overflow-hidden ${
|
||||
isChannelListExpanded ? "w-80" : "w-0"
|
||||
}`}
|
||||
>
|
||||
<ChannelList
|
||||
filters={filters}
|
||||
setActiveChannelOnMount={isChannelInUrl ? false : true}
|
||||
Preview={({
|
||||
displayTitle,
|
||||
channel,
|
||||
activeChannel,
|
||||
setActiveChannel,
|
||||
unread,
|
||||
latestMessagePreview,
|
||||
}) => (
|
||||
<ChannelPreview
|
||||
displayTitle={displayTitle}
|
||||
channel={channel}
|
||||
tablo={tablos?.find((t) => t.id === channel.id) ?? null}
|
||||
activeChannel={activeChannel}
|
||||
setActiveChannel={setActiveChannel}
|
||||
unreadCount={unread}
|
||||
latestMessagePreview={latestMessagePreview}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-white dark:bg-gray-700/40">
|
||||
<Channel channel={channel}>
|
||||
<Window>
|
||||
<CustomChannelHeader
|
||||
tablos={tablos ?? []}
|
||||
onToggleChannelList={toggleChannelList}
|
||||
isChannelListExpanded={isChannelListExpanded}
|
||||
/>
|
||||
<MessageList />
|
||||
<MessageInput />
|
||||
</Window>
|
||||
</Channel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,12 +13,7 @@ import {
|
|||
SelectValue,
|
||||
} from "@xtablo/ui/components/select";
|
||||
import { Strong, Text, TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
SearchIcon,
|
||||
} from "lucide-react";
|
||||
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, SearchIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Outlet, useNavigate } from "react-router-dom";
|
||||
|
|
@ -230,285 +225,279 @@ export function EventsPage() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Filters */}
|
||||
<div className="bg-card rounded-lg shadow-sm border border-border p-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center">
|
||||
{/* Search */}
|
||||
<div className="flex-1 w-full">
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("pages:events.search")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 h-10"
|
||||
/>
|
||||
</div>
|
||||
{/* Filters */}
|
||||
<div className="bg-card rounded-lg shadow-sm border border-border p-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center">
|
||||
{/* Search */}
|
||||
<div className="flex-1 w-full">
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("pages:events.search")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tablo Filter */}
|
||||
<div className="w-full lg:w-64">
|
||||
<Select
|
||||
value={selectedTabloId}
|
||||
onValueChange={(value) => setSelectedTabloId(value)}
|
||||
>
|
||||
<SelectTrigger className="w-full h-10" aria-label="Filtrer par tableau">
|
||||
<SelectValue placeholder="Tous les tablos" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("pages:events.filters.allTablos")}</SelectItem>
|
||||
{tablos?.map((tablo) => (
|
||||
<SelectItem key={tablo.id} value={tablo.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-2 h-2 rounded-full",
|
||||
tablo.color || "bg-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
{tablo.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<ButtonGroup orientation="horizontal">
|
||||
{statusOptions.map((option) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
variant={statusFilter === option.id ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter(option.id as BookingStatus)}
|
||||
className="rounded-full"
|
||||
>
|
||||
{t(`pages:events.filters.${option.id}`)}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="bg-card rounded-lg shadow-sm border border-border">
|
||||
{tablosLoading || eventsLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : paginatedEvents.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<CalendarIcon className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<h3 className="mt-2 text-sm font-medium text-foreground">
|
||||
{t("pages:events.emptyState.title")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{searchTerm || statusFilter !== "all"
|
||||
? t("pages:events.emptyState.noResults")
|
||||
: t("pages:events.emptyState.noEvents")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{paginatedEvents.map((event) => (
|
||||
<div
|
||||
key={event.event_id}
|
||||
className="p-6 hover:bg-muted transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<Strong className="text-lg text-foreground truncate">
|
||||
{event.title || "Événement sans titre"}
|
||||
</Strong>
|
||||
{getEventStatusBadge(event)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground mb-2">
|
||||
<span className="flex items-center">
|
||||
<CalendarIcon className="w-4 h-4 mr-1" />
|
||||
{formatEventDateTime(event)}
|
||||
</span>
|
||||
{event.tablo_name && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"inline-flex items-center px-2 py-1 rounded-md text-xs font-medium",
|
||||
event.tablo_color,
|
||||
getTextColorFromTabloColor(event.tablo_color)
|
||||
)}
|
||||
>
|
||||
{event.tablo_name}
|
||||
</span>
|
||||
{/* Tablo Filter */}
|
||||
<div className="w-full lg:w-64">
|
||||
<Select
|
||||
value={selectedTabloId}
|
||||
onValueChange={(value) => setSelectedTabloId(value)}
|
||||
>
|
||||
<SelectTrigger className="w-full h-10" aria-label="Filtrer par tableau">
|
||||
<SelectValue placeholder="Tous les tablos" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("pages:events.filters.allTablos")}</SelectItem>
|
||||
{tablos?.map((tablo) => (
|
||||
<SelectItem key={tablo.id} value={tablo.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-2 h-2 rounded-full",
|
||||
tablo.color || "bg-muted-foreground"
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
{tablo.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{event.description && (
|
||||
<Text className="text-muted-foreground line-clamp-2">
|
||||
{event.description}
|
||||
</Text>
|
||||
{/* Status Filter */}
|
||||
<ButtonGroup orientation="horizontal">
|
||||
{statusOptions.map((option) => (
|
||||
<Button
|
||||
key={option.id}
|
||||
variant={statusFilter === option.id ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter(option.id as BookingStatus)}
|
||||
className="rounded-full"
|
||||
>
|
||||
{t(`pages:events.filters.${option.id}`)}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
<div className="bg-card rounded-lg shadow-sm border border-border">
|
||||
{tablosLoading || eventsLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : paginatedEvents.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<CalendarIcon className="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<h3 className="mt-2 text-sm font-medium text-foreground">
|
||||
{t("pages:events.emptyState.title")}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{searchTerm || statusFilter !== "all"
|
||||
? t("pages:events.emptyState.noResults")
|
||||
: t("pages:events.emptyState.noEvents")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{paginatedEvents.map((event) => (
|
||||
<div
|
||||
key={event.event_id}
|
||||
className="p-6 hover:bg-muted transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<Strong className="text-lg text-foreground truncate">
|
||||
{event.title || "Événement sans titre"}
|
||||
</Strong>
|
||||
{getEventStatusBadge(event)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground mb-2">
|
||||
<span className="flex items-center">
|
||||
<CalendarIcon className="w-4 h-4 mr-1" />
|
||||
{formatEventDateTime(event)}
|
||||
</span>
|
||||
{event.tablo_name && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"inline-flex items-center px-2 py-1 rounded-md text-xs font-medium",
|
||||
event.tablo_color,
|
||||
getTextColorFromTabloColor(event.tablo_color)
|
||||
)}
|
||||
>
|
||||
{event.tablo_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleViewEvent(event)}
|
||||
>
|
||||
{t("common:buttons.details")}
|
||||
</Button>
|
||||
</div>
|
||||
{event.description && (
|
||||
<Text className="text-muted-foreground line-clamp-2">
|
||||
{event.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
<Button variant="outline" size="sm" onClick={() => handleViewEvent(event)}>
|
||||
{t("common:buttons.details")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalItems > 0 && (
|
||||
<div className="bg-card rounded-lg shadow-sm border border-border px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{t("pages:events.pagination.showing", {
|
||||
start: startIndex + 1,
|
||||
end: Math.min(endIndex, totalItems),
|
||||
total: totalItems,
|
||||
})}
|
||||
{/* Pagination Controls */}
|
||||
{totalItems > 0 && (
|
||||
<div className="bg-card rounded-lg shadow-sm border border-border px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{t("pages:events.pagination.showing", {
|
||||
start: startIndex + 1,
|
||||
end: Math.min(endIndex, totalItems),
|
||||
total: totalItems,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="whitespace-nowrap">
|
||||
{t("pages:events.pagination.itemsPerPage")}
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="whitespace-nowrap">
|
||||
{t("pages:events.pagination.itemsPerPage")}
|
||||
</span>
|
||||
<Select
|
||||
value={itemsPerPage.toString()}
|
||||
onValueChange={(value) => setItemsPerPage(Number(value))}
|
||||
<Select
|
||||
value={itemsPerPage.toString()}
|
||||
onValueChange={(value) => setItemsPerPage(Number(value))}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="min-w-16 h-8"
|
||||
aria-label="Nombre d'éléments par page"
|
||||
>
|
||||
<SelectTrigger
|
||||
className="min-w-16 h-8"
|
||||
aria-label="Nombre d'éléments par page"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
{t("pages:events.pagination.previous")}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter((page) => {
|
||||
return (
|
||||
page === 1 || page === totalPages || Math.abs(page - currentPage) <= 1
|
||||
);
|
||||
})
|
||||
.map((page, index, array) => {
|
||||
const prevPage = array[index - 1];
|
||||
const showEllipsis = prevPage && page - prevPage > 1;
|
||||
|
||||
return (
|
||||
<div key={page} className="flex items-center">
|
||||
{showEllipsis && (
|
||||
<span className="px-2 text-muted-foreground">...</span>
|
||||
)}
|
||||
<Button
|
||||
variant={currentPage === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={
|
||||
currentPage === page
|
||||
? "bg-emerald-700 text-white hover:bg-emerald-600"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
{t("pages:events.pagination.next")}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Summary */}
|
||||
{filteredEvents.length > 0 && (
|
||||
<div className="bg-card rounded-lg shadow-sm border border-border p-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{filteredEvents.length}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("pages:events.stats.found")}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
{t("pages:events.pagination.previous")}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter((page) => {
|
||||
return (
|
||||
page === 1 || page === totalPages || Math.abs(page - currentPage) <= 1
|
||||
);
|
||||
})
|
||||
.map((page, index, array) => {
|
||||
const prevPage = array[index - 1];
|
||||
const showEllipsis = prevPage && page - prevPage > 1;
|
||||
|
||||
return (
|
||||
<div key={page} className="flex items-center">
|
||||
{showEllipsis && (
|
||||
<span className="px-2 text-muted-foreground">...</span>
|
||||
)}
|
||||
<Button
|
||||
variant={currentPage === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={
|
||||
currentPage === page
|
||||
? "bg-emerald-700 text-white hover:bg-emerald-600"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
{t("pages:events.pagination.next")}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{
|
||||
filteredEvents.filter((e) => {
|
||||
if (!e.start_date) return false;
|
||||
const eventDate = new Date(e.start_date);
|
||||
return eventDate >= new Date();
|
||||
}).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("pages:events.stats.upcoming")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Summary */}
|
||||
{filteredEvents.length > 0 && (
|
||||
<div className="bg-card rounded-lg shadow-sm border border-border p-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-foreground">{filteredEvents.length}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("pages:events.stats.found")}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{
|
||||
filteredEvents.filter((e) => {
|
||||
if (!e.start_date) return false;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const eventDate = new Date(e.start_date);
|
||||
eventDate.setHours(0, 0, 0, 0);
|
||||
return eventDate.getTime() === today.getTime();
|
||||
}).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("pages:events.stats.today")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{
|
||||
filteredEvents.filter((e) => {
|
||||
if (!e.start_date) return false;
|
||||
const eventDate = new Date(e.start_date);
|
||||
return eventDate >= new Date();
|
||||
}).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("pages:events.stats.upcoming")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-primary">
|
||||
{
|
||||
filteredEvents.filter((e) => {
|
||||
if (!e.start_date) return false;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const eventDate = new Date(e.start_date);
|
||||
eventDate.setHours(0, 0, 0, 0);
|
||||
return eventDate.getTime() === today.getTime();
|
||||
}).length
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("pages:events.stats.today")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Event Details Modal */}
|
||||
|
|
@ -522,7 +511,6 @@ export function EventsPage() {
|
|||
onEdit={() => selectedEvent && handleEditEvent(selectedEvent)}
|
||||
canEdit={selectedEvent ? canEditEvent(selectedEvent) : false}
|
||||
/>
|
||||
|
||||
</main>
|
||||
|
||||
{/* Render child routes (e.g. EventModal) */}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,7 @@
|
|||
import { toast } from "@xtablo/shared";
|
||||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import { toast } from "@xtablo/shared";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@xtablo/ui/components/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@xtablo/ui/components/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -25,18 +20,18 @@ import {
|
|||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
extractFolderIdFromFileName,
|
||||
getFileNameWithoutFolder,
|
||||
getFolderFilePrefix,
|
||||
useTabloFolders,
|
||||
} from "../hooks/tablo_folders";
|
||||
import {
|
||||
useAllTablosFileNames,
|
||||
useCreateTabloFile,
|
||||
useDeleteTabloFile,
|
||||
useDownloadTabloFile,
|
||||
} from "../hooks/tablo_data";
|
||||
import {
|
||||
extractFolderIdFromFileName,
|
||||
getFileNameWithoutFolder,
|
||||
getFolderFilePrefix,
|
||||
useTabloFolders,
|
||||
} from "../hooks/tablo_folders";
|
||||
import { useTablosList } from "../hooks/tablos";
|
||||
|
||||
// Derive icon color from file extension
|
||||
|
|
@ -146,7 +141,9 @@ function UploadModal({
|
|||
>
|
||||
{tablo.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100 truncate">{tablo.name}</span>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100 truncate">
|
||||
{tablo.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -155,7 +152,9 @@ function UploadModal({
|
|||
{/* Folder selector (optional) */}
|
||||
{folders.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Folder (optional)</label>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Folder (optional)
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -319,8 +318,12 @@ function TabloFilesSection({
|
|||
<div key={folder.id} className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-2 px-1">
|
||||
<FolderIcon className="w-4 h-4 text-amber-500 dark:text-amber-400" />
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{folder.name}</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">({folderFiles.length})</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{folder.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
({folderFiles.length})
|
||||
</span>
|
||||
</div>
|
||||
<FileTable
|
||||
fileNames={folderFiles}
|
||||
|
|
@ -336,7 +339,9 @@ function TabloFilesSection({
|
|||
<>
|
||||
{folders.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-2 px-1">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Other files</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Other files
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">({rootFiles.length})</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -368,7 +373,9 @@ function FileTable({
|
|||
<table className="w-full">
|
||||
<thead className="border-y border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/80">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-sm font-normal text-gray-900 dark:text-gray-300">File name</th>
|
||||
<th className="px-6 py-3 text-left text-sm font-normal text-gray-900 dark:text-gray-300">
|
||||
File name
|
||||
</th>
|
||||
<th className="px-6 py-3 w-12" />
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -377,7 +384,10 @@ function FileTable({
|
|||
const displayName = getFileNameWithoutFolder(fileName);
|
||||
const iconColor = getFileIconColor(displayName);
|
||||
return (
|
||||
<tr key={fileName} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group">
|
||||
<tr
|
||||
key={fileName}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group"
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
|
|
@ -385,7 +395,9 @@ function FileTable({
|
|||
>
|
||||
<FileTextIcon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<p className="text-sm font-normal text-gray-900 dark:text-gray-100">{displayName}</p>
|
||||
<p className="text-sm font-normal text-gray-900 dark:text-gray-100">
|
||||
{displayName}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
|
|
@ -444,8 +456,9 @@ export function FilesPage() {
|
|||
const files = filesByTabloId.get(tablo.id) ?? [];
|
||||
const visibleFiles = files.filter((f) => !f.startsWith("."));
|
||||
if (searchQuery) {
|
||||
return visibleFiles.some((f) =>
|
||||
f.toLowerCase().includes(searchQuery) || tablo.name.toLowerCase().includes(searchQuery)
|
||||
return visibleFiles.some(
|
||||
(f) =>
|
||||
f.toLowerCase().includes(searchQuery) || tablo.name.toLowerCase().includes(searchQuery)
|
||||
);
|
||||
}
|
||||
return visibleFiles.length > 0;
|
||||
|
|
@ -455,7 +468,9 @@ export function FilesPage() {
|
|||
<div className="py-6 px-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{t("files", "Files")}</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{t("files", "Files")}
|
||||
</h1>
|
||||
<Button
|
||||
className="gap-2 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
|
||||
onClick={() => setUploadOpen(true)}
|
||||
|
|
@ -490,11 +505,7 @@ export function FilesPage() {
|
|||
)}
|
||||
|
||||
{tablos && tablos.length > 0 && (
|
||||
<UploadModal
|
||||
isOpen={uploadOpen}
|
||||
onClose={() => setUploadOpen(false)}
|
||||
tablos={tablos}
|
||||
/>
|
||||
<UploadModal isOpen={uploadOpen} onClose={() => setUploadOpen(false)} tablos={tablos} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,13 +5,7 @@ import { Button } from "@xtablo/ui/components/button";
|
|||
import { FieldError } from "@xtablo/ui/components/field";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
import { Label } from "@xtablo/ui/components/label";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
MonitorIcon,
|
||||
MoonIcon,
|
||||
SparklesIcon,
|
||||
SunIcon,
|
||||
} from "lucide-react";
|
||||
import { ArrowLeftIcon, MonitorIcon, MoonIcon, SparklesIcon, SunIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
|
|
@ -96,27 +90,14 @@ export function LoginV2Page() {
|
|||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src="/logo_dark.png"
|
||||
alt="Xtablo"
|
||||
className="h-10 w-auto block dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo_white.png"
|
||||
alt="Xtablo"
|
||||
className="h-10 w-auto hidden dark:block"
|
||||
/>
|
||||
<img src="/logo_dark.png" alt="Xtablo" className="h-10 w-auto block dark:hidden" />
|
||||
<img src="/logo_white.png" alt="Xtablo" className="h-10 w-auto hidden dark:block" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||
{t("auth:login.title")}
|
||||
</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("auth:login.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground mb-8">
|
||||
{t("auth:login.noAccount")}{" "}
|
||||
<Link
|
||||
to="/signup-v2"
|
||||
className="text-[#804EEC] hover:text-[#6f3fd4] font-semibold"
|
||||
>
|
||||
<Link to="/signup-v2" className="text-[#804EEC] hover:text-[#6f3fd4] font-semibold">
|
||||
{t("auth:login.signupLink")}
|
||||
</Link>
|
||||
</p>
|
||||
|
|
@ -124,46 +105,36 @@ export function LoginV2Page() {
|
|||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">
|
||||
{t("common:labels.email")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
{t("common:labels.email")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
required
|
||||
placeholder={t("auth:login.emailPlaceholder")}
|
||||
className="h-11"
|
||||
/>
|
||||
{errors?.email && (
|
||||
<FieldError errors={[{ message: errors.email }]} />
|
||||
)}
|
||||
{errors?.email && <FieldError errors={[{ message: errors.email }]} />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
{t("common:labels.password")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
{t("common:labels.password")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
required
|
||||
placeholder={t("auth:login.passwordPlaceholder")}
|
||||
className="h-11"
|
||||
/>
|
||||
{errors?.password && (
|
||||
<FieldError errors={[{ message: errors.password }]} />
|
||||
)}
|
||||
{errors?.password && <FieldError errors={[{ message: errors.password }]} />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
|
|
@ -179,9 +150,7 @@ export function LoginV2Page() {
|
|||
className="w-full h-11 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
|
||||
type="submit"
|
||||
>
|
||||
{isPending
|
||||
? t("auth:common.connecting")
|
||||
: t("auth:login.loginButton")}
|
||||
{isPending ? t("auth:common.connecting") : t("auth:login.loginButton")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
|
|
@ -245,9 +214,7 @@ export function LoginV2Page() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-center mb-3">
|
||||
{t("auth:login.asideTitle")}
|
||||
</h2>
|
||||
<h2 className="text-2xl font-bold text-center mb-3">{t("auth:login.asideTitle")}</h2>
|
||||
<p className="text-center text-muted-foreground mb-8">
|
||||
{t("auth:login.asideDescription")}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export function LoginPage() {
|
|||
const rotateY = ((x - centerX) / centerX) * 1;
|
||||
|
||||
setTransform(
|
||||
`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.002, 1.002, 1.002)`,
|
||||
`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.002, 1.002, 1.002)`
|
||||
);
|
||||
setIsHovered(true);
|
||||
};
|
||||
|
|
@ -101,7 +101,7 @@ export function LoginPage() {
|
|||
ref={cardRef}
|
||||
className={twMerge(
|
||||
"w-full max-w-lg rounded-2xl relative",
|
||||
"transition-transform duration-200 ease-out will-change-transform",
|
||||
"transition-transform duration-200 ease-out will-change-transform"
|
||||
)}
|
||||
style={{ transform }}
|
||||
onMouseMove={handleMouseMove}
|
||||
|
|
@ -116,7 +116,7 @@ export function LoginPage() {
|
|||
"relative w-full h-full p-8 bg-card/80 backdrop-blur-md rounded-2xl border border-border z-10 transition-shadow duration-200",
|
||||
isHovered
|
||||
? "shadow-[0_15px_35px_rgba(0,0,0,0.15)] dark:shadow-[0_15px_35px_rgba(0,0,0,0.3)]"
|
||||
: "shadow-xl shadow-black/10 dark:shadow-black/25",
|
||||
: "shadow-xl shadow-black/10 dark:shadow-black/25"
|
||||
)}
|
||||
>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
|
|
@ -124,12 +124,7 @@ export function LoginPage() {
|
|||
href="https://www.xtablo.com"
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
|
@ -180,50 +175,37 @@ export function LoginPage() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-4 flex flex-col items-center">
|
||||
<form
|
||||
className="space-y-4 w-95 max-w-md mx-auto"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<form className="space-y-4 w-95 max-w-md mx-auto" onSubmit={onSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">
|
||||
{t("common:labels.email")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
{t("common:labels.email")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
required
|
||||
placeholder={t("auth:login.emailPlaceholder")}
|
||||
/>
|
||||
{errors?.email && (
|
||||
<FieldError errors={[{ message: errors.email }]} />
|
||||
)}
|
||||
{errors?.email && <FieldError errors={[{ message: errors.email }]} />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
{t("common:labels.password")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
{t("common:labels.password")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
required
|
||||
placeholder={t("auth:login.passwordPlaceholder")}
|
||||
/>
|
||||
{errors?.password && (
|
||||
<FieldError errors={[{ message: errors.password }]} />
|
||||
)}
|
||||
{errors?.password && <FieldError errors={[{ message: errors.password }]} />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
|
|
@ -236,9 +218,7 @@ export function LoginPage() {
|
|||
</div>
|
||||
|
||||
<Button className="w-full" type="submit">
|
||||
{isPending
|
||||
? t("auth:common.connecting")
|
||||
: t("auth:login.loginButton")}
|
||||
{isPending ? t("auth:common.connecting") : t("auth:login.loginButton")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
|
|
@ -255,7 +235,7 @@ export function LoginPage() {
|
|||
"rounded-full",
|
||||
"relative z-10",
|
||||
"before:absolute before:w-[100px] before:h-px before:bg-border before:left-[-110px] before:top-1/2",
|
||||
"after:absolute after:w-[100px] after:h-px after:bg-border after:right-[-110px] after:top-1/2",
|
||||
"after:absolute after:w-[100px] after:h-px after:bg-border after:right-[-110px] after:top-1/2"
|
||||
)}
|
||||
>
|
||||
{t("auth:common.orContinue")}
|
||||
|
|
|
|||
|
|
@ -39,16 +39,11 @@ import {
|
|||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Outlet,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { Outlet, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { EventModal } from "../components/EventModal";
|
||||
import { useDeleteEvent, useEventsByTablo } from "../hooks/events";
|
||||
import { useGetAllTabloAccess, useTablosList } from "../hooks/tablos";
|
||||
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
|
||||
import { EventModal } from "../components/EventModal";
|
||||
|
||||
type ViewType = "month" | "week" | "day";
|
||||
|
||||
|
|
@ -100,14 +95,10 @@ export const PlanningPage = () => {
|
|||
// Initialize view from URL search params, default to "month"
|
||||
const viewFromUrl = searchParams.get("view") as ViewType | null;
|
||||
const initialView: ViewType =
|
||||
viewFromUrl && ["month", "week", "day"].includes(viewFromUrl)
|
||||
? viewFromUrl
|
||||
: "month";
|
||||
viewFromUrl && ["month", "week", "day"].includes(viewFromUrl) ? viewFromUrl : "month";
|
||||
const [currentView, setCurrentView] = useState<ViewType>(initialView);
|
||||
|
||||
const [selectedTabloId, setSelectedTabloId] = useState<string>(
|
||||
tablo_id || "all",
|
||||
);
|
||||
const [selectedTabloId, setSelectedTabloId] = useState<string>(tablo_id || "all");
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
const [isWebcalModalOpen, setIsWebcalModalOpen] = useState(false);
|
||||
const isReadOnly = useIsReadOnlyUser();
|
||||
|
|
@ -119,8 +110,9 @@ export const PlanningPage = () => {
|
|||
const { data: tablos, isLoading: tablosLoading } = useTablosList();
|
||||
|
||||
// Fetch events for selected tablo or all tablos
|
||||
const { data: tabloEvents = [], isLoading: tabloEventsLoading } =
|
||||
useEventsByTablo(selectedTabloId !== "all" ? selectedTabloId : null);
|
||||
const { data: tabloEvents = [], isLoading: tabloEventsLoading } = useEventsByTablo(
|
||||
selectedTabloId !== "all" ? selectedTabloId : null
|
||||
);
|
||||
// Fetch all tablo accesses
|
||||
const { data: tabloAccess } = useGetAllTabloAccess();
|
||||
|
||||
|
|
@ -131,7 +123,7 @@ export const PlanningPage = () => {
|
|||
if (
|
||||
tabloAccess?.find(
|
||||
(access: { tablo_id: string; is_admin: boolean }) =>
|
||||
access.tablo_id === event.tablo_id && access.is_admin,
|
||||
access.tablo_id === event.tablo_id && access.is_admin
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
|
|
@ -154,17 +146,13 @@ export const PlanningPage = () => {
|
|||
return newParams;
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
[setSearchParams]
|
||||
);
|
||||
|
||||
// Sync view with URL on mount and when URL changes
|
||||
useEffect(() => {
|
||||
const viewParam = searchParams.get("view") as ViewType | null;
|
||||
if (
|
||||
viewParam &&
|
||||
["month", "week", "day"].includes(viewParam) &&
|
||||
viewParam !== currentView
|
||||
) {
|
||||
if (viewParam && ["month", "week", "day"].includes(viewParam) && viewParam !== currentView) {
|
||||
setCurrentView(viewParam);
|
||||
} else if (!viewParam) {
|
||||
// If no view param in URL, set it to current view
|
||||
|
|
@ -174,7 +162,7 @@ export const PlanningPage = () => {
|
|||
newParams.set("view", currentView);
|
||||
return newParams;
|
||||
},
|
||||
{ replace: true },
|
||||
{ replace: true }
|
||||
);
|
||||
}
|
||||
}, [searchParams, currentView, setSearchParams]);
|
||||
|
|
@ -225,8 +213,7 @@ export const PlanningPage = () => {
|
|||
const calendarName =
|
||||
selectedTabloId === "all"
|
||||
? t("planning:allEvents")
|
||||
: tablos?.find((t) => t.id === selectedTabloId)?.name ||
|
||||
t("planning:title");
|
||||
: tablos?.find((t) => t.id === selectedTabloId)?.name || t("planning:title");
|
||||
|
||||
const icsContent = generateICSFromEvents(tabloEvents, calendarName);
|
||||
const filename =
|
||||
|
|
@ -355,12 +342,7 @@ export const PlanningPage = () => {
|
|||
// const nowMinute = now.getMinutes();
|
||||
const nowDay = now.getDate();
|
||||
|
||||
fullDate.setHours(
|
||||
Number(time.split(":")[0]),
|
||||
Number(time.split(":")[1]),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
fullDate.setHours(Number(time.split(":")[0]), Number(time.split(":")[1]), 0, 0);
|
||||
|
||||
const hour = fullDate.getHours();
|
||||
// const minute = fullDate.getMinutes();
|
||||
|
|
@ -428,8 +410,7 @@ export const PlanningPage = () => {
|
|||
const daysInMonth = lastDay.getDate();
|
||||
// Adjust for Monday as first day of week
|
||||
const startingDayOfWeek = firstDay.getDay();
|
||||
const mondayStartingDay =
|
||||
startingDayOfWeek === 0 ? 6 : startingDayOfWeek - 1;
|
||||
const mondayStartingDay = startingDayOfWeek === 0 ? 6 : startingDayOfWeek - 1;
|
||||
|
||||
const days = [];
|
||||
for (let i = 0; i < mondayStartingDay; i++) {
|
||||
|
|
@ -466,9 +447,7 @@ export const PlanningPage = () => {
|
|||
if (currentView === "week") {
|
||||
const weekDays = getWeekDays();
|
||||
const weekDateStrings = weekDays.map(formatDate);
|
||||
return tabloEvents.filter((event) =>
|
||||
weekDateStrings.includes(event.start_date),
|
||||
);
|
||||
return tabloEvents.filter((event) => weekDateStrings.includes(event.start_date));
|
||||
} else if (currentView === "day") {
|
||||
const dateString = formatDate(currentDate);
|
||||
return tabloEvents.filter((event) => event.start_date === dateString);
|
||||
|
|
@ -489,7 +468,7 @@ export const PlanningPage = () => {
|
|||
...visibleEvents.map((event) => {
|
||||
const [hour] = event.start_time.split(":").map(Number);
|
||||
return hour;
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Return the earlier of 8am or the earliest event hour
|
||||
|
|
@ -513,7 +492,7 @@ export const PlanningPage = () => {
|
|||
}
|
||||
const [hour] = event.end_time.split(":").map(Number);
|
||||
return hour;
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Return the later of 7pm or the latest event hour
|
||||
|
|
@ -540,7 +519,7 @@ export const PlanningPage = () => {
|
|||
|
||||
return Array.from(
|
||||
{ length: numSlots },
|
||||
(_, i) => `${(startHour + i).toString().padStart(2, "0")}:00`,
|
||||
(_, i) => `${(startHour + i).toString().padStart(2, "0")}:00`
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -568,9 +547,7 @@ export const PlanningPage = () => {
|
|||
className={`min-h-[120px] border-b border-border ${
|
||||
(index + 1) % 7 !== 0 ? "border-r border-border" : ""
|
||||
} ${day ? "cursor-pointer hover:bg-muted" : "bg-muted"} ${
|
||||
day && formatDate(day) === formatDate(new Date())
|
||||
? "bg-primary/10"
|
||||
: ""
|
||||
day && formatDate(day) === formatDate(new Date()) ? "bg-primary/10" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (day) {
|
||||
|
|
@ -588,9 +565,7 @@ export const PlanningPage = () => {
|
|||
<div className="p-2">
|
||||
<div
|
||||
className={`text-sm font-medium mb-1 ${
|
||||
formatDate(day) === formatDate(new Date())
|
||||
? "text-primary"
|
||||
: "text-foreground"
|
||||
formatDate(day) === formatDate(new Date()) ? "text-primary" : "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{day.getDate()}
|
||||
|
|
@ -617,18 +592,14 @@ export const PlanningPage = () => {
|
|||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (canEditEvent(event)) {
|
||||
navigate(
|
||||
`/planning/${event.tablo_id}/events/${event.event_id}/edit`,
|
||||
);
|
||||
navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="truncate">
|
||||
{formatTime(event.start_time)} {event.title}
|
||||
{selectedTabloId === "all" && event.tablo_name && (
|
||||
<span className="opacity-75 ml-1">
|
||||
• {event.tablo_name}
|
||||
</span>
|
||||
<span className="opacity-75 ml-1">• {event.tablo_name}</span>
|
||||
)}
|
||||
</div>
|
||||
{canDeleteEvent(event) && (
|
||||
|
|
@ -676,9 +647,7 @@ export const PlanningPage = () => {
|
|||
</div>
|
||||
<div
|
||||
className={`text-lg font-medium mt-1 ${
|
||||
formatDate(day) === formatDate(new Date())
|
||||
? "text-primary"
|
||||
: "text-foreground"
|
||||
formatDate(day) === formatDate(new Date()) ? "text-primary" : "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{day.getDate()}
|
||||
|
|
@ -720,18 +689,10 @@ export const PlanningPage = () => {
|
|||
)}
|
||||
|
||||
{getEventsForDate(day)
|
||||
.filter((event) =>
|
||||
event.start_time.startsWith(time.split(":")[0]),
|
||||
)
|
||||
.filter((event) => event.start_time.startsWith(time.split(":")[0]))
|
||||
.map((event) => {
|
||||
const eventHeight = calculateEventHeight(
|
||||
event.start_time,
|
||||
event.end_time,
|
||||
);
|
||||
const eventOffset = calculateEventOffset(
|
||||
event.start_time,
|
||||
time,
|
||||
);
|
||||
const eventHeight = calculateEventHeight(event.start_time, event.end_time);
|
||||
const eventOffset = calculateEventOffset(event.start_time, time);
|
||||
return (
|
||||
<div
|
||||
key={event.event_id}
|
||||
|
|
@ -748,7 +709,7 @@ export const PlanningPage = () => {
|
|||
minHeight: "30px",
|
||||
}}
|
||||
title={`${formatTime(event.start_time)} - ${formatTime(
|
||||
event.end_time,
|
||||
event.end_time
|
||||
)} ${event.title}${
|
||||
selectedTabloId === "all" && event.tablo_name
|
||||
? ` - ${event.tablo_name}`
|
||||
|
|
@ -757,24 +718,19 @@ export const PlanningPage = () => {
|
|||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (canEditEvent(event)) {
|
||||
navigate(
|
||||
`/planning/${event.tablo_id}/events/${event.event_id}/edit`,
|
||||
);
|
||||
navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="text-[10px] font-medium leading-tight">
|
||||
{event.title}
|
||||
{selectedTabloId === "all" && event.tablo_name && (
|
||||
<span className="opacity-75 ml-1">
|
||||
• {event.tablo_name}
|
||||
</span>
|
||||
<span className="opacity-75 ml-1">• {event.tablo_name}</span>
|
||||
)}
|
||||
</div>
|
||||
{eventHeight >= 30 && (
|
||||
<div className="text-[9px] opacity-75 leading-tight">
|
||||
{formatTime(event.start_time)} -{" "}
|
||||
{formatTime(event.end_time)}
|
||||
{formatTime(event.start_time)} - {formatTime(event.end_time)}
|
||||
</div>
|
||||
)}
|
||||
{canDeleteEvent(event) && (
|
||||
|
|
@ -807,9 +763,7 @@ export const PlanningPage = () => {
|
|||
<div className="text-sm text-muted-foreground uppercase">
|
||||
{dayNames[currentDate.getDay()]}
|
||||
</div>
|
||||
<div className="text-2xl font-medium text-foreground mt-1">
|
||||
{currentDate.getDate()}
|
||||
</div>
|
||||
<div className="text-2xl font-medium text-foreground mt-1">{currentDate.getDate()}</div>
|
||||
</div>
|
||||
|
||||
{/* Time slots */}
|
||||
|
|
@ -842,18 +796,10 @@ export const PlanningPage = () => {
|
|||
)}
|
||||
|
||||
{getEventsForDate(currentDate)
|
||||
.filter((event) =>
|
||||
event.start_time.startsWith(time.split(":")[0]),
|
||||
)
|
||||
.filter((event) => event.start_time.startsWith(time.split(":")[0]))
|
||||
.map((event) => {
|
||||
const eventHeight = calculateEventHeight(
|
||||
event.start_time,
|
||||
event.end_time,
|
||||
);
|
||||
const eventOffset = calculateEventOffset(
|
||||
event.start_time,
|
||||
time,
|
||||
);
|
||||
const eventHeight = calculateEventHeight(event.start_time, event.end_time);
|
||||
const eventOffset = calculateEventOffset(event.start_time, time);
|
||||
return (
|
||||
<div
|
||||
key={event.event_id}
|
||||
|
|
@ -870,7 +816,7 @@ export const PlanningPage = () => {
|
|||
minHeight: "30px",
|
||||
}}
|
||||
title={`${formatTime(event.start_time)} - ${formatTime(
|
||||
event.end_time,
|
||||
event.end_time
|
||||
)} ${event.title}${
|
||||
selectedTabloId === "all" && event.tablo_name
|
||||
? ` - ${event.tablo_name}`
|
||||
|
|
@ -879,24 +825,19 @@ export const PlanningPage = () => {
|
|||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (canEditEvent(event)) {
|
||||
navigate(
|
||||
`/planning/${event.tablo_id}/events/${event.event_id}/edit`,
|
||||
);
|
||||
navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="text-[10px] font-medium truncate leading-tight">
|
||||
{event.title}
|
||||
{selectedTabloId === "all" && event.tablo_name && (
|
||||
<span className="opacity-75 ml-1">
|
||||
• {event.tablo_name}
|
||||
</span>
|
||||
<span className="opacity-75 ml-1">• {event.tablo_name}</span>
|
||||
)}
|
||||
</div>
|
||||
{eventHeight >= 30 && (
|
||||
<div className="text-[9px] opacity-75 leading-tight">
|
||||
{formatTime(event.start_time)} -{" "}
|
||||
{formatTime(event.end_time)}
|
||||
{formatTime(event.start_time)} - {formatTime(event.end_time)}
|
||||
</div>
|
||||
)}
|
||||
{eventHeight >= 75 && event.description && (
|
||||
|
|
@ -932,9 +873,7 @@ export const PlanningPage = () => {
|
|||
today.setHours(0, 0, 0, 0);
|
||||
const filtered = tabloEvents.filter((e) => {
|
||||
if (showAllEvents) return true;
|
||||
const eventDate = e.start_date
|
||||
? new Date(e.start_date + "T00:00:00")
|
||||
: null;
|
||||
const eventDate = e.start_date ? new Date(`${e.start_date}T00:00:00`) : null;
|
||||
return !eventDate || eventDate >= today;
|
||||
});
|
||||
|
||||
|
|
@ -1014,21 +953,15 @@ export const PlanningPage = () => {
|
|||
Aucun événement trouvé
|
||||
</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
|
||||
{showAllEvents
|
||||
? "Aucun événement trouvé"
|
||||
: "Aucun événement à venir"}
|
||||
{showAllEvents ? "Aucun événement trouvé" : "Aucun événement à venir"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{filtered.map((event) => {
|
||||
const date = event.start_date
|
||||
? new Date(event.start_date + "T00:00:00")
|
||||
: null;
|
||||
const date = event.start_date ? new Date(`${event.start_date}T00:00:00`) : null;
|
||||
const monthLabel = date ? months[date.getMonth()] : "";
|
||||
const dayLabel = date
|
||||
? String(date.getDate()).padStart(2, "0")
|
||||
: "";
|
||||
const dayLabel = date ? String(date.getDate()).padStart(2, "0") : "";
|
||||
const TabloIcon = getTabloIcon(event.tablo_color);
|
||||
const iconColor = getTabloIconColor(event.tablo_color);
|
||||
const timeLabel = event.start_time
|
||||
|
|
@ -1060,11 +993,7 @@ export const PlanningPage = () => {
|
|||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => deleteEvent.mutate(event.event_id)}
|
||||
disabled={
|
||||
!canDeleteEvent(event) ||
|
||||
isReadOnly ||
|
||||
deleteEvent.isPending
|
||||
}
|
||||
disabled={!canDeleteEvent(event) || isReadOnly || deleteEvent.isPending}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Supprimer
|
||||
|
|
@ -1113,9 +1042,7 @@ export const PlanningPage = () => {
|
|||
>
|
||||
<TabloIcon className={`w-3 h-3 ${iconColor}`} />
|
||||
</div>
|
||||
<span className="text-sm truncate">
|
||||
{event.tablo_name}
|
||||
</span>
|
||||
<span className="text-sm truncate">{event.tablo_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1136,9 +1063,7 @@ export const PlanningPage = () => {
|
|||
mode="create"
|
||||
isOpen={isCreateEventOpen}
|
||||
onClose={() => setIsCreateEventOpen(false)}
|
||||
defaultTabloId={
|
||||
selectedTabloId !== "all" ? selectedTabloId : undefined
|
||||
}
|
||||
defaultTabloId={selectedTabloId !== "all" ? selectedTabloId : undefined}
|
||||
defaultDate={currentDate}
|
||||
/>
|
||||
<Outlet />
|
||||
|
|
@ -1159,15 +1084,10 @@ export const PlanningPage = () => {
|
|||
onValueChange={(value) => setSelectedTabloId(value)}
|
||||
disabled={tablosLoading}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-label={t("planning:selectTablo")}
|
||||
>
|
||||
<SelectTrigger className="w-full" aria-label={t("planning:selectTablo")}>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
tablosLoading
|
||||
? t("common:actions.loading")
|
||||
: t("planning:selectTablo")
|
||||
tablosLoading ? t("common:actions.loading") : t("planning:selectTablo")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
|
|
@ -1192,17 +1112,15 @@ export const PlanningPage = () => {
|
|||
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer d'événement.",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (selectedTabloId === "all") {
|
||||
navigate(
|
||||
`/planning/create?date=${currentDate.toISOString()}`,
|
||||
);
|
||||
navigate(`/planning/create?date=${currentDate.toISOString()}`);
|
||||
} else {
|
||||
navigate(
|
||||
`/planning/create?tablo_id=${selectedTabloId}&date=${currentDate.toISOString()}`,
|
||||
`/planning/create?tablo_id=${selectedTabloId}&date=${currentDate.toISOString()}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
|
|
@ -1223,7 +1141,7 @@ export const PlanningPage = () => {
|
|||
"Vous êtes en mode lecture seule. Vous ne pouvez pas importer de calendrier.",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -1256,10 +1174,7 @@ export const PlanningPage = () => {
|
|||
</div>
|
||||
<div className="grid grid-cols-7 gap-1 text-xs">
|
||||
{dayNamesShort.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="text-center text-muted-foreground p-1"
|
||||
>
|
||||
<div key={day} className="text-center text-muted-foreground p-1">
|
||||
{day.slice(0, 1)}
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -1304,16 +1219,8 @@ export const PlanningPage = () => {
|
|||
{t("planning:today")}
|
||||
</Button>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => navigateDate(-1)}
|
||||
className="p-2 hover:bg-muted rounded"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<button onClick={() => navigateDate(-1)} className="p-2 hover:bg-muted rounded">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
|
@ -1322,16 +1229,8 @@ export const PlanningPage = () => {
|
|||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigateDate(1)}
|
||||
className="p-2 hover:bg-muted rounded"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<button onClick={() => navigateDate(1)} className="p-2 hover:bg-muted rounded">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
|
@ -1386,9 +1285,7 @@ export const PlanningPage = () => {
|
|||
alt="Loading..."
|
||||
className="animate-spin rounded-full h-8 w-8 object-cover"
|
||||
/>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{t("planning:loadingEvents")}
|
||||
</span>
|
||||
<span className="ml-2 text-muted-foreground">{t("planning:loadingEvents")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -1403,14 +1300,9 @@ export const PlanningPage = () => {
|
|||
|
||||
<Outlet />
|
||||
|
||||
{isImportModalOpen && (
|
||||
<ImportICSModal onClose={() => setIsImportModalOpen(false)} />
|
||||
)}
|
||||
{isImportModalOpen && <ImportICSModal onClose={() => setIsImportModalOpen(false)} />}
|
||||
|
||||
<WebcalModal
|
||||
open={isWebcalModalOpen}
|
||||
onOpenChange={setIsWebcalModalOpen}
|
||||
/>
|
||||
<WebcalModal open={isWebcalModalOpen} onOpenChange={setIsWebcalModalOpen} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { useIntroduction } from "../hooks/intros";
|
|||
import {
|
||||
useInviteOrganizationUser,
|
||||
useOrganization,
|
||||
useRemoveOrganizationMember,
|
||||
useUpdateOrganization,
|
||||
} from "../hooks/organization";
|
||||
import { useRemoveAvatar, useUpdateProfile, useUploadAvatar } from "../hooks/profile";
|
||||
|
|
@ -39,7 +40,7 @@ import { useUser } from "../providers/UserStoreProvider";
|
|||
|
||||
export default function SettingsPage() {
|
||||
const user = useUser();
|
||||
const { t } = useTranslation(["settings", "common"]);
|
||||
const { t, i18n } = useTranslation(["settings", "common"]);
|
||||
const {
|
||||
introduction,
|
||||
updateIntroduction,
|
||||
|
|
@ -55,6 +56,8 @@ export default function SettingsPage() {
|
|||
useUpdateOrganization();
|
||||
const { mutate: inviteOrganizationUser, isPending: inviteOrganizationUserPending } =
|
||||
useInviteOrganizationUser();
|
||||
const { mutate: removeOrganizationMember, isPending: removeOrganizationMemberPending } =
|
||||
useRemoveOrganizationMember();
|
||||
|
||||
const [firstName, setFirstName] = useState(user?.first_name || "");
|
||||
const [lastName, setLastName] = useState(user?.last_name || "");
|
||||
|
|
@ -66,6 +69,40 @@ export default function SettingsPage() {
|
|||
const [organizationName, setOrganizationName] = useState("");
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const displayedOrganizationPlan =
|
||||
organizationData?.organization?.plan === "annual"
|
||||
? "founder"
|
||||
: organizationData?.organization?.plan;
|
||||
const invitedByCurrentUser = organizationData?.invites_sent || [];
|
||||
const organizationMembers = (organizationData?.members || []).filter(
|
||||
(member) => member.id !== user?.id
|
||||
);
|
||||
const canManageMembers = organizationData?.is_billing_owner ?? false;
|
||||
|
||||
const getDisplayName = (input: {
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
}) => {
|
||||
const combinedName = [input.first_name, input.last_name].filter(Boolean).join(" ").trim();
|
||||
if (combinedName) {
|
||||
return combinedName;
|
||||
}
|
||||
|
||||
if (input.name) {
|
||||
return input.name;
|
||||
}
|
||||
|
||||
return input.email || t("settings:teamInvite.unknownUser");
|
||||
};
|
||||
|
||||
const formatDate = (date: string) =>
|
||||
new Date(date).toLocaleDateString(i18n.language || undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (organizationData?.organization?.name) {
|
||||
|
|
@ -329,7 +366,7 @@ export default function SettingsPage() {
|
|||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings:organization.currentPlan", {
|
||||
plan: organizationData?.organization?.plan || "solo",
|
||||
plan: displayedOrganizationPlan || "solo",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
|
@ -358,7 +395,7 @@ export default function SettingsPage() {
|
|||
<CardTitle>{t("settings:teamInvite.title")}</CardTitle>
|
||||
<CardDescription>{t("settings:teamInvite.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inviteOrganizationEmail">
|
||||
{t("settings:teamInvite.emailLabel")}
|
||||
|
|
@ -386,6 +423,104 @@ export default function SettingsPage() {
|
|||
: t("settings:teamInvite.invite")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">{t("settings:teamInvite.invitedByYouTitle")}</p>
|
||||
{invitedByCurrentUser.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings:teamInvite.noInvitesYet")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{invitedByCurrentUser.map((invite) => {
|
||||
const member = invite.invited_member;
|
||||
const displayName = getDisplayName({
|
||||
first_name: member?.first_name,
|
||||
last_name: member?.last_name,
|
||||
name: member?.name,
|
||||
email: member?.email || invite.invited_email,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={invite.id}
|
||||
className="flex items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{displayName}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{member?.email || invite.invited_email}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground shrink-0 ml-3">
|
||||
{t("settings:teamInvite.invitedOn", {
|
||||
date: formatDate(invite.created_at),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">{t("settings:teamInvite.membersTitle")}</p>
|
||||
{organizationMembers.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings:teamInvite.noOtherMembers")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{organizationMembers.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{getDisplayName({
|
||||
first_name: member.first_name,
|
||||
last_name: member.last_name,
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-3">
|
||||
<p className="text-xs text-muted-foreground shrink-0">
|
||||
{t("settings:teamInvite.joinedOn", {
|
||||
date: member.created_at
|
||||
? formatDate(member.created_at)
|
||||
: t("settings:teamInvite.unknownDate"),
|
||||
})}
|
||||
</p>
|
||||
{canManageMembers && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={removeOrganizationMemberPending}
|
||||
onClick={() => {
|
||||
const confirmed = window.confirm(
|
||||
t(
|
||||
"settings:teamInvite.removeConfirm",
|
||||
"Retirer ce membre de l'organisation ?"
|
||||
)
|
||||
);
|
||||
if (!confirmed) return;
|
||||
removeOrganizationMember(member.id);
|
||||
}}
|
||||
>
|
||||
{t("settings:teamInvite.remove", "Retirer")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,17 +5,12 @@ import { Button } from "@xtablo/ui/components/button";
|
|||
import { FieldError } from "@xtablo/ui/components/field";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
import { Label } from "@xtablo/ui/components/label";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
MonitorIcon,
|
||||
MoonIcon,
|
||||
SparklesIcon,
|
||||
SunIcon,
|
||||
} from "lucide-react";
|
||||
import { ArrowLeftIcon, MonitorIcon, MoonIcon, SparklesIcon, SunIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useSignUp } from "../hooks/auth";
|
||||
import type { SignupBillingIntent } from "../lib/billing";
|
||||
|
||||
export function SignUpV2Page() {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
|
|
@ -35,6 +30,7 @@ export function SignUpV2Page() {
|
|||
business_name: "",
|
||||
});
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [billingIntent, setBillingIntent] = useState<SignupBillingIntent>("trial");
|
||||
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
|
|
@ -101,6 +97,7 @@ export function SignUpV2Page() {
|
|||
last_name: formData.last_name,
|
||||
confirm_password: formData.confirmPassword,
|
||||
business_name: formData.business_name,
|
||||
billing_intent: billingIntent,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -132,27 +129,14 @@ export function SignUpV2Page() {
|
|||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<img
|
||||
src="/logo_dark.png"
|
||||
alt="Xtablo"
|
||||
className="h-10 w-auto block dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo_white.png"
|
||||
alt="Xtablo"
|
||||
className="h-10 w-auto hidden dark:block"
|
||||
/>
|
||||
<img src="/logo_dark.png" alt="Xtablo" className="h-10 w-auto block dark:hidden" />
|
||||
<img src="/logo_white.png" alt="Xtablo" className="h-10 w-auto hidden dark:block" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">
|
||||
{t("auth:signup.title")}
|
||||
</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("auth:signup.title")}</h1>
|
||||
<p className="text-sm text-muted-foreground mb-7">
|
||||
{t("auth:signup.alreadyAccount")}{" "}
|
||||
<Link
|
||||
to="/login-v2"
|
||||
className="text-[#804EEC] hover:text-[#6f3fd4] font-semibold"
|
||||
>
|
||||
<Link to="/login-v2" className="text-[#804EEC] hover:text-[#6f3fd4] font-semibold">
|
||||
{t("auth:signup.loginLink")}
|
||||
</Link>
|
||||
</p>
|
||||
|
|
@ -161,97 +145,76 @@ export function SignUpV2Page() {
|
|||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first_name">
|
||||
{t("auth:signup.firstName")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
{t("auth:signup.firstName")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
value={formData.first_name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, first_name: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
|
||||
required
|
||||
placeholder={t("auth:signup.firstNamePlaceholder")}
|
||||
className="h-11"
|
||||
/>
|
||||
{errors?.first_name && (
|
||||
<FieldError errors={[{ message: errors.first_name }]} />
|
||||
)}
|
||||
{errors?.first_name && <FieldError errors={[{ message: errors.first_name }]} />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last_name">
|
||||
{t("auth:signup.lastName")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
{t("auth:signup.lastName")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
value={formData.last_name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, last_name: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
|
||||
required
|
||||
placeholder={t("auth:signup.lastNamePlaceholder")}
|
||||
className="h-11"
|
||||
/>
|
||||
{errors?.last_name && (
|
||||
<FieldError errors={[{ message: errors.last_name }]} />
|
||||
)}
|
||||
{errors?.last_name && <FieldError errors={[{ message: errors.last_name }]} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">
|
||||
{t("auth:signup.email")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
{t("auth:signup.email")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, email: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
required
|
||||
placeholder={t("auth:signup.emailPlaceholder")}
|
||||
className="h-11"
|
||||
/>
|
||||
{errors?.email && (
|
||||
<FieldError errors={[{ message: errors.email }]} />
|
||||
)}
|
||||
{errors?.email && <FieldError errors={[{ message: errors.email }]} />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
{t("common:labels.password")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
{t("common:labels.password")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, password: e.target.value })
|
||||
}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
required
|
||||
placeholder={t("auth:signup.passwordPlaceholder")}
|
||||
className="h-11"
|
||||
/>
|
||||
{errors?.password && (
|
||||
<FieldError errors={[{ message: errors.password }]} />
|
||||
)}
|
||||
{errors?.password && <FieldError errors={[{ message: errors.password }]} />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">
|
||||
{t("auth:signup.confirmPassword")}{" "}
|
||||
<span className="text-red-500">*</span>
|
||||
{t("auth:signup.confirmPassword")} <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
|
|
@ -273,6 +236,40 @@ export function SignUpV2Page() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Plan</Label>
|
||||
<div className="grid gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingIntent("trial")}
|
||||
className={`w-full rounded-md border p-3 text-left transition-colors ${
|
||||
billingIntent === "trial"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium">Trial (14 jours)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Plan Solo ou Teams après la période d'essai.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingIntent("founder")}
|
||||
className={`w-full rounded-md border p-3 text-left transition-colors ${
|
||||
billingIntent === "founder"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium">Founder (99€/an)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paiement immédiat après la création du compte.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
|
|
@ -284,10 +281,7 @@ export function SignUpV2Page() {
|
|||
className="mt-1 h-4 w-4 rounded border border-border bg-background"
|
||||
required
|
||||
/>
|
||||
<Label
|
||||
htmlFor="terms"
|
||||
className="text-xs text-muted-foreground leading-relaxed"
|
||||
>
|
||||
<Label htmlFor="terms" className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("auth:signup.termsAccept")}{" "}
|
||||
<Link
|
||||
to="/legal-notice"
|
||||
|
|
@ -306,18 +300,14 @@ export function SignUpV2Page() {
|
|||
</Link>
|
||||
</Label>
|
||||
</div>
|
||||
{errors?.terms && (
|
||||
<FieldError errors={[{ message: errors.terms }]} />
|
||||
)}
|
||||
{errors?.terms && <FieldError errors={[{ message: errors.terms }]} />}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-11 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
|
||||
type="submit"
|
||||
>
|
||||
{isPending
|
||||
? t("auth:common.creatingAccount")
|
||||
: t("auth:signup.signupButton")}
|
||||
{isPending ? t("auth:common.creatingAccount") : t("auth:signup.signupButton")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
|
|
@ -358,9 +348,7 @@ export function SignUpV2Page() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-center mb-3">
|
||||
{t("auth:signup.asideTitle")}
|
||||
</h2>
|
||||
<h2 className="text-2xl font-bold text-center mb-3">{t("auth:signup.asideTitle")}</h2>
|
||||
<p className="text-center text-muted-foreground mb-8">
|
||||
{t("auth:signup.asideDescription")}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -193,10 +193,44 @@ describe("SignUpPage", () => {
|
|||
last_name: "Doe",
|
||||
confirm_password: "password123",
|
||||
business_name: "",
|
||||
billing_intent: "trial",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("submits founder billing intent when selected", async () => {
|
||||
renderWithProviders(<SignUpPage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /founder/i }));
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /create my account/i });
|
||||
const termsCheckbox = screen.getByRole("checkbox", { name: /i accept/i });
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/your first name/i), {
|
||||
target: { value: "John" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/your last name/i), {
|
||||
target: { value: "Doe" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/your email/i), {
|
||||
target: { value: "john@example.com" },
|
||||
});
|
||||
fireEvent.change(screen.getAllByPlaceholderText(/your password/i)[0], {
|
||||
target: { value: "password123" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/confirm your password/i), {
|
||||
target: { value: "password123" },
|
||||
});
|
||||
fireEvent.click(termsCheckbox);
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignUp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ billing_intent: "founder" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("requires terms checkbox to be checked", () => {
|
||||
renderWithProviders(<SignUpPage />);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ import { Label } from "@xtablo/ui/components/label";
|
|||
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useSignUp } from "../hooks/auth";
|
||||
import type { SignupBillingIntent } from "../lib/billing";
|
||||
|
||||
export function SignUpPage() {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
const navigate = useNavigate();
|
||||
const redirectUrl = localStorage.getItem("redirectUrl");
|
||||
const { mutate: signUp, isPending } = useSignUp({
|
||||
redirectUrl: redirectUrl ?? null,
|
||||
|
|
@ -31,6 +31,7 @@ export function SignUpPage() {
|
|||
business_name: "",
|
||||
});
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [billingIntent, setBillingIntent] = useState<SignupBillingIntent>("trial");
|
||||
|
||||
// 3D Parallax effect
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -137,14 +138,12 @@ export function SignUpPage() {
|
|||
last_name: formData.last_name,
|
||||
confirm_password: formData.confirmPassword,
|
||||
business_name: formData.business_name,
|
||||
billing_intent: billingIntent,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center bg-linear-to-br from-primary/10 via-background to-secondary/5 animate-gradient-x bg-size-[400%_400%] relative overflow-hidden"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-primary/10 via-background to-secondary/5 animate-gradient-x bg-size-[400%_400%] relative overflow-hidden">
|
||||
<AnimatedBackground />
|
||||
<div
|
||||
ref={cardRef}
|
||||
|
|
@ -155,7 +154,6 @@ export function SignUpPage() {
|
|||
style={{ transform }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Glow effect behind the card */}
|
||||
<div className="absolute inset-0 rounded-2xl bg-linear-to-br from-primary/10 via-primary/5 to-secondary/10 blur-xl -z-10"></div>
|
||||
|
|
@ -324,6 +322,42 @@ export function SignUpPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Plan</Label>
|
||||
<div className="grid gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingIntent("trial")}
|
||||
className={twMerge(
|
||||
"w-full rounded-md border p-3 text-left transition-colors",
|
||||
billingIntent === "trial"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium">Trial (14 jours)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Plan Solo ou Teams après la période d'essai.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingIntent("founder")}
|
||||
className={twMerge(
|
||||
"w-full rounded-md border p-3 text-left transition-colors",
|
||||
billingIntent === "founder"
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium">Founder (99€/an)</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paiement immédiat après la création du compte.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@ import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
|||
import { cn, toast } from "@xtablo/shared";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { Etape, KanbanTask } from "@xtablo/shared-types";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@xtablo/ui/components/avatar";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -43,12 +39,7 @@ import {
|
|||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Link,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { GanttChart } from "../components/gantt/GanttChart";
|
||||
import { TaskModal } from "../components/kanban/TaskModal";
|
||||
import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
|
||||
|
|
@ -58,10 +49,7 @@ import { TabloTasksSection } from "../components/TabloTasksSection";
|
|||
import { useTabloDiscussionUnread } from "../hooks/channel";
|
||||
import { useInviteUser } from "../hooks/invite";
|
||||
import { useTabloFileNames } from "../hooks/tablo_data";
|
||||
import {
|
||||
useCancelTabloInvite,
|
||||
usePendingTabloInvitesByTablo,
|
||||
} from "../hooks/tablo_invites";
|
||||
import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites";
|
||||
import { useTabloMembers, useTablosList } from "../hooks/tablos";
|
||||
import {
|
||||
useAllTasks,
|
||||
|
|
@ -176,9 +164,9 @@ export const TabloDetailsPage = () => {
|
|||
const { hasUnread: hasUnreadDiscussion } = useTabloDiscussionUnread(tabloId);
|
||||
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
const [taskModalInitialDueDate, setTaskModalInitialDueDate] = useState<
|
||||
Date | undefined
|
||||
>(undefined);
|
||||
const [taskModalInitialDueDate, setTaskModalInitialDueDate] = useState<Date | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [showAllOverviewTasks, setShowAllOverviewTasks] = useState(false);
|
||||
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
|
|
@ -186,8 +174,7 @@ export const TabloDetailsPage = () => {
|
|||
const currentUser = useUser();
|
||||
const { data: members } = useTabloMembers(tabloId ?? "");
|
||||
const { data: pendingInvites } = usePendingTabloInvitesByTablo(tabloId ?? "");
|
||||
const { mutate: cancelInvite, isPending: isCancellingInvite } =
|
||||
useCancelTabloInvite();
|
||||
const { mutate: cancelInvite, isPending: isCancellingInvite } = useCancelTabloInvite();
|
||||
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
|
||||
const { mutate: updateTask } = useUpdateTask();
|
||||
|
||||
|
|
@ -204,8 +191,7 @@ export const TabloDetailsPage = () => {
|
|||
};
|
||||
|
||||
const filteredMembers = members?.filter(
|
||||
(member) =>
|
||||
!pendingInvites?.some((invite) => invite.invited_email === member.email),
|
||||
(member) => !pendingInvites?.some((invite) => invite.invited_email === member.email)
|
||||
);
|
||||
|
||||
const openTaskModal = (dueDate?: Date) => {
|
||||
|
|
@ -235,11 +221,10 @@ export const TabloDetailsPage = () => {
|
|||
toast.add(
|
||||
{
|
||||
title: "Projet introuvable",
|
||||
description:
|
||||
"Le projet demandé n'existe pas ou vous n'y avez pas accès",
|
||||
description: "Le projet demandé n'existe pas ou vous n'y avez pas accès",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
navigate("/tablos");
|
||||
}
|
||||
|
|
@ -248,22 +233,16 @@ export const TabloDetailsPage = () => {
|
|||
|
||||
// Tasks for this tablo (used in overview)
|
||||
const { data: allTasks = [] } = useAllTasks();
|
||||
const tabloTasks = (allTasks as KanbanTask[]).filter(
|
||||
(t) => t.tablo_id === tabloId,
|
||||
);
|
||||
const tabloTasks = (allTasks as KanbanTask[]).filter((t) => t.tablo_id === tabloId);
|
||||
const myTabloTasks = tabloTasks.filter((task) => task.assignee_id === currentUser.id);
|
||||
const visibleOverviewTasks = showAllOverviewTasks
|
||||
? myTabloTasks
|
||||
: myTabloTasks.slice(0, 5);
|
||||
const visibleOverviewTasks = showAllOverviewTasks ? myTabloTasks : myTabloTasks.slice(0, 5);
|
||||
|
||||
// Etapes (parent tasks) for this tablo
|
||||
const { data: etapes = [] } = useTabloEtapes(tabloId);
|
||||
|
||||
// Files for this tablo (used in overview)
|
||||
const { data: filesData } = useTabloFileNames(tabloId ?? "");
|
||||
const fileNames = (filesData?.fileNames ?? []).filter(
|
||||
(f) => !f.startsWith("."),
|
||||
);
|
||||
const fileNames = (filesData?.fileNames ?? []).filter((f) => !f.startsWith("."));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -290,22 +269,16 @@ export const TabloDetailsPage = () => {
|
|||
<div
|
||||
className={cn(
|
||||
"w-12 h-12 rounded-lg flex items-center justify-center shrink-0 overflow-hidden",
|
||||
!tablo.image && (tablo.color || "bg-gray-400"),
|
||||
!tablo.image && (tablo.color || "bg-gray-400")
|
||||
)}
|
||||
>
|
||||
{tablo.image ? (
|
||||
<img
|
||||
src={tablo.image}
|
||||
alt={tablo.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<TabloIcon className={cn("w-6 h-6", iconColor)} />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-xl md:text-3xl font-bold text-foreground">
|
||||
{tablo.name}
|
||||
</h1>
|
||||
<h1 className="text-xl md:text-3xl font-bold text-foreground">{tablo.name}</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
|
@ -333,9 +306,7 @@ export const TabloDetailsPage = () => {
|
|||
<div className="flex flex-wrap items-center gap-6 text-sm border-b border-[#F2F4F7] dark:border-gray-700 pb-4 mb-4">
|
||||
<div className="flex items-center gap-2 md:border-r border-[#D0D5DD] dark:border-gray-600 pr-4">
|
||||
<span className="text-muted-foreground">Rôle :</span>
|
||||
<span className="text-foreground font-medium">
|
||||
{isAdmin ? "Admin" : "Invité"}
|
||||
</span>
|
||||
<span className="text-foreground font-medium">{isAdmin ? "Admin" : "Invité"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:border-r border-[#D0D5DD] dark:border-gray-600 pr-4">
|
||||
<span className="text-muted-foreground">Créé le :</span>
|
||||
|
|
@ -349,12 +320,7 @@ export const TabloDetailsPage = () => {
|
|||
</div>
|
||||
<div className="flex items-center gap-2 md:border-r border-[#D0D5DD] dark:border-gray-600 pr-4">
|
||||
<span className="text-muted-foreground">Statut :</span>
|
||||
<span
|
||||
className={cn(
|
||||
"px-3 py-1 rounded-full text-xs font-medium",
|
||||
badgeClass,
|
||||
)}
|
||||
>
|
||||
<span className={cn("px-3 py-1 rounded-full text-xs font-medium", badgeClass)}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -386,15 +352,13 @@ export const TabloDetailsPage = () => {
|
|||
key={tab.id}
|
||||
type="button"
|
||||
disabled={tab.disabled}
|
||||
onClick={() =>
|
||||
!tab.disabled && setSearchParams({ section: tab.id })
|
||||
}
|
||||
onClick={() => !tab.disabled && setSearchParams({ section: tab.id })}
|
||||
className={cn(
|
||||
"flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2",
|
||||
isActive
|
||||
? "text-[#804EEC] border-[#804EEC]"
|
||||
: "text-[#667085] border-transparent hover:text-gray-900 dark:hover:text-gray-100",
|
||||
tab.disabled && "opacity-40 cursor-not-allowed",
|
||||
tab.disabled && "opacity-40 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<span className="relative inline-flex">
|
||||
|
|
@ -428,9 +392,8 @@ export const TabloDetailsPage = () => {
|
|||
Description du projet
|
||||
</h2>
|
||||
<p className="text-muted-foreground leading-relaxed text-sm sm:text-base">
|
||||
Ce projet regroupe les tâches, fichiers et événements
|
||||
associés. Utilisez les onglets ci-dessus pour naviguer entre
|
||||
les différentes sections.
|
||||
Ce projet regroupe les tâches, fichiers et événements associés. Utilisez les
|
||||
onglets ci-dessus pour naviguer entre les différentes sections.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -487,7 +450,7 @@ export const TabloDetailsPage = () => {
|
|||
"text-sm font-medium truncate",
|
||||
task.status === "done"
|
||||
? "line-through text-gray-400"
|
||||
: "text-gray-900 dark:text-gray-100",
|
||||
: "text-gray-900 dark:text-gray-100"
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
|
|
@ -515,9 +478,7 @@ export const TabloDetailsPage = () => {
|
|||
{/* Files */}
|
||||
<div className="bg-white dark:bg-card rounded-xl border border-border p-5 sm:p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-foreground">
|
||||
Fichiers
|
||||
</h3>
|
||||
<h3 className="text-lg font-bold text-foreground">Fichiers</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchParams({ section: "files" })}
|
||||
|
|
@ -528,9 +489,7 @@ export const TabloDetailsPage = () => {
|
|||
</div>
|
||||
<div className="space-y-3">
|
||||
{fileNames.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Aucun fichier
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Aucun fichier</p>
|
||||
) : (
|
||||
fileNames.slice(0, 5).map((fileName) => (
|
||||
<div
|
||||
|
|
@ -541,9 +500,7 @@ export const TabloDetailsPage = () => {
|
|||
<FileTextIcon className="w-4 h-4 text-red-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground text-sm truncate">
|
||||
{fileName}
|
||||
</p>
|
||||
<p className="font-medium text-foreground text-sm truncate">{fileName}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -559,38 +516,25 @@ export const TabloDetailsPage = () => {
|
|||
|
||||
{/* Info */}
|
||||
<div className="bg-white dark:bg-card rounded-xl border border-border p-5 sm:p-6 shadow-sm">
|
||||
<h3 className="text-lg font-bold text-foreground mb-4">
|
||||
Informations
|
||||
</h3>
|
||||
<h3 className="text-lg font-bold text-foreground mb-4">Informations</h3>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-muted-foreground">Tâches</dt>
|
||||
<dd className="font-medium text-foreground">
|
||||
{tabloTasks.length}
|
||||
</dd>
|
||||
<dd className="font-medium text-foreground">{tabloTasks.length}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-muted-foreground">Fichiers</dt>
|
||||
<dd className="font-medium text-foreground">
|
||||
{fileNames.length}
|
||||
</dd>
|
||||
<dd className="font-medium text-foreground">{fileNames.length}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-muted-foreground">Statut</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-xs font-medium",
|
||||
badgeClass,
|
||||
)}
|
||||
>
|
||||
<dd className={cn("px-2 py-0.5 rounded-full text-xs font-medium", badgeClass)}>
|
||||
{statusLabel}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-muted-foreground">Rôle</dt>
|
||||
<dd className="font-medium text-foreground">
|
||||
{isAdmin ? "Admin" : "Invité"}
|
||||
</dd>
|
||||
<dd className="font-medium text-foreground">{isAdmin ? "Admin" : "Invité"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
|
@ -598,18 +542,12 @@ export const TabloDetailsPage = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === "tasks" && (
|
||||
<TabloTasksSection tablo={tablo} isAdmin={isAdmin} />
|
||||
)}
|
||||
{activeSection === "files" && (
|
||||
<TabloFilesSection tablo={tablo} isAdmin={isAdmin} />
|
||||
)}
|
||||
{activeSection === "tasks" && <TabloTasksSection tablo={tablo} isAdmin={isAdmin} />}
|
||||
{activeSection === "files" && <TabloFilesSection tablo={tablo} isAdmin={isAdmin} />}
|
||||
{activeSection === "discussion" && (
|
||||
<TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} />
|
||||
)}
|
||||
{activeSection === "events" && (
|
||||
<TabloEventsSection tablo={tablo} isAdmin={isAdmin} />
|
||||
)}
|
||||
{activeSection === "events" && <TabloEventsSection tablo={tablo} isAdmin={isAdmin} />}
|
||||
|
||||
{activeSection === "etapes" && (
|
||||
<EtapesSection
|
||||
|
|
@ -621,11 +559,7 @@ export const TabloDetailsPage = () => {
|
|||
)}
|
||||
|
||||
{activeSection === "roadmap" && (
|
||||
<RoadmapSection
|
||||
etapes={etapes}
|
||||
tabloTasks={tabloTasks}
|
||||
onDateClick={openTaskModal}
|
||||
/>
|
||||
<RoadmapSection etapes={etapes} tabloTasks={tabloTasks} onDateClick={openTaskModal} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -645,9 +579,7 @@ export const TabloDetailsPage = () => {
|
|||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Partager le projet</DialogTitle>
|
||||
<DialogDescription>
|
||||
Invitez des personnes à collaborer sur ce projet
|
||||
</DialogDescription>
|
||||
<DialogDescription>Invitez des personnes à collaborer sur ce projet</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
|
@ -737,18 +669,14 @@ export const TabloDetailsPage = () => {
|
|||
{filteredMembers.map((member) => {
|
||||
const avatarUrl =
|
||||
member.avatar_url ??
|
||||
(member.id === currentUser.id
|
||||
? currentUser.avatar_url
|
||||
: null);
|
||||
(member.id === currentUser.id ? currentUser.avatar_url : null);
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center space-x-2 p-2 bg-muted rounded-lg"
|
||||
>
|
||||
<Avatar className="w-8 h-8">
|
||||
{avatarUrl && (
|
||||
<AvatarImage src={avatarUrl} alt={member.name} />
|
||||
)}
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={member.name} />}
|
||||
<AvatarFallback className="bg-primary text-primary-foreground text-xs font-medium">
|
||||
{member.name.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
|
|
@ -788,16 +716,13 @@ function EtapesSection({
|
|||
isAdmin: boolean;
|
||||
}) {
|
||||
const [expandedEtapes, setExpandedEtapes] = useState<Set<string>>(
|
||||
new Set(etapes.map((e) => e.id)),
|
||||
);
|
||||
const [addingTaskToEtape, setAddingTaskToEtape] = useState<string | null>(
|
||||
null,
|
||||
new Set(etapes.map((e) => e.id))
|
||||
);
|
||||
const [addingTaskToEtape, setAddingTaskToEtape] = useState<string | null>(null);
|
||||
const [newEtapeTitle, setNewEtapeTitle] = useState("");
|
||||
const [newTaskTitle, setNewTaskTitle] = useState("");
|
||||
const { mutate: createTask } = useCreateTask();
|
||||
const { mutateAsync: createEtape, isPending: isCreatingEtape } =
|
||||
useCreateEtape();
|
||||
const { mutateAsync: createEtape, isPending: isCreatingEtape } = useCreateEtape();
|
||||
|
||||
const toggleEtape = (id: string) => {
|
||||
setExpandedEtapes((prev) => {
|
||||
|
|
@ -829,8 +754,7 @@ function EtapesSection({
|
|||
return;
|
||||
}
|
||||
|
||||
const nextPosition =
|
||||
etapes.reduce((max, etape) => Math.max(max, etape.position), -1) + 1;
|
||||
const nextPosition = etapes.reduce((max, etape) => Math.max(max, etape.position), -1) + 1;
|
||||
|
||||
await createEtape({
|
||||
tabloId,
|
||||
|
|
@ -848,18 +772,15 @@ function EtapesSection({
|
|||
},
|
||||
in_progress: {
|
||||
label: "En cours",
|
||||
color:
|
||||
"bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400",
|
||||
color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400",
|
||||
},
|
||||
in_review: {
|
||||
label: "Vérification",
|
||||
color:
|
||||
"bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400",
|
||||
color: "bg-purple-100 text-purple-700 dark:bg-purple-950/30 dark:text-purple-400",
|
||||
},
|
||||
done: {
|
||||
label: "Terminé",
|
||||
color:
|
||||
"bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400",
|
||||
color: "bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -891,241 +812,230 @@ function EtapesSection({
|
|||
{etapes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<ListChecksIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">
|
||||
Aucune étape
|
||||
</p>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">Aucune étape</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
|
||||
Les étapes permettent de structurer votre projet en grandes phases
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
etapes.map((etape, index) => {
|
||||
const childTasks = tabloTasks.filter(
|
||||
(t) => t.parent_task_id === etape.id,
|
||||
);
|
||||
const doneCount = childTasks.filter((t) => t.status === "done").length;
|
||||
const totalCount = childTasks.length;
|
||||
const progressPct =
|
||||
totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0;
|
||||
const isExpanded = expandedEtapes.has(etape.id);
|
||||
const childTasks = tabloTasks.filter((t) => t.parent_task_id === etape.id);
|
||||
const doneCount = childTasks.filter((t) => t.status === "done").length;
|
||||
const totalCount = childTasks.length;
|
||||
const progressPct = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : 0;
|
||||
const isExpanded = expandedEtapes.has(etape.id);
|
||||
|
||||
// Derive status from child tasks instead of etape.status
|
||||
const derivedStatus =
|
||||
totalCount === 0
|
||||
? "todo"
|
||||
: doneCount === totalCount
|
||||
? "done"
|
||||
: doneCount > 0
|
||||
? "in_progress"
|
||||
: "todo";
|
||||
const status = statusConfig[derivedStatus] ?? statusConfig.todo;
|
||||
// Derive status from child tasks instead of etape.status
|
||||
const derivedStatus =
|
||||
totalCount === 0
|
||||
? "todo"
|
||||
: doneCount === totalCount
|
||||
? "done"
|
||||
: doneCount > 0
|
||||
? "in_progress"
|
||||
: "todo";
|
||||
const status = statusConfig[derivedStatus] ?? statusConfig.todo;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={etape.id}
|
||||
className="bg-white dark:bg-card rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm overflow-hidden"
|
||||
>
|
||||
{/* Etape header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEtape(etape.id)}
|
||||
className="w-full flex items-center gap-4 px-5 py-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left"
|
||||
return (
|
||||
<div
|
||||
key={etape.id}
|
||||
className="bg-white dark:bg-card rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm overflow-hidden"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<ChevronRightIcon className="w-5 h-5 text-gray-400 shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="w-8 h-8 rounded-lg bg-[#F4F3FF] dark:bg-purple-900/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-sm font-bold text-[#7F56D9] dark:text-purple-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{etape.title}
|
||||
</h3>
|
||||
{etape.description && (
|
||||
<p className="text-sm text-muted-foreground truncate mt-0.5">
|
||||
{etape.description}
|
||||
</p>
|
||||
{/* Etape header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEtape(etape.id)}
|
||||
className="w-full flex items-center gap-4 px-5 py-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors text-left"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<ChevronRightIcon className="w-5 h-5 text-gray-400 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{etape.due_date && (
|
||||
<div
|
||||
<div className="w-8 h-8 rounded-lg bg-[#F4F3FF] dark:bg-purple-900/20 flex items-center justify-center shrink-0">
|
||||
<span className="text-sm font-bold text-[#7F56D9] dark:text-purple-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{etape.title}
|
||||
</h3>
|
||||
{etape.description && (
|
||||
<p className="text-sm text-muted-foreground truncate mt-0.5">
|
||||
{etape.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{etape.due_date && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs shrink-0",
|
||||
derivedStatus !== "done" &&
|
||||
new Date(etape.due_date) < new Date(new Date().toDateString())
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
<span>
|
||||
{new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}).format(new Date(etape.due_date))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs shrink-0",
|
||||
derivedStatus !== "done" &&
|
||||
new Date(etape.due_date) <
|
||||
new Date(new Date().toDateString())
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground",
|
||||
"px-2.5 py-1 rounded-full text-xs font-medium shrink-0",
|
||||
status.color
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
<span>
|
||||
{new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}).format(new Date(etape.due_date))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{status.label}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"px-2.5 py-1 rounded-full text-xs font-medium shrink-0",
|
||||
status.color,
|
||||
)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
|
||||
{totalCount > 0 && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-16 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full transition-all"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Child tasks + add task */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
{childTasks.length > 0 && (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{childTasks.map((task) => (
|
||||
{totalCount > 0 && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-16 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 px-5 py-3 pl-16 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{task.status === "done" ? (
|
||||
<CircleCheckIcon className="w-4 h-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm flex-1 truncate",
|
||||
task.status === "done"
|
||||
? "line-through text-gray-400"
|
||||
: "text-gray-900 dark:text-gray-100",
|
||||
)}
|
||||
className="h-full bg-green-500 rounded-full transition-all"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Child tasks + add task */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
{childTasks.length > 0 && (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{childTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 px-5 py-3 pl-16 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
{task.due_date && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs shrink-0",
|
||||
task.status !== "done" &&
|
||||
new Date(task.due_date) <
|
||||
new Date(new Date().toDateString())
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
<span>
|
||||
{new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}).format(new Date(task.due_date))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{task.status && (
|
||||
{task.status === "done" ? (
|
||||
<CircleCheckIcon className="w-4 h-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0",
|
||||
(statusConfig[task.status] ?? statusConfig.todo)
|
||||
.color,
|
||||
"text-sm flex-1 truncate",
|
||||
task.status === "done"
|
||||
? "line-through text-gray-400"
|
||||
: "text-gray-900 dark:text-gray-100"
|
||||
)}
|
||||
>
|
||||
{
|
||||
(statusConfig[task.status] ?? statusConfig.todo)
|
||||
.label
|
||||
}
|
||||
{task.title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{task.due_date && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-xs shrink-0",
|
||||
task.status !== "done" &&
|
||||
new Date(task.due_date) < new Date(new Date().toDateString())
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3 h-3" />
|
||||
<span>
|
||||
{new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}).format(new Date(task.due_date))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{task.status && (
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-[10px] font-medium shrink-0",
|
||||
(statusConfig[task.status] ?? statusConfig.todo).color
|
||||
)}
|
||||
>
|
||||
{(statusConfig[task.status] ?? statusConfig.todo).label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{childTasks.length === 0 && addingTaskToEtape !== etape.id && (
|
||||
<div className="px-5 py-4 pl-16 text-sm text-muted-foreground">
|
||||
Aucune tâche dans cette étape
|
||||
</div>
|
||||
)}
|
||||
{childTasks.length === 0 && addingTaskToEtape !== etape.id && (
|
||||
<div className="px-5 py-4 pl-16 text-sm text-muted-foreground">
|
||||
Aucune tâche dans cette étape
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline add task */}
|
||||
{addingTaskToEtape === etape.id ? (
|
||||
<div className="flex items-center gap-2 px-5 py-3 pl-16 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newTaskTitle}
|
||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAddTask(etape.id);
|
||||
if (e.key === "Escape") {
|
||||
{/* Inline add task */}
|
||||
{addingTaskToEtape === etape.id ? (
|
||||
<div className="flex items-center gap-2 px-5 py-3 pl-16 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-gray-300 dark:border-gray-600 shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newTaskTitle}
|
||||
onChange={(e) => setNewTaskTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAddTask(etape.id);
|
||||
if (e.key === "Escape") {
|
||||
setAddingTaskToEtape(null);
|
||||
setNewTaskTitle("");
|
||||
}
|
||||
}}
|
||||
placeholder="Nom de la tâche..."
|
||||
className="flex-1 text-sm bg-transparent border-none outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddTask(etape.id)}
|
||||
disabled={!newTaskTitle.trim()}
|
||||
className="text-xs font-medium px-3 py-1 rounded-md bg-[#804EEC] text-white hover:bg-[#6f3fd4] disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Ajouter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddingTaskToEtape(null);
|
||||
setNewTaskTitle("");
|
||||
}
|
||||
}}
|
||||
placeholder="Nom de la tâche..."
|
||||
className="flex-1 text-sm bg-transparent border-none outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400"
|
||||
/>
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddTask(etape.id)}
|
||||
disabled={!newTaskTitle.trim()}
|
||||
className="text-xs font-medium px-3 py-1 rounded-md bg-[#804EEC] text-white hover:bg-[#6f3fd4] disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Ajouter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddingTaskToEtape(null);
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddingTaskToEtape(etape.id);
|
||||
setNewTaskTitle("");
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-2 py-1"
|
||||
className="flex items-center gap-2 px-5 py-2.5 pl-16 text-sm text-muted-foreground hover:text-[#804EEC] hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors w-full text-left border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
Annuler
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Ajouter une tâche
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddingTaskToEtape(etape.id);
|
||||
setNewTaskTitle("");
|
||||
}}
|
||||
className="flex items-center gap-2 px-5 py-2.5 pl-16 text-sm text-muted-foreground hover:text-[#804EEC] hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors w-full text-left border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
Ajouter une tâche
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1148,9 +1058,7 @@ function RoadmapSection({
|
|||
tasks={tabloTasks}
|
||||
isLoading={false}
|
||||
onDateClick={onDateClick}
|
||||
onTaskStatusChange={(taskId, status) =>
|
||||
updateTask({ id: taskId, status })
|
||||
}
|
||||
onTaskStatusChange={(taskId, status) => updateTask({ id: taskId, status })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,7 @@ import {
|
|||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from "@xtablo/ui/components/empty";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@xtablo/ui/components/tooltip";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@xtablo/ui/components/tooltip";
|
||||
import { Text } from "@xtablo/ui/components/typography";
|
||||
import {
|
||||
CheckCircle2,
|
||||
|
|
@ -41,31 +37,38 @@ import {
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
useCanCreateTablo,
|
||||
useCreateTablo,
|
||||
useDeleteTablo,
|
||||
useTablosList,
|
||||
} from "../hooks/tablos";
|
||||
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
|
||||
import { DashboardActionCards } from "src/components/DashboardActionCards";
|
||||
import { DashboardTaskList } from "src/components/DashboardTaskList";
|
||||
import { InviteOrganizationModal } from "src/components/InviteOrganizationModal";
|
||||
import { TaskModal } from "src/components/kanban/TaskModal";
|
||||
import { ProjectCardList } from "src/components/ProjectCardList";
|
||||
import { useCanCreateTablo, useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos";
|
||||
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
|
||||
|
||||
function getTabloIcon(color: string | null | undefined) {
|
||||
switch (color) {
|
||||
case "bg-blue-500": return Zap;
|
||||
case "bg-green-500": return Leaf;
|
||||
case "bg-purple-500": return Gem;
|
||||
case "bg-red-500": return Flame;
|
||||
case "bg-yellow-500": return Star;
|
||||
case "bg-indigo-500": return Compass;
|
||||
case "bg-pink-500": return Heart;
|
||||
case "bg-teal-500": return Waves;
|
||||
case "bg-orange-500": return Sun;
|
||||
case "bg-cyan-500": return Sparkles;
|
||||
default: return FolderIcon;
|
||||
case "bg-blue-500":
|
||||
return Zap;
|
||||
case "bg-green-500":
|
||||
return Leaf;
|
||||
case "bg-purple-500":
|
||||
return Gem;
|
||||
case "bg-red-500":
|
||||
return Flame;
|
||||
case "bg-yellow-500":
|
||||
return Star;
|
||||
case "bg-indigo-500":
|
||||
return Compass;
|
||||
case "bg-pink-500":
|
||||
return Heart;
|
||||
case "bg-teal-500":
|
||||
return Waves;
|
||||
case "bg-orange-500":
|
||||
return Sun;
|
||||
case "bg-cyan-500":
|
||||
return Sparkles;
|
||||
default:
|
||||
return FolderIcon;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +94,7 @@ export const TabloPage = () => {
|
|||
} | null>(null);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
const [isInviteTeamModalOpen, setIsInviteTeamModalOpen] = useState(false);
|
||||
const [deletingTablo, setDeletingTablo] = useState<UserTablo | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [filterType] = useState<"all" | "todo" | "inProgress" | "done">("all");
|
||||
|
|
@ -119,7 +123,6 @@ export const TabloPage = () => {
|
|||
const viewMode = (searchParams.get("view") as "grid" | "list") || "list";
|
||||
const searchQuery = searchParams.get("q")?.toLowerCase() ?? "";
|
||||
|
||||
|
||||
const { data: tablos, isLoading, error } = useTablosList();
|
||||
const createTabloMutation = useCreateTablo();
|
||||
// const { mutateAsync: updateTablo } = useUpdateTablo();
|
||||
|
|
@ -133,8 +136,7 @@ export const TabloPage = () => {
|
|||
(filterType === "inProgress" && tablo.status === "inProgress") ||
|
||||
(filterType === "done" && tablo.status === "done");
|
||||
|
||||
const matchesSearch =
|
||||
!searchQuery || tablo.name.toLowerCase().includes(searchQuery);
|
||||
const matchesSearch = !searchQuery || tablo.name.toLowerCase().includes(searchQuery);
|
||||
|
||||
return matchesStatus && matchesSearch;
|
||||
});
|
||||
|
|
@ -146,8 +148,7 @@ export const TabloPage = () => {
|
|||
},
|
||||
{
|
||||
name: "Membres",
|
||||
action: (tabloId: string) =>
|
||||
navigate(`/tablos/${tabloId}?section=members`),
|
||||
action: (tabloId: string) => navigate(`/tablos/${tabloId}?section=members`),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -156,11 +157,10 @@ export const TabloPage = () => {
|
|||
toast.add(
|
||||
{
|
||||
title: t("common:error"),
|
||||
description:
|
||||
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer de projet.",
|
||||
description: "Vous êtes en mode lecture seule. Vous ne pouvez pas créer de projet.",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
@ -172,7 +172,7 @@ export const TabloPage = () => {
|
|||
};
|
||||
|
||||
const createNewTablo = async (
|
||||
tabloData: Pick<TabloInsert, "name" | "color" | "image" | "status">,
|
||||
tabloData: Pick<TabloInsert, "name" | "color" | "image" | "status">
|
||||
) => {
|
||||
try {
|
||||
await createTabloMutation.mutateAsync(tabloData);
|
||||
|
|
@ -241,9 +241,7 @@ export const TabloPage = () => {
|
|||
};
|
||||
|
||||
const getUserRole = (tablo: UserTablo) => {
|
||||
return tablo.is_admin
|
||||
? t("pages:tablo.role.admin")
|
||||
: t("pages:tablo.role.guest");
|
||||
return tablo.is_admin ? t("pages:tablo.role.admin") : t("pages:tablo.role.guest");
|
||||
};
|
||||
|
||||
const getRoleColor = (tablo: UserTablo) => {
|
||||
|
|
@ -256,14 +254,11 @@ export const TabloPage = () => {
|
|||
|
||||
const totalTablos = tablos.length;
|
||||
const todoCount = tablos.filter((t) => t.status === "todo").length;
|
||||
const inProgressCount = tablos.filter(
|
||||
(t) => t.status === "inProgress",
|
||||
).length;
|
||||
const inProgressCount = tablos.filter((t) => t.status === "inProgress").length;
|
||||
const doneCount = tablos.filter((t) => t.status === "done").length;
|
||||
const adminCount = tablos.filter((t) => t.is_admin).length;
|
||||
const guestCount = tablos.filter((t) => !t.is_admin).length;
|
||||
const completionRate =
|
||||
totalTablos > 0 ? Math.round((doneCount / totalTablos) * 100) : 0;
|
||||
const completionRate = totalTablos > 0 ? Math.round((doneCount / totalTablos) * 100) : 0;
|
||||
|
||||
return {
|
||||
totalTablos,
|
||||
|
|
@ -282,15 +277,9 @@ export const TabloPage = () => {
|
|||
const isCreateDisabled = createTabloMutation.isPending || isReadOnly;
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
id="create-tablo-button"
|
||||
onClick={openCreateModal}
|
||||
disabled={isCreateDisabled}
|
||||
>
|
||||
<Button id="create-tablo-button" onClick={openCreateModal} disabled={isCreateDisabled}>
|
||||
<Plus />
|
||||
{createTabloMutation.isPending
|
||||
? t("common:actions.saving")
|
||||
: t("pages:tablo.createButton")}
|
||||
{createTabloMutation.isPending ? t("common:actions.saving") : t("pages:tablo.createButton")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
|
|
@ -307,15 +296,9 @@ export const TabloPage = () => {
|
|||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isReadOnlyUser ? (
|
||||
<p>
|
||||
Vous ne pouvez pas créer de tablo car vous êtes en mode lecture
|
||||
seule.
|
||||
</p>
|
||||
<p>Vous ne pouvez pas créer de tablo car vous êtes en mode lecture seule.</p>
|
||||
) : (
|
||||
<p>
|
||||
Vous ne pouvez pas créer de tablo car vous avez atteint votre
|
||||
limite de tablos.
|
||||
</p>
|
||||
<p>Vous ne pouvez pas créer de tablo car vous avez atteint votre limite de tablos.</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -330,12 +313,8 @@ export const TabloPage = () => {
|
|||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">
|
||||
{t("pages:tablo.title")}
|
||||
</h1>
|
||||
<Text className="text-muted-foreground mt-1">
|
||||
{t("pages:tablo.subtitle")}
|
||||
</Text>
|
||||
<h1 className="text-3xl font-bold text-foreground">{t("pages:tablo.title")}</h1>
|
||||
<Text className="text-muted-foreground mt-1">{t("pages:tablo.subtitle")}</Text>
|
||||
</div>
|
||||
{createTabloButton()}
|
||||
</div>
|
||||
|
|
@ -358,12 +337,8 @@ export const TabloPage = () => {
|
|||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">
|
||||
{t("pages:tablo.title")}
|
||||
</h1>
|
||||
<Text className="text-muted-foreground mt-1">
|
||||
{t("pages:tablo.subtitle")}
|
||||
</Text>
|
||||
<h1 className="text-3xl font-bold text-foreground">{t("pages:tablo.title")}</h1>
|
||||
<Text className="text-muted-foreground mt-1">{t("pages:tablo.subtitle")}</Text>
|
||||
</div>
|
||||
<Button onClick={openCreateModal} disabled={isReadOnly}>
|
||||
<Plus /> Nouveau projet
|
||||
|
|
@ -374,13 +349,9 @@ export const TabloPage = () => {
|
|||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex justify-center items-center min-h-64">
|
||||
<div className="text-center">
|
||||
<p className="text-destructive mb-2">
|
||||
Erreur lors du chargement des projets
|
||||
</p>
|
||||
<p className="text-destructive mb-2">Erreur lors du chargement des projets</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Une erreur inconnue s'est produite"}
|
||||
{error instanceof Error ? error.message : "Une erreur inconnue s'est produite"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -408,9 +379,7 @@ export const TabloPage = () => {
|
|||
>
|
||||
<div
|
||||
className={`bg-card rounded-lg shadow-lg transition-all duration-300 w-56 overflow-hidden border border-border ${
|
||||
isAdmin
|
||||
? "hover:shadow-xl cursor-pointer"
|
||||
: "hover:shadow-xl cursor-pointer opacity-75"
|
||||
isAdmin ? "hover:shadow-xl cursor-pointer" : "hover:shadow-xl cursor-pointer opacity-75"
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -420,11 +389,7 @@ export const TabloPage = () => {
|
|||
{/* Image or Color */}
|
||||
<div className="relative h-40 group">
|
||||
{tablo.image ? (
|
||||
<img
|
||||
src={tablo.image}
|
||||
alt={tablo.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div
|
||||
className={`w-full h-full ${
|
||||
|
|
@ -447,21 +412,17 @@ export const TabloPage = () => {
|
|||
<div className="p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<h3 className="text-foreground font-semibold text-base truncate">
|
||||
{tablo.name}
|
||||
</h3>
|
||||
<h3 className="text-foreground font-semibold text-base truncate">{tablo.name}</h3>
|
||||
{/* Status badge */}
|
||||
<div
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusBadgeColor(
|
||||
tablo.status,
|
||||
tablo.status
|
||||
)} shrink-0`}
|
||||
>
|
||||
<span>{getStatusLabel(tablo.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-1 text-xs font-medium ${getRoleColor(tablo)}`}
|
||||
>
|
||||
<div className={`flex items-center gap-1 text-xs font-medium ${getRoleColor(tablo)}`}>
|
||||
<Shield className="w-3 h-3" />
|
||||
<span>{getUserRole(tablo)}</span>
|
||||
</div>
|
||||
|
|
@ -568,12 +529,8 @@ export const TabloPage = () => {
|
|||
<div className="bg-card rounded-lg shadow-md p-4 border border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Total
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
{kpis.totalTablos}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">Total</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">{kpis.totalTablos}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Users className="w-5 h-5 text-primary" />
|
||||
|
|
@ -588,9 +545,7 @@ export const TabloPage = () => {
|
|||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("pages:tablo.kpis.todo")}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
{kpis.todoCount}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">{kpis.todoCount}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
<ListTodo className="w-5 h-5 text-muted-foreground" />
|
||||
|
|
@ -622,9 +577,7 @@ export const TabloPage = () => {
|
|||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("pages:tablo.kpis.done")}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
{kpis.doneCount}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">{kpis.doneCount}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-secondary/50 rounded-lg">
|
||||
<CheckCircle2 className="w-5 h-5 text-secondary-foreground" />
|
||||
|
|
@ -656,9 +609,7 @@ export const TabloPage = () => {
|
|||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("pages:tablo.kpis.admin")}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
{kpis.adminCount}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">{kpis.adminCount}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
|
|
@ -673,9 +624,7 @@ export const TabloPage = () => {
|
|||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{t("pages:tablo.kpis.guest")}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">
|
||||
{kpis.guestCount}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">{kpis.guestCount}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
<Users className="w-5 h-5 text-muted-foreground" />
|
||||
|
|
@ -689,6 +638,7 @@ export const TabloPage = () => {
|
|||
<DashboardActionCards
|
||||
onCreateProject={openCreateModal}
|
||||
onCreateTask={() => setIsTaskModalOpen(true)}
|
||||
onInviteTeam={() => setIsInviteTeamModalOpen(true)}
|
||||
onSendMessage={() => navigate("/chat")}
|
||||
/>
|
||||
|
||||
|
|
@ -710,8 +660,7 @@ export const TabloPage = () => {
|
|||
<EmptyHeader>
|
||||
<EmptyTitle>{t("pages:tablo.emptyState.title")}</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{filterType === "all" &&
|
||||
t("pages:tablo.emptyState.description")}
|
||||
{filterType === "all" && t("pages:tablo.emptyState.description")}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
{filterType === "all" && (
|
||||
|
|
@ -735,10 +684,7 @@ export const TabloPage = () => {
|
|||
|
||||
{/* Create Tablo Modal */}
|
||||
{isCreateModalOpen && (
|
||||
<CreateTabloModal
|
||||
onClose={closeCreateModal}
|
||||
onCreate={createNewTablo}
|
||||
/>
|
||||
<CreateTabloModal onClose={closeCreateModal} onCreate={createNewTablo} />
|
||||
)}
|
||||
|
||||
{/* Delete Tablo Modal */}
|
||||
|
|
@ -759,6 +705,10 @@ export const TabloPage = () => {
|
|||
allowTabloSelection={true}
|
||||
initialStatus="todo"
|
||||
/>
|
||||
<InviteOrganizationModal
|
||||
isOpen={isInviteTeamModalOpen}
|
||||
onOpenChange={setIsInviteTeamModalOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import { cn } from "@xtablo/shared";
|
||||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import {
|
||||
CalendarIcon,
|
||||
Compass,
|
||||
|
|
@ -32,17 +32,28 @@ import { useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos";
|
|||
|
||||
function getTabloIcon(color: string | null | undefined) {
|
||||
switch (color) {
|
||||
case "bg-blue-500": return Zap;
|
||||
case "bg-green-500": return Leaf;
|
||||
case "bg-purple-500": return Gem;
|
||||
case "bg-red-500": return Flame;
|
||||
case "bg-yellow-500": return Star;
|
||||
case "bg-indigo-500": return Compass;
|
||||
case "bg-pink-500": return Heart;
|
||||
case "bg-teal-500": return Waves;
|
||||
case "bg-orange-500": return Sun;
|
||||
case "bg-cyan-500": return Sparkles;
|
||||
default: return FolderIcon;
|
||||
case "bg-blue-500":
|
||||
return Zap;
|
||||
case "bg-green-500":
|
||||
return Leaf;
|
||||
case "bg-purple-500":
|
||||
return Gem;
|
||||
case "bg-red-500":
|
||||
return Flame;
|
||||
case "bg-yellow-500":
|
||||
return Star;
|
||||
case "bg-indigo-500":
|
||||
return Compass;
|
||||
case "bg-pink-500":
|
||||
return Heart;
|
||||
case "bg-teal-500":
|
||||
return Waves;
|
||||
case "bg-orange-500":
|
||||
return Sun;
|
||||
case "bg-cyan-500":
|
||||
return Sparkles;
|
||||
default:
|
||||
return FolderIcon;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,13 +80,15 @@ function getStatusConfig(status: string) {
|
|||
case "done":
|
||||
return {
|
||||
label: "Terminé",
|
||||
badgeClass: "bg-green-50 text-green-600 border border-green-200 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800",
|
||||
badgeClass:
|
||||
"bg-green-50 text-green-600 border border-green-200 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800",
|
||||
progress: 100,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: "À faire",
|
||||
badgeClass: "bg-blue-50 text-blue-600 border border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800",
|
||||
badgeClass:
|
||||
"bg-blue-50 text-blue-600 border border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800",
|
||||
progress: 0,
|
||||
};
|
||||
}
|
||||
|
|
@ -118,7 +131,10 @@ function TabloCard({
|
|||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(tablo.id); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(tablo.id);
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
</button>
|
||||
|
|
@ -152,18 +168,28 @@ function TabloCard({
|
|||
{/* Progress */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">{t("tablo.card.progress")} :</span>
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{progress}%</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t("tablo.card.progress")} :
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div className="bg-green-500 h-2 rounded-full transition-all" style={{ width: `${progress}%` }} />
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-4 border-t border-dashed border-[#D0D5DD] dark:border-gray-600">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Créé le <span className="font-semibold text-gray-900 dark:text-gray-100">{formatDate(tablo.created_at)}</span>
|
||||
Créé le{" "}
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{formatDate(tablo.created_at)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -204,11 +230,15 @@ function TabloRow({
|
|||
<TabloIcon className={cn("w-4 h-4", iconColor)} />
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">{tablo.name}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{tablo.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={cn("px-3 py-1 rounded-full text-sm font-medium", badgeClass)}>{label}</span>
|
||||
<span className={cn("px-3 py-1 rounded-full text-sm font-medium", badgeClass)}>
|
||||
{label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
|
@ -219,16 +249,24 @@ function TabloRow({
|
|||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 min-w-[80px]">
|
||||
<div className="bg-green-500 h-2 rounded-full transition-all" style={{ width: `${progress}%` }} />
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 w-8 text-right">{progress}%</span>
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 w-8 text-right">
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors p-1 rounded"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(tablo.id); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(tablo.id);
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
</button>
|
||||
|
|
@ -351,7 +389,9 @@ export function TablosPage() {
|
|||
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
|
||||
<Grid3x3Icon className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">Aucun projet trouvé</p>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">
|
||||
Aucun projet trouvé
|
||||
</p>
|
||||
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
|
||||
{searchQuery ? "Essayez un autre terme de recherche" : "Créez votre premier projet"}
|
||||
</p>
|
||||
|
|
@ -372,10 +412,18 @@ export function TablosPage() {
|
|||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/80 border-b border-[#EAECF0] dark:border-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Projet</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Statut</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Créé le</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Progression</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
||||
Projet
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
||||
Statut
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
||||
Créé le
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
|
||||
Progression
|
||||
</th>
|
||||
<th className="px-6 py-3 w-12" />
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
|
|||
|
|
@ -114,15 +114,13 @@ export function TasksPage() {
|
|||
const [statusFilter, setStatusFilter] = useState<TaskStatus>("all");
|
||||
const [assigneeFilter, setAssigneeFilter] = useState<string>("all");
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
const [taskModalInitialDueDate, setTaskModalInitialDueDate] = useState<
|
||||
Date | undefined
|
||||
>(undefined);
|
||||
const [taskModalInitialDueDate, setTaskModalInitialDueDate] = useState<Date | undefined>(
|
||||
undefined
|
||||
);
|
||||
const searchQuery = searchParams.get("q") ?? "";
|
||||
|
||||
// Get view mode from URL params, default to "kanban"
|
||||
const viewMode =
|
||||
(searchParams.get("view") as "kanban" | "aggregated" | "roadmap") ||
|
||||
"kanban";
|
||||
const viewMode = (searchParams.get("view") as "kanban" | "aggregated" | "roadmap") || "kanban";
|
||||
|
||||
// Function to update view mode in URL
|
||||
const setViewMode = (mode: "kanban" | "aggregated" | "roadmap") => {
|
||||
|
|
@ -169,9 +167,7 @@ export function TasksPage() {
|
|||
} else if (assigneeFilter === "unassigned") {
|
||||
filtered = filtered.filter((task) => !task.assignee_id);
|
||||
} else {
|
||||
filtered = filtered.filter(
|
||||
(task) => task.assignee_id === assigneeFilter,
|
||||
);
|
||||
filtered = filtered.filter((task) => task.assignee_id === assigneeFilter);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -180,20 +176,12 @@ export function TasksPage() {
|
|||
const q = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(task) =>
|
||||
task.title?.toLowerCase().includes(q) ||
|
||||
task.description?.toLowerCase().includes(q),
|
||||
task.title?.toLowerCase().includes(q) || task.description?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [
|
||||
allTasks,
|
||||
selectedTabloId,
|
||||
statusFilter,
|
||||
assigneeFilter,
|
||||
user.id,
|
||||
searchQuery,
|
||||
]);
|
||||
}, [allTasks, selectedTabloId, statusFilter, assigneeFilter, user.id, searchQuery]);
|
||||
|
||||
// Initialize Kanban columns from filtered tasks
|
||||
const columns = useMemo((): KanbanColumn[] => {
|
||||
|
|
@ -264,7 +252,7 @@ export function TasksPage() {
|
|||
|
||||
const handleDrop = (
|
||||
e: React.DragEvent,
|
||||
targetStatus: "todo" | "in_progress" | "in_review" | "done",
|
||||
targetStatus: "todo" | "in_progress" | "in_review" | "done"
|
||||
) => {
|
||||
e.preventDefault();
|
||||
const taskId = e.dataTransfer.getData("taskId");
|
||||
|
|
@ -323,23 +311,18 @@ export function TasksPage() {
|
|||
type="button"
|
||||
disabled={tab.disabled}
|
||||
onClick={() =>
|
||||
!tab.disabled &&
|
||||
setViewMode(tab.id as "kanban" | "aggregated" | "roadmap")
|
||||
!tab.disabled && setViewMode(tab.id as "kanban" | "aggregated" | "roadmap")
|
||||
}
|
||||
className={twMerge(
|
||||
"flex items-center gap-2 pb-3 px-1 text-sm font-semibold transition-colors border-b-2",
|
||||
isActive
|
||||
? "text-purple-600 border-purple-600 dark:text-purple-400 dark:border-purple-400"
|
||||
: "text-[#667085] border-transparent hover:text-gray-900 dark:hover:text-gray-100",
|
||||
tab.disabled && "cursor-not-allowed",
|
||||
tab.disabled && "cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<tab.icon
|
||||
className={twMerge("w-4 h-4", tab.disabled && "opacity-40")}
|
||||
/>
|
||||
<span className={tab.disabled ? "opacity-40" : ""}>
|
||||
{tab.label}
|
||||
</span>
|
||||
<tab.icon className={twMerge("w-4 h-4", tab.disabled && "opacity-40")} />
|
||||
<span className={tab.disabled ? "opacity-40" : ""}>{tab.label}</span>
|
||||
{"comingSoon" in tab && tab.comingSoon && (
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 leading-none">
|
||||
Bientôt
|
||||
|
|
@ -354,10 +337,7 @@ export function TasksPage() {
|
|||
<div className="flex flex-col md:flex-row md:items-center md:justify-end gap-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full md:w-auto gap-2 bg-transparent"
|
||||
>
|
||||
<Button variant="outline" className="w-full md:w-auto gap-2 bg-transparent">
|
||||
<Settings2Icon className="w-4 h-4" />
|
||||
Filtrer
|
||||
</Button>
|
||||
|
|
@ -381,7 +361,7 @@ export function TasksPage() {
|
|||
<div
|
||||
className={twMerge(
|
||||
"w-2 h-2 rounded-full shrink-0",
|
||||
tablo.color || "bg-gray-400",
|
||||
tablo.color || "bg-gray-400"
|
||||
)}
|
||||
/>
|
||||
{tablo.name}
|
||||
|
|
@ -484,9 +464,7 @@ export function TasksPage() {
|
|||
{/* Column header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CircleIcon
|
||||
className={`w-5 h-5 ${columnIconColor}`}
|
||||
/>
|
||||
<CircleIcon className={`w-5 h-5 ${columnIconColor}`} />
|
||||
<h2 className="font-semibold text-gray-800 dark:text-gray-100">
|
||||
{column.title}
|
||||
</h2>
|
||||
|
|
@ -525,8 +503,7 @@ export function TasksPage() {
|
|||
const isOverdue =
|
||||
task.due_date &&
|
||||
task.status !== "done" &&
|
||||
new Date(task.due_date) <
|
||||
new Date(new Date().toDateString());
|
||||
new Date(task.due_date) < new Date(new Date().toDateString());
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -551,10 +528,7 @@ export function TasksPage() {
|
|||
<EllipsisVerticalIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-48"
|
||||
>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -564,17 +538,8 @@ export function TasksPage() {
|
|||
Ouvrir la tâche
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>
|
||||
Déplacer vers
|
||||
</DropdownMenuLabel>
|
||||
{(
|
||||
[
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
] as const
|
||||
)
|
||||
<DropdownMenuLabel>Déplacer vers</DropdownMenuLabel>
|
||||
{(["todo", "in_progress", "in_review", "done"] as const)
|
||||
.filter((s) => s !== task.status)
|
||||
.map((s) => (
|
||||
<DropdownMenuItem
|
||||
|
|
@ -600,7 +565,7 @@ export function TasksPage() {
|
|||
"flex items-center text-xs mb-3",
|
||||
isOverdue
|
||||
? "text-red-500"
|
||||
: "text-gray-500 dark:text-gray-400",
|
||||
: "text-gray-500 dark:text-gray-400"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5 mr-1.5" />
|
||||
|
|
@ -611,27 +576,17 @@ export function TasksPage() {
|
|||
{/* Tablo row */}
|
||||
{taskWithTablo.tablos &&
|
||||
(() => {
|
||||
const TabloIcon = getTabloIcon(
|
||||
taskWithTablo.tablos.color,
|
||||
);
|
||||
const iconColor = getTabloIconColor(
|
||||
taskWithTablo.tablos.color,
|
||||
);
|
||||
const TabloIcon = getTabloIcon(taskWithTablo.tablos.color);
|
||||
const iconColor = getTabloIconColor(taskWithTablo.tablos.color);
|
||||
return (
|
||||
<div className="flex items-center mb-3 border-b border-dashed border-[#D0D5DD] dark:border-gray-600 pb-3">
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-5 h-5 rounded-[5px] mr-2 flex items-center justify-center shrink-0",
|
||||
taskWithTablo.tablos.color ||
|
||||
"bg-gray-400",
|
||||
taskWithTablo.tablos.color || "bg-gray-400"
|
||||
)}
|
||||
>
|
||||
<TabloIcon
|
||||
className={twMerge(
|
||||
"w-3 h-3",
|
||||
iconColor,
|
||||
)}
|
||||
/>
|
||||
<TabloIcon className={twMerge("w-3 h-3", iconColor)} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||
{taskWithTablo.tablos.name}
|
||||
|
|
@ -644,12 +599,10 @@ export function TasksPage() {
|
|||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3 text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center text-xs">
|
||||
<MessageSquareIcon className="w-3.5 h-3.5 mr-1" />
|
||||
0
|
||||
<MessageSquareIcon className="w-3.5 h-3.5 mr-1" />0
|
||||
</div>
|
||||
<div className="flex items-center text-xs">
|
||||
<PaperclipIcon className="w-3.5 h-3.5 mr-1" />
|
||||
0
|
||||
<PaperclipIcon className="w-3.5 h-3.5 mr-1" />0
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -664,9 +617,7 @@ export function TasksPage() {
|
|||
/>
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-full bg-purple-500 border-2 border-white dark:border-gray-800 flex items-center justify-center text-white text-[10px] font-medium">
|
||||
{task.assignee_name
|
||||
?.charAt(0)
|
||||
.toUpperCase() || (
|
||||
{task.assignee_name?.charAt(0).toUpperCase() || (
|
||||
<UserIcon className="w-3 h-3" />
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -728,9 +679,7 @@ export function TasksPage() {
|
|||
{/* Column header */}
|
||||
<div className="px-4 md:px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleIcon
|
||||
className={`w-5 h-5 ${columnIconColor}`}
|
||||
/>
|
||||
<CircleIcon className={`w-5 h-5 ${columnIconColor}`} />
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{column.title}
|
||||
</h3>
|
||||
|
|
@ -801,26 +750,20 @@ export function TasksPage() {
|
|||
<td className="px-4 md:px-6 py-3">
|
||||
{taskWithTablo.tablos ? (
|
||||
(() => {
|
||||
const TabloIcon = getTabloIcon(
|
||||
taskWithTablo.tablos.color,
|
||||
);
|
||||
const TabloIcon = getTabloIcon(taskWithTablo.tablos.color);
|
||||
const iconColor = getTabloIconColor(
|
||||
taskWithTablo.tablos.color,
|
||||
taskWithTablo.tablos.color
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-5 h-5 rounded-[4px] shrink-0 flex items-center justify-center",
|
||||
taskWithTablo.tablos.color ||
|
||||
"bg-gray-400",
|
||||
taskWithTablo.tablos.color || "bg-gray-400"
|
||||
)}
|
||||
>
|
||||
<TabloIcon
|
||||
className={twMerge(
|
||||
"w-3 h-3",
|
||||
iconColor,
|
||||
)}
|
||||
className={twMerge("w-3 h-3", iconColor)}
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate">
|
||||
|
|
@ -830,9 +773,7 @@ export function TasksPage() {
|
|||
);
|
||||
})()
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">
|
||||
—
|
||||
</span>
|
||||
<span className="text-sm text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
|
|
@ -850,26 +791,21 @@ export function TasksPage() {
|
|||
"flex items-center gap-1 text-sm",
|
||||
dueDateOverdue
|
||||
? "text-red-500"
|
||||
: "text-gray-600 dark:text-gray-400",
|
||||
: "text-gray-600 dark:text-gray-400"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
<span>
|
||||
{new Intl.DateTimeFormat(
|
||||
"fr-FR",
|
||||
{
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
},
|
||||
).format(new Date(task.due_date))}
|
||||
{new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}).format(new Date(task.due_date))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<span className="text-sm text-gray-400">
|
||||
—
|
||||
</span>
|
||||
<span className="text-sm text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
|
|
@ -889,9 +825,7 @@ export function TasksPage() {
|
|||
title={task.assignee_name || ""}
|
||||
className="w-6 h-6 rounded-full bg-purple-200 dark:bg-purple-900 text-purple-700 dark:text-purple-300 flex items-center justify-center text-xs font-semibold border border-white dark:border-gray-800"
|
||||
>
|
||||
{task.assignee_name
|
||||
?.charAt(0)
|
||||
.toUpperCase() || (
|
||||
{task.assignee_name?.charAt(0).toUpperCase() || (
|
||||
<UserIcon className="w-3 h-3" />
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -916,10 +850,7 @@ export function TasksPage() {
|
|||
<EllipsisVerticalIcon className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-48"
|
||||
>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -929,17 +860,8 @@ export function TasksPage() {
|
|||
Ouvrir la tâche
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>
|
||||
Déplacer vers
|
||||
</DropdownMenuLabel>
|
||||
{(
|
||||
[
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
] as const
|
||||
)
|
||||
<DropdownMenuLabel>Déplacer vers</DropdownMenuLabel>
|
||||
{(["todo", "in_progress", "in_review", "done"] as const)
|
||||
.filter((s) => s !== task.status)
|
||||
.map((s) => (
|
||||
<DropdownMenuItem
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ export interface EtapeProgressStats {
|
|||
export function getEtapeProgressStats(etapes: Etape[]): EtapeProgressStats {
|
||||
const total = etapes.length;
|
||||
const done = etapes.filter((etape) => etape.status === "done").length;
|
||||
const started = etapes.filter((etape) => STARTED_ETAPE_STATUSES.has(etape.status ?? "todo")).length;
|
||||
const started = etapes.filter((etape) =>
|
||||
STARTED_ETAPE_STATUSES.has(etape.status ?? "todo")
|
||||
).length;
|
||||
|
||||
if (total === 0) {
|
||||
return {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -48,14 +48,14 @@ export type {
|
|||
UserSubscriptionStatus,
|
||||
} from "./stripe.types.js";
|
||||
// ============================================================================
|
||||
// Tablo Types
|
||||
// ============================================================================
|
||||
export type { CreateTablo, Tablo, TabloInsert, TabloUpdate, UserTablo } from "./tablos.types.js";
|
||||
// ============================================================================
|
||||
// Tablo Data Types (Files and Folders)
|
||||
// ============================================================================
|
||||
export type { TabloFolder, TabloFoldersMetadata } from "./tablo-data.types.js";
|
||||
// ============================================================================
|
||||
// Tablo Types
|
||||
// ============================================================================
|
||||
export type { CreateTablo, Tablo, TabloInsert, TabloUpdate, UserTablo } from "./tablos.types.js";
|
||||
// ============================================================================
|
||||
// Utility Types
|
||||
// ============================================================================
|
||||
export type {
|
||||
|
|
|
|||
|
|
@ -20,4 +20,3 @@ export interface TabloFoldersMetadata {
|
|||
folders: TabloFolder[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
-- Extend Stripe subscription -> profile.plan mapping so Founder plans
|
||||
-- are normalized to internal annual semantics.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.update_profile_subscription_status() RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id uuid;
|
||||
v_customer_id text;
|
||||
v_plan_hint text;
|
||||
v_has_paid_subscription boolean := FALSE;
|
||||
v_plan public.subscription_plan := 'solo';
|
||||
BEGIN
|
||||
-- Resolve customer id from the changed stripe row
|
||||
IF TG_TABLE_NAME = 'subscriptions' THEN
|
||||
v_customer_id := NEW.customer;
|
||||
ELSIF TG_TABLE_NAME = 'subscription_items' THEN
|
||||
SELECT s.customer
|
||||
INTO v_customer_id
|
||||
FROM stripe.subscriptions s
|
||||
WHERE s.id = NEW.subscription;
|
||||
ELSE
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF v_customer_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Resolve application user id from Stripe customer metadata
|
||||
SELECT (c.metadata->>'user_id')::uuid
|
||||
INTO v_user_id
|
||||
FROM stripe.customers c
|
||||
WHERE c.id = v_customer_id;
|
||||
|
||||
IF v_user_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Pick the best active/trialing subscription for this user and infer plan from
|
||||
-- price/product metadata or lookup key. Fall back to Team when paid but unmapped.
|
||||
WITH candidate_subscriptions AS (
|
||||
SELECT
|
||||
s.status::text AS status,
|
||||
lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) AS plan_hint,
|
||||
CASE s.status::text
|
||||
WHEN 'active' THEN 3
|
||||
WHEN 'past_due' THEN 2
|
||||
WHEN 'trialing' THEN 1
|
||||
ELSE 0
|
||||
END AS status_weight,
|
||||
CASE
|
||||
WHEN lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) LIKE '%founder%' OR lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) LIKE '%annual%' OR lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) LIKE '%beta%' THEN 3
|
||||
WHEN lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) LIKE '%team%' OR lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) LIKE '%standard%' THEN 2
|
||||
WHEN lower(
|
||||
coalesce(
|
||||
NULLIF(pr.metadata->>'plan', ''),
|
||||
NULLIF(pr.lookup_key, ''),
|
||||
NULLIF(pd.metadata->>'plan', ''),
|
||||
NULLIF(pd.name, '')
|
||||
)
|
||||
) LIKE '%solo%' THEN 1
|
||||
ELSE 0
|
||||
END AS plan_weight
|
||||
FROM stripe.subscriptions s
|
||||
JOIN stripe.customers c
|
||||
ON c.id = s.customer
|
||||
LEFT JOIN stripe.subscription_items si
|
||||
ON si.subscription = s.id
|
||||
LEFT JOIN stripe.prices pr
|
||||
ON pr.id = si.price
|
||||
LEFT JOIN stripe.products pd
|
||||
ON pd.id = pr.product
|
||||
WHERE (c.metadata->>'user_id')::uuid = v_user_id
|
||||
AND s.status::text IN ('active', 'past_due', 'trialing')
|
||||
AND (
|
||||
si.current_period_end IS NULL
|
||||
OR to_timestamp(si.current_period_end) > now()
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
TRUE,
|
||||
cs.plan_hint
|
||||
INTO v_has_paid_subscription, v_plan_hint
|
||||
FROM candidate_subscriptions cs
|
||||
ORDER BY cs.plan_weight DESC, cs.status_weight DESC
|
||||
LIMIT 1;
|
||||
|
||||
IF v_has_paid_subscription THEN
|
||||
IF v_plan_hint LIKE '%founder%' OR v_plan_hint LIKE '%annual%' OR v_plan_hint LIKE '%beta%' THEN
|
||||
v_plan := 'annual';
|
||||
ELSIF v_plan_hint LIKE '%team%' OR v_plan_hint LIKE '%standard%' THEN
|
||||
v_plan := 'team';
|
||||
ELSIF v_plan_hint LIKE '%solo%' OR v_plan_hint LIKE '%free%' THEN
|
||||
v_plan := 'solo';
|
||||
ELSE
|
||||
-- paid but unmapped => Team by default (backward compatibility)
|
||||
v_plan := 'team';
|
||||
END IF;
|
||||
ELSE
|
||||
v_plan := 'solo';
|
||||
END IF;
|
||||
|
||||
UPDATE public.profiles
|
||||
SET plan = v_plan
|
||||
WHERE id = v_user_id;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER FUNCTION public.update_profile_subscription_status() OWNER TO postgres;
|
||||
|
||||
COMMENT ON FUNCTION public.update_profile_subscription_status() IS
|
||||
'Maps Stripe subscription state to profile.plan and normalizes founder/annual to annual.';
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
-- Track organization invitations sent by members so Settings can display invite history.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.organization_invites (
|
||||
id bigserial PRIMARY KEY,
|
||||
organization_id integer NOT NULL,
|
||||
invited_by uuid NOT NULL,
|
||||
invited_email character varying(255) NOT NULL,
|
||||
invited_user_id uuid,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.organization_invites
|
||||
DROP CONSTRAINT IF EXISTS organization_invites_organization_id_fkey;
|
||||
|
||||
ALTER TABLE public.organization_invites
|
||||
ADD CONSTRAINT organization_invites_organization_id_fkey
|
||||
FOREIGN KEY (organization_id)
|
||||
REFERENCES public.organizations(id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE public.organization_invites
|
||||
DROP CONSTRAINT IF EXISTS organization_invites_invited_by_fkey;
|
||||
|
||||
ALTER TABLE public.organization_invites
|
||||
ADD CONSTRAINT organization_invites_invited_by_fkey
|
||||
FOREIGN KEY (invited_by)
|
||||
REFERENCES public.profiles(id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE public.organization_invites
|
||||
DROP CONSTRAINT IF EXISTS organization_invites_invited_user_id_fkey;
|
||||
|
||||
ALTER TABLE public.organization_invites
|
||||
ADD CONSTRAINT organization_invites_invited_user_id_fkey
|
||||
FOREIGN KEY (invited_user_id)
|
||||
REFERENCES public.profiles(id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS organization_invites_organization_id_idx
|
||||
ON public.organization_invites (organization_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS organization_invites_invited_by_idx
|
||||
ON public.organization_invites (invited_by);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS organization_invites_created_at_idx
|
||||
ON public.organization_invites (created_at DESC);
|
||||
|
||||
COMMENT ON TABLE public.organization_invites IS 'History of organization invitations sent by members.';
|
||||
COMMENT ON COLUMN public.organization_invites.organization_id IS 'Organization where the invite was sent.';
|
||||
COMMENT ON COLUMN public.organization_invites.invited_by IS 'Profile ID of the member who sent the invite.';
|
||||
COMMENT ON COLUMN public.organization_invites.invited_email IS 'Email that was invited.';
|
||||
COMMENT ON COLUMN public.organization_invites.invited_user_id IS 'Profile ID created for the invited email, when available.';
|
||||
|
||||
ALTER TABLE public.organization_invites ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "Users can view organization invites they sent" ON public.organization_invites;
|
||||
CREATE POLICY "Users can view organization invites they sent"
|
||||
ON public.organization_invites
|
||||
FOR SELECT
|
||||
USING (
|
||||
organization_id = public.current_user_organization_id()
|
||||
AND invited_by = auth.uid()
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Users can insert organization invites they sent" ON public.organization_invites;
|
||||
CREATE POLICY "Users can insert organization invites they sent"
|
||||
ON public.organization_invites
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
organization_id = public.current_user_organization_id()
|
||||
AND invited_by = auth.uid()
|
||||
);
|
||||
|
||||
GRANT SELECT, INSERT ON public.organization_invites TO authenticated;
|
||||
GRANT USAGE, SELECT ON SEQUENCE public.organization_invites_id_seq TO authenticated;
|
||||
Loading…
Reference in a new issue