diff --git a/.cursor/md/example-export-pb-schema.md b/.cursor/md/example-export-pb-schema.md new file mode 100644 index 0000000..8f2ab85 --- /dev/null +++ b/.cursor/md/example-export-pb-schema.md @@ -0,0 +1,1020 @@ +# pb schema + +```json +[ + { + "id": "pbc_647898912", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "ActivityLog", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2387082370", + "hidden": false, + "id": "relation2106360836", + "maxSelect": 1, + "minSelect": 0, + "name": "organization", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "hidden": false, + "id": "relation1689669068", + "maxSelect": 1, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_156890547", + "hidden": false, + "id": "relation3433725209", + "maxSelect": 1, + "minSelect": 0, + "name": "equipment", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "json1326724116", + "maxSize": 0, + "name": "metadata", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_879879449", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "AppUser", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3885137012", + "max": 0, + "min": 0, + "name": "email", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "bool1547992806", + "name": "emailVisibility", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "hidden": false, + "id": "bool256245529", + "name": "verified", + "presentable": false, + "required": false, + "system": false, + "type": "bool" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "date2697416787", + "max": "", + "min": "", + "name": "lastLogin", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3875972033", + "max": 0, + "min": 0, + "name": "clerkId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "json1326724116", + "maxSize": 0, + "name": "metadata", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_461999422", + "hidden": false, + "id": "relation1115430015", + "maxSelect": 999, + "minSelect": 0, + "name": "organizations", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_Tg6zFyQpbw` ON `AppUser` (`clerkId`)" + ], + "system": false + }, + { + "id": "pbc_2166913018", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "Assignment", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2387082370", + "hidden": false, + "id": "relation2106360836", + "maxSelect": 1, + "minSelect": 0, + "name": "organization", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_156890547", + "hidden": false, + "id": "relation3433725209", + "maxSelect": 1, + "minSelect": 0, + "name": "equipment", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "hidden": false, + "id": "relation1706602226", + "maxSelect": 1, + "minSelect": 0, + "name": "assignedToUser", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1901958808", + "hidden": false, + "id": "relation3498911044", + "maxSelect": 1, + "minSelect": 0, + "name": "assignedToProject", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "date1269603864", + "max": "", + "min": "", + "name": "startDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "date826688707", + "max": "", + "min": "", + "name": "endDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "convertURLs": false, + "hidden": false, + "id": "editor18589324", + "maxSize": 0, + "name": "notes", + "presentable": false, + "required": false, + "system": false, + "type": "editor" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_156890547", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "Equipment", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2387082370", + "hidden": false, + "id": "relation2106360836", + "maxSelect": 1, + "minSelect": 0, + "name": "organization", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text231537297", + "max": 0, + "min": 0, + "name": "qrNfcCode", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1874629670", + "max": 0, + "min": 0, + "name": "tags", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "convertURLs": false, + "hidden": false, + "id": "editor18589324", + "maxSize": 0, + "name": "notes", + "presentable": false, + "required": false, + "system": false, + "type": "editor" + }, + { + "hidden": false, + "id": "date58351749", + "max": "", + "min": "", + "name": "acquisitionDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_156890547", + "hidden": false, + "id": "relation1849591526", + "maxSelect": 1, + "minSelect": 0, + "name": "parentEquipment", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_3500197394", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "Images", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text724990059", + "max": 0, + "min": 0, + "name": "title", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text678750603", + "max": 0, + "min": 0, + "name": "alt", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text4135340389", + "max": 0, + "min": 0, + "name": "caption", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "file3309110367", + "maxSelect": 1, + "maxSize": 0, + "mimeTypes": [], + "name": "image", + "presentable": false, + "protected": false, + "required": false, + "system": false, + "thumbs": [], + "type": "file" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_2387082370", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "Organization", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3885137012", + "max": 0, + "min": 0, + "name": "email", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1146066909", + "max": 0, + "min": 0, + "name": "phone", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text223244161", + "max": 0, + "min": 0, + "name": "address", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "json3846545605", + "maxSize": 0, + "name": "settings", + "presentable": false, + "required": false, + "system": false, + "type": "json" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3875972033", + "max": 0, + "min": 0, + "name": "clerkId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1278432162", + "max": 0, + "min": 0, + "name": "stripeCustomerId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text3396850601", + "max": 0, + "min": 0, + "name": "subscriptionId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text212635077", + "max": 0, + "min": 0, + "name": "subscriptionStatus", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text2938615432", + "max": 0, + "min": 0, + "name": "priceId", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_MHRKs66UDJ` ON `Organization` (`clerkId`)" + ], + "system": false + }, + { + "id": "pbc_461999422", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "OrganizationAppUser", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2387082370", + "hidden": false, + "id": "relation3253625724", + "maxSelect": 1, + "minSelect": 0, + "name": "organization", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_879879449", + "hidden": false, + "id": "relation1320735562", + "maxSelect": 1, + "minSelect": 0, + "name": "appUser", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1466534506", + "max": 0, + "min": 0, + "name": "role", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_QDEdzobtpm` ON `OrganizationAppUser` (\n `organization`,\n `appUser`\n)" + ], + "system": false + }, + { + "id": "pbc_1901958808", + "listRule": null, + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "Project", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text223244161", + "max": 0, + "min": 0, + "name": "address", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "convertURLs": false, + "hidden": false, + "id": "editor18589324", + "maxSize": 0, + "name": "notes", + "presentable": false, + "required": false, + "system": false, + "type": "editor" + }, + { + "hidden": false, + "id": "date1269603864", + "max": "", + "min": "", + "name": "startDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "hidden": false, + "id": "date826688707", + "max": "", + "min": "", + "name": "endDate", + "presentable": false, + "required": false, + "system": false, + "type": "date" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_2387082370", + "hidden": false, + "id": "relation2106360836", + "maxSelect": 1, + "minSelect": 0, + "name": "organization", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + } +] +``` diff --git a/.cursor/md/example-webhook-clerk.md b/.cursor/md/example-webhook-clerk.md new file mode 100644 index 0000000..f9618be --- /dev/null +++ b/.cursor/md/example-webhook-clerk.md @@ -0,0 +1,325 @@ +# Organization + +```json +{ + "data": { + "created_at": 1654013202977, + "created_by": "user_1vq84bqWzw7qmFgqSwN4CH1Wp0n", + "id": "org_29w9IfBrPmcpi0IeBVaKtA7R94W", + "image_url": "https://img.clerk.com/xxxxxx", + "logo_url": "https://example.org/example.png", + "name": "Acme Inc", + "object": "organization", + "public_metadata": {}, + "slug": "acme-inc", + "updated_at": 1654013202977 + }, + "event_attributes": { + "http_request": { + "client_ip": "0.0.0.0", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + } + }, + "object": "event", + "timestamp": 1654013202977, + "type": "organization.created" +} +``` + +``` +{ + "data": { + "deleted": true, + "id": "org_29w9IfBrPmcpi0IeBVaKtA7R94W", + "object": "organization" + }, + "event_attributes": { + "http_request": { + "client_ip": "", + "user_agent": "" + } + }, + "object": "event", + "timestamp": 1661861640000, + "type": "organization.deleted" +} +``` + +``` +{ + "data": { + "created_at": 1654013202977, + "created_by": "user_1vq84bqWzw7qmFgqSwN4CH1Wp0n", + "id": "org_29w9IfBrPmcpi0IeBVaKtA7R94W", + "image_url": "https://img.clerk.com/xxxxxx", + "logo_url": "https://example.com/example.png", + "name": "Acme Inc", + "object": "organization", + "public_metadata": {}, + "slug": "acme-inc", + "updated_at": 1654013466465 + }, + "event_attributes": { + "http_request": { + "client_ip": "", + "user_agent": "" + } + }, + "object": "event", + "timestamp": 1654013466465, + "type": "organization.updated" +} +``` + +# Organization Membership + +``` +{ + "data": { + "created_at": 1654013203217, + "id": "orgmem_29w9IptNja3mP8GDXpquBwN2qR9", + "object": "organization_membership", + "organization": { + "created_at": 1654013202977, + "created_by": "user_1vq84bqWzw7qmFgqSwN4CH1Wp0n", + "id": "org_29w9IfBrPmcpi0IeBVaKtA7R94W", + "image_url": "https://img.clerk.com/xxxxxx", + "logo_url": "https://example.com/example.png", + "name": "Acme Inc", + "object": "organization", + "public_metadata": {}, + "slug": "acme-inc", + "updated_at": 1654013202977 + }, + "public_user_data": { + "first_name": "Example", + "identifier": "example@example.org", + "image_url": "https://img.clerk.com/xxxxxx", + "last_name": "Example", + "profile_image_url": "https://www.gravatar.com/avatar?d=mp", + "user_id": "user_29w83sxmDNGwOuEthce5gg56FcC" + }, + "role": "admin", + "updated_at": 1654013203217 + }, + "event_attributes": { + "http_request": { + "client_ip": "0.0.0.0", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + } + }, + "object": "event", + "timestamp": 1654013203217, + "type": "organizationMembership.created" +} +``` + +``` +{ + "data": { + "created_at": 1654013847054, + "id": "orgmem_29wAbjiJs6aZuPq7AzmkW9dwmyl", + "object": "organization_membership", + "organization": { + "created_at": 1654013202977, + "created_by": "user_1vq84bqWzw7qmFgqSwN4CH1Wp0n", + "id": "org_29w9IfBrPmcpi0IeBVaKtA7R94W", + "image_url": "https://img.clerk.com/xxxxxx", + "logo_url": null, + "name": "Acme Inc", + "object": "organization", + "public_metadata": {}, + "slug": "acme-inc", + "updated_at": 1654013567994 + }, + "public_user_data": { + "first_name": null, + "identifier": "example@example.org", + "image_url": "https://img.clerk.com/xxxxxx", + "last_name": null, + "profile_image_url": "https://www.gravatar.com/avatar?d=mp", + "user_id": "user_29wACSk1DjeUCwsS6SbbgIgilMy" + }, + "role": "basic_member", + "updated_at": 1654013847054 + }, + "event_attributes": { + "http_request": { + "client_ip": "0.0.0.0", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + } + }, + "object": "event", + "timestamp": 1654013847054, + "type": "organizationMembership.deleted" +} +``` + +``` +{ + "data": { + "created_at": 1654013847054, + "id": "orgmem_29wAbjiJs6aZuPq7AzmkW9dwmyl", + "object": "organization_membership", + "organization": { + "created_at": 1654013202977, + "created_by": "user_1vq84bqWzw7qmFgqSwN4CH1Wp0n", + "id": "org_29w9IfBrPmcpi0IeBVaKtA7R94W", + "image_url": "https://img.clerk.com/xxxxxx", + "logo_url": null, + "name": "Acme Inc", + "object": "organization", + "public_metadata": {}, + "slug": "acme-inc", + "updated_at": 1654013567994 + }, + "public_user_data": { + "first_name": null, + "identifier": "example@example.org", + "image_url": "https://img.clerk.com/xxxxxx", + "last_name": null, + "profile_image_url": "https://www.gravatar.com/avatar?d=mp", + "user_id": "user_29wACSk1DjeUCwsS6SbbgIgilMy" + }, + "role": "basic_member", + "updated_at": 1654013910646 + }, + "event_attributes": { + "http_request": { + "client_ip": "0.0.0.0", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + } + }, + "object": "event", + "timestamp": 1654013910646, + "type": "organizationMembership.updated" +} +``` + +# User + +``` +{ + "data": { + "birthday": "", + "created_at": 1654012591514, + "email_addresses": [ + { + "email_address": "example@example.org", + "id": "idn_29w83yL7CwVlJXylYLxcslromF1", + "linked_to": [], + "object": "email_address", + "verification": { + "status": "verified", + "strategy": "ticket" + } + } + ], + "external_accounts": [], + "external_id": "567772", + "first_name": "Example", + "gender": "", + "id": "user_29w83sxmDNGwOuEthce5gg56FcC", + "image_url": "https://img.clerk.com/xxxxxx", + "last_name": "Example", + "last_sign_in_at": 1654012591514, + "object": "user", + "password_enabled": true, + "phone_numbers": [], + "primary_email_address_id": "idn_29w83yL7CwVlJXylYLxcslromF1", + "primary_phone_number_id": null, + "primary_web3_wallet_id": null, + "private_metadata": {}, + "profile_image_url": "https://www.gravatar.com/avatar?d=mp", + "public_metadata": {}, + "two_factor_enabled": false, + "unsafe_metadata": {}, + "updated_at": 1654012591835, + "username": null, + "web3_wallets": [] + }, + "event_attributes": { + "http_request": { + "client_ip": "0.0.0.0", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + } + }, + "object": "event", + "timestamp": 1654012591835, + "type": "user.created" +} +``` + +``` +{ + "data": { + "deleted": true, + "id": "user_29wBMCtzATuFJut8jO2VNTVekS4", + "object": "user" + }, + "event_attributes": { + "http_request": { + "client_ip": "0.0.0.0", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + } + }, + "object": "event", + "timestamp": 1661861640000, + "type": "user.deleted" +} +``` + +``` +{ + "data": { + "birthday": "", + "created_at": 1654012591514, + "email_addresses": [ + { + "email_address": "example@example.org", + "id": "idn_29w83yL7CwVlJXylYLxcslromF1", + "linked_to": [], + "object": "email_address", + "reserved": true, + "verification": { + "attempts": null, + "expire_at": null, + "status": "verified", + "strategy": "admin" + } + } + ], + "external_accounts": [], + "external_id": null, + "first_name": "Example", + "gender": "", + "id": "user_29w83sxmDNGwOuEthce5gg56FcC", + "image_url": "https://img.clerk.com/xxxxxx", + "last_name": null, + "last_sign_in_at": null, + "object": "user", + "password_enabled": true, + "phone_numbers": [], + "primary_email_address_id": "idn_29w83yL7CwVlJXylYLxcslromF1", + "primary_phone_number_id": null, + "primary_web3_wallet_id": null, + "private_metadata": {}, + "profile_image_url": "https://www.gravatar.com/avatar?d=mp", + "public_metadata": {}, + "two_factor_enabled": false, + "unsafe_metadata": {}, + "updated_at": 1654012824306, + "username": null, + "web3_wallets": [] + }, + "event_attributes": { + "http_request": { + "client_ip": "0.0.0.0", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + } + }, + "object": "event", + "timestamp": 1654012824306, + "type": "user.updated" +} +``` diff --git a/.cursor/rules/rules-diagram-mermaid.mdc b/.cursor/rules/rules-diagram-mermaid.mdc index f5a2f75..088a424 100644 --- a/.cursor/rules/rules-diagram-mermaid.mdc +++ b/.cursor/rules/rules-diagram-mermaid.mdc @@ -4,95 +4,99 @@ globs: alwaysApply: true --- erDiagram -Organization { - string id PK - string name - string email - string phone - string address - json settings - string clerkId - string stripeCustomerId - string subscriptionId - string subscriptionStatus - string priceId - date created - date updated -} + Organization { + string id PK + string name + string email + string phone + string address + json settings + string clerkId + string stripeCustomerId + string subscriptionId + string subscriptionStatus + string priceId + date created + date updated + } -User { - string id PK - string name - string email - string phone - string role - boolean isAdmin - boolean canLogin - string lastLogin - file avatar - boolean verified - boolean emailVisibility - string clerkId - date created - date updated -} + AppUser { + string id PK + string name + string email + string phone + string role + boolean isAdmin + boolean verified + boolean emailVisibility + string clerkId + date lastLogin + date created + date updated + json metadata + } -Equipment { - string id PK - string organizationId FK - string name - string qrNfcCode - string tags - editor notes - date acquisitionDate - string parentEquipmentId FK - date created - date updated -} + Equipment { + string id PK + string name + string qrNfcCode + string tags + editor notes + date acquisitionDate + date created + date updated + } -Project { - string id PK - string organizationId FK - string name - string address - editor notes - date startDate - date endDate - date created - date updated -} + Project { + string id PK + string name + string address + editor notes + date startDate + date endDate + date created + date updated + } -Assignment { - string id PK - string organizationId FK - string equipmentId FK - string assignedToUserId FK - string assignedToProjectId FK - date startDate - date endDate - editor notes - date created - date updated -} + Assignment { + string id PK + date startDate + date endDate + editor notes + date created + date updated + } -Image { - string id PK - string title - string alt - string caption - file image - date created - date updated -} + Image { + string id PK + string title + string alt + string caption + file image + date created + date updated + } -Organization ||--o{ User : has -Organization ||--o{ Equipment : owns -Organization ||--o{ Project : manages -Organization ||--o{ Assignment : oversees + ActivityLog { + string id PK + json metadata + date created + date updated + } -User }o--o{ Assignment : "is assigned to" + Organization ||--o{ UserApp : "has" + Organization ||--o{ Equipment : "owns" + Organization ||--o{ Project : "manages" + Organization ||--o{ Assignment : "oversees" + Organization ||--o{ ActivityLog : "tracks" -Equipment }o--o{ Assignment : "is assigned via" -Equipment }o--o{ Equipment : "parent/child" + UserApp }o--o{ Organization : "belongs to" + UserApp }o--o{ Assignment : "is assigned to" + UserApp }o--o{ ActivityLog : "generates" + UserApp }o--|| Image : "have" -Project }o--o{ Assignment : includes + Equipment }o--o{ Assignment : "is assigned via" + Equipment }o--o{ Equipment : "parent/child" + Equipment }o--o{ ActivityLog : "is tracked in" + + Project }o--o{ Assignment : "includes" diff --git a/.cursor/rules/rules-stack-technique.mdc b/.cursor/rules/rules-stack-technique.mdc index 07fead5..067f03b 100644 --- a/.cursor/rules/rules-stack-technique.mdc +++ b/.cursor/rules/rules-stack-technique.mdc @@ -51,10 +51,15 @@ Cette plateforme SaaS de gestion d'équipements avec tracking NFC/QR combine les - Isolation multi-tenant intégrée - **Zod** - Validation de schémas pour les données d'entrée - **Tan stack form** - Gestion de formulaires avec validation côté client +- **Webhook** - En local on va utiliser ngrok, en prod on va utiliser les endpoints classiques + ### Backend -- **Pockebase** - Backend as a service +- **PocketBase** - Backend as a service + - Services modulaires (baseService, equipmentService, etc.) + - Gestion d'authentification et permissions + - Stockage de données structurées ### Sécurité API @@ -102,17 +107,12 @@ Cette plateforme SaaS de gestion d'équipements avec tracking NFC/QR combine les - **Glitchtip** - Suivi des erreurs (compatible avec l'API Sentry) - **Umami** - Analytics respectueux de la vie privée -### Sauvegarde & Restauration - -- **pgbackrest** - Solution de backup robuste pour PostgreSQL -- **pg_dump automatisé** - Sauvegardes programmées - ## 6. Architecture multi-tenant - Architecture à schéma unique avec discrimination par tenant_id - Isolation des données par organisation au niveau des Server Actions - Middleware de protection centralisé pour les vérifications d'accès -- Optimisation des requêtes grâce aux index sur tenant_id +- Optimisation des requêtes avec PocketBase ## 7. Intégration NFC/QR @@ -131,26 +131,68 @@ Cette plateforme SaaS de gestion d'équipements avec tracking NFC/QR combine les ## 9. Documentation - **Swagger/OpenAPI** - Documentation d'API auto-générée -- **Docusaurus** - Documentation utilisateur et technique - -## 10. Structure du projet +## 10. Structure du projet ``` src/ -├── app/ # Next.js App Router -│ ├── (auth)/ # Routes authentifiées -│ ├── (marketing)/ # Routes publiques (landing) -│ └── api/ # Routes API REST si nécessaire -├── components/ # Composants React partagés -│ ├── ui/ # Composants UI de base (shadcn) -│ └── [feature]/ # Composants spécifiques aux fonctionnalités -├── lib/ # Code utilitaire partagé -├── server/ # Code serveur -│ ├── actions/ # Next.js Server Actions protégées -│ │ └── middleware.ts # Wrapper de protection HOF -│ ├── db/ # Prisma et utilitaires DB -│ └── services/ # Logique métier -├── stores/ # Stores Zustand -├── styles/ # Styles globaux Tailwind -└── types/ # Types TypeScript partagés +├── app/ # Next.js App Router +│ ├── (application)/ # Application sécurisée +│ │ ├── (clerk)/ # Routes authentifiées par Clerk +│ │ └── app/ # Fonctionnalités principales de l'application +│ ├── (marketing)/ # Routes publiques (landing) +│ └── actions/ # Server Actions sécurisées +│ ├── equipment/ # Actions pour la gestion des équipements +│ └── services/ # Services d'accès aux données +│ └── pocketbase/ # Services PocketBase modulaires +├── components/ # Composants React partagés +│ ├── app/ # Composants spécifiques à l'application +│ ├── magicui/ # Composants UI avancés (animations, effets) +│ └── ui/ # Composants UI de base (shadcn) +├── hooks/ # Hooks React personnalisés +├── lib/ # Code utilitaire partagé +├── stores/ # Stores Zustand +└── types/ # Types TypeScript partagés ``` + + +## 11. Schéma d'Architecture Globale + +```mermaid +flowchart TB + subgraph Client["Client (Browser/Mobile)"] + UI["Next.js UI"] + ZustandStore["Zustand Store"] + TanStackForm["Tan Stack Form"] + NFC["NFC/QR Scanner"] + end + + subgraph ServerSide["Server Side (Next.js)"] + ServerActions["Server Actions"] + Middleware["Protection Middleware"] + ClerkAuth["Clerk Auth"] + end + + subgraph Services["External Services"] + PocketBase["PocketBase"] + Stripe["Stripe Payments"] + CloudflareR2["Cloudflare R2"] + Algolia["Algolia Search"] + Resend["Resend Email"] + Twilio["Twilio SMS"] + end + + UI <--> ZustandStore + UI <--> TanStackForm + UI <--> NFC + TanStackForm --> ServerActions + UI <--> ServerActions + ServerActions <--> Middleware + Middleware <--> ClerkAuth + ServerActions <--> PocketBase + ServerActions <--> Stripe + ServerActions <--> CloudflareR2 + ServerActions <--> Algolia + ServerActions <--> Resend + ServerActions <--> Twilio +``` + diff --git a/.cursor/rules/rules-technique-prompt.mdc b/.cursor/rules/rules-technique-prompt.mdc index 5a305f9..4bede28 100644 --- a/.cursor/rules/rules-technique-prompt.mdc +++ b/.cursor/rules/rules-technique-prompt.mdc @@ -11,49 +11,14 @@ Tu es un assistant de développement expert spécialisé dans la création d'une ## 📋 Directives Générales -- **Langue**: Toujours coder et commenter en anglais +- **Langue**: Toujours coder et commenter en anglais, très important ! - **Style de collaboration**: Proactif et pédagogique, explique tes choix techniques - **Format de réponse**: Structuré, avec des sections claires et une bonne utilisation du markdown - **Erreurs**: Identifie de manière proactive les problèmes potentiels dans mon code - **Standards**: Respecte les meilleures pratiques pour chaque technologie utilisée - **Optimisations**: Suggère des améliorations de performance, sécurité et maintenabilité -## 🏗️ Stack Technique à Respecter - -### Frontend - -- **Framework**: Next.js 15+, React 19+ -- **Styling**: Tailwind CSS 4+, shadcn/ui -- !! Attention, on va utiliser Tailwind v4, et pas les versions en dessous, on évitera les morceaux de code incompatible lié à Tailwindv3 -- **État**: Zustand pour la gestion d'état globale (éviter le prop drilling) -- **Forms**: Tan stack form + Zod pour la validation -- **Animations**: Framer Motion, Rive pour les animations complexes -- **UI**: Composants shadcn/ui, icônes Lucide React -- **Mobile**: next-pwa, WebNFC API, QR code fallback - -### Backend - -- **API**: Next.js Server Actions avec middleware de protection centralisé -- **Validation**: Zod pour la validation des données -- **ORM**: Prisma avec PostgreSQL -- **Authentification**: Clerk 6+ -- **Paiements**: Stripe -- **Recherche**: Algolia -- **Stockage**: Cloudflare R2 -- **Emails**: Resend -- **SMS**: Twilio -- **Temps réel**: Socket.io -- **Tâches asynchrones**: Temporal.io - -### DevOps & Sécurité - -- **Déploiement**: Coolify, Docker -- **CI/CD**: GitHub Actions -- **Monitoring**: Prometheus, Grafana, Loki, Glitchtip -- **Analytics**: Umami -- **Sécurité API**: Rate limiting, CORS, Helmet - -## 11. Schéma / visualisation +## Schéma / visualisation Tout les schémas et assets pour les visualisations sont dans le dossier [dev-assets](mdc:../dev-assets/images ...) pour la partie dev , et pour les éléments visuels, ils se trouveront dans le dossier public/assets/ pour la partie prod. Si il y a besoin de schémas, il faut les les créer avec [Mermaid](mdc:https:/mermaid-js.github.io) et suivre les bonnes pratiques de ce langage. @@ -74,7 +39,8 @@ Si il y a besoin de schémas, il faut les les créer avec [Mermaid](mdc:https:/m - **React**: Composants fonctionnels avec hooks - **Imports**: Groupés et ordonnés (1. React/Next, 2. Libs externes, 3. Components, 4. Utils) - **Nommage**: camelCase pour variables/fonctions, PascalCase pour composants/types -- **État**: Préférer `useState`, `useReducer` localement, Zustand globalement +- **État**: Préférer `useState`, `useReducer` localement, Zustand globalement, et éviter absolument le props drilling. +- **Types**: les types seront regroupés correctement au même endroit dans /types, pour éviter toute redondances et duplications des types et du code ### Documentation @@ -99,28 +65,6 @@ Si il y a besoin de schémas, il faut les les créer avec [Mermaid](mdc:https:/m - Tests end-to-end avec Playwright - Privilégier les tests pour la logique métier critique -## 📐 Structure de Projet Attendue - -``` -src/ -├── app/ # Next.js App Router -│ ├── (auth)/ # Routes authentifiées -│ ├── (marketing)/ # Routes publiques (landing) -│ └── api/ # Routes API REST si nécessaire -├── components/ # Composants React partagés -│ ├── ui/ # Composants UI de base (shadcn) -│ └── [feature]/ # Composants spécifiques aux fonctionnalités -├── lib/ # Code utilitaire partagé -├── server/ # Code serveur -│ ├── actions/ # Next.js Server Actions protégées -│ │ └── middleware.ts # Wrapper de protection HOF -│ ├── db/ # Prisma et utilitaires DB -│ └── services/ # Logique métier -├── stores/ # Stores Zustand -├── styles/ # Styles globaux Tailwind -└── types/ # Types TypeScript partagés -``` - ## 🤝 Collaboration Attendue - **Proactivité**: Anticipe les besoins et problèmes potentiels @@ -139,6 +83,7 @@ src/ - Éviter d'exposer des données sensibles dans le frontend - Ne pas dupliquer la logique d'authentification et de validation - Éviter de créer des Server Actions sans utiliser le middleware de protection +- Tout les imports doivent utiliser le format '@/...' et pas de chemin relatif ou absolu direct ## 🔄 Processus de Travail @@ -148,4 +93,4 @@ src/ 4. Suggère des améliorations ou alternatives si pertinent 5. Offre des conseils pour les tests et la maintenance -Utilise ces directives pour m'assister de manière précise et efficace dans le développement de cette plateforme SaaS de gestion d'équipements NFC/QR. +Utilise ces directives pour m'assister de manière précise et efficace dans le développement de cette plateforme SaaS de gestion d'équipements NFC/QR. diff --git a/.cursor/tanstack/table-main/docs/api/core/cell.md b/.cursor/tanstack/table-main/docs/api/core/cell.md new file mode 100644 index 0000000..a39ca79 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/core/cell.md @@ -0,0 +1,68 @@ +--- +title: Cell APIs +--- + +These are **core** options and API properties for all cells. More options and API properties are available for other [table features](../guide/features). + +## Cell API + +All cell objects have the following properties: + +### `id` + +```tsx +id: string +``` + +The unique ID for the cell across the entire table. + +### `getValue` + +```tsx +getValue: () => any +``` + +Returns the value for the cell, accessed via the associated column's accessor key or accessor function. + +### `renderValue` + +```tsx +renderValue: () => any +``` + +Renders the value for a cell the same as `getValue`, but will return the `renderFallbackValue` if no value is found. + +### `row` + +```tsx +row: Row +``` + +The associated Row object for the cell. + +### `column` + +```tsx +column: Column +``` + +The associated Column object for the cell. + +### `getContext` + +```tsx +getContext: () => { + table: Table + column: Column + row: Row + cell: Cell + getValue: () => TTValue + renderValue: () => TTValue | null +} +``` + +Returns the rendering context (or props) for cell-based components like cells and aggregated cells. Use these props with your framework's `flexRender` utility to render these using the template of your choice: + +```tsx +flexRender(cell.column.columnDef.cell, cell.getContext()) +``` diff --git a/.cursor/tanstack/table-main/docs/api/core/column-def.md b/.cursor/tanstack/table-main/docs/api/core/column-def.md new file mode 100644 index 0000000..3522e9d --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/core/column-def.md @@ -0,0 +1,107 @@ +--- +title: ColumnDef APIs +--- + +Column definitions are plain objects with the following options: + +## Options + +### `id` + +```tsx +id: string +``` + +The unique identifier for the column. + +> 🧠 A column ID is optional when: +> +> - An accessor column is created with an object key accessor +> - The column header is defined as a string + +### `accessorKey` + +```tsx +accessorKey?: string & typeof TData +``` + +The key of the row object to use when extracting the value for the column. + +### `accessorFn` + +```tsx +accessorFn?: (originalRow: TData, index: number) => any +``` + +The accessor function to use when extracting the value for the column from each row. + +### `columns` + +```tsx +columns?: ColumnDef[] +``` + +The child column defs to include in a group column. + +### `header` + +```tsx +header?: + | string + | ((props: { + table: Table + header: Header + column: Column + }) => unknown) +``` + +The header to display for the column. If a string is passed, it can be used as a default for the column ID. If a function is passed, it will be passed a props object for the header and should return the rendered header value (the exact type depends on the adapter being used). + +### `footer` + +```tsx +footer?: + | string + | ((props: { + table: Table + header: Header + column: Column + }) => unknown) +``` + +The footer to display for the column. If a function is passed, it will be passed a props object for the footer and should return the rendered footer value (the exact type depends on the adapter being used). + +### `cell` + +```tsx +cell?: + | string + | ((props: { + table: Table + row: Row + column: Column + cell: Cell + getValue: () => any + renderValue: () => any + }) => unknown) +``` + +The cell to display each row for the column. If a function is passed, it will be passed a props object for the cell and should return the rendered cell value (the exact type depends on the adapter being used). + +### `meta` + +```tsx +meta?: ColumnMeta // This interface is extensible via declaration merging. See below! +``` + +The meta data to be associated with the column. We can access it anywhere when the column is available via `column.columnDef.meta`. This type is global to all tables and can be extended like so: + +```tsx +import '@tanstack/react-table' //or vue, svelte, solid, qwik, etc. + +declare module '@tanstack/react-table' { + interface ColumnMeta { + foo: string + } +} +``` diff --git a/.cursor/tanstack/table-main/docs/api/core/column.md b/.cursor/tanstack/table-main/docs/api/core/column.md new file mode 100644 index 0000000..1ca4db8 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/core/column.md @@ -0,0 +1,77 @@ +--- +title: Column APIs +--- + +These are **core** options and API properties for all columns. More options and API properties are available for other [table features](../guide/features). + +## Column API + +All column objects have the following properties: + +### `id` + +```tsx +id: string +``` + +The resolved unique identifier for the column resolved in this priority: + +- A manual `id` property from the column def +- The accessor key from the column def +- The header string from the column def + +### `depth` + +```tsx +depth: number +``` + +The depth of the column (if grouped) relative to the root column def array. + +### `accessorFn` + +```tsx +accessorFn?: AccessorFn +``` + +The resolved accessor function to use when extracting the value for the column from each row. Will only be defined if the column def has a valid accessor key or function defined. + +### `columnDef` + +```tsx +columnDef: ColumnDef +``` + +The original column def used to create the column. + +### `columns` + +```tsx +type columns = ColumnDef[] +``` + +The child column (if the column is a group column). Will be an empty array if the column is not a group column. + +### `parent` + +```tsx +parent?: Column +``` + +The parent column for this column. Will be undefined if this is a root column. + +### `getFlatColumns` + +```tsx +type getFlatColumns = () => Column[] +``` + +Returns the flattened array of this column and all child/grand-child columns for this column. + +### `getLeafColumns` + +```tsx +type getLeafColumns = () => Column[] +``` + +Returns an array of all leaf-node columns for this column. If a column has no children, it is considered the only leaf-node column. diff --git a/.cursor/tanstack/table-main/docs/api/core/header-group.md b/.cursor/tanstack/table-main/docs/api/core/header-group.md new file mode 100644 index 0000000..24b5a5b --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/core/header-group.md @@ -0,0 +1,33 @@ +--- +title: HeaderGroup APIs +--- + +These are **core** options and API properties for all header groups. More options and API properties may be available for other [table features](../guide/features). + +## Header Group API + +All header group objects have the following properties: + +### `id` + +```tsx +id: string +``` + +The unique identifier for the header group. + +### `depth` + +```tsx +depth: number +``` + +The depth of the header group, zero-indexed based. + +### `headers` + +```tsx +type headers = Header[] +``` + +An array of [Header](../api/core/header) objects that belong to this header group diff --git a/.cursor/tanstack/table-main/docs/api/core/header.md b/.cursor/tanstack/table-main/docs/api/core/header.md new file mode 100644 index 0000000..4cb3ca7 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/core/header.md @@ -0,0 +1,243 @@ +--- +title: Header APIs +--- + +These are **core** options and API properties for all headers. More options and API properties may be available for other [table features](../guide/features). + +## Header API + +All header objects have the following properties: + +### `id` + +```tsx +id: string +``` + +The unique identifier for the header. + +### `index` + +```tsx +index: number +``` + +The index for the header within the header group. + +### `depth` + +```tsx +depth: number +``` + +The depth of the header, zero-indexed based. + +### `column` + +```tsx +column: Column +``` + +The header's associated [Column](../api/core/column) object + +### `headerGroup` + +```tsx +headerGroup: HeaderGroup +``` + +The header's associated [HeaderGroup](../api/core/header-group) object + +### `subHeaders` + +```tsx +type subHeaders = Header[] +``` + +The header's hierarchical sub/child headers. Will be empty if the header's associated column is a leaf-column. + +### `colSpan` + +```tsx +colSpan: number +``` + +The col-span for the header. + +### `rowSpan` + +```tsx +rowSpan: number +``` + +The row-span for the header. + +### `getLeafHeaders` + +```tsx +type getLeafHeaders = () => Header[] +``` + +Returns the leaf headers hierarchically nested under this header. + +### `isPlaceholder` + +```tsx +isPlaceholder: boolean +``` + +A boolean denoting if the header is a placeholder header + +### `placeholderId` + +```tsx +placeholderId?: string +``` + +If the header is a placeholder header, this will be a unique header ID that does not conflict with any other headers across the table + +### `getContext` + +```tsx +getContext: () => { + table: Table + header: Header + column: Column +} +``` + +Returns the rendering context (or props) for column-based components like headers, footers and filters. Use these props with your framework's `flexRender` utility to render these using the template of your choice: + +```tsx +flexRender(header.column.columnDef.header, header.getContext()) +``` + +## Table API + +### `getHeaderGroups` + +```tsx +type getHeaderGroups = () => HeaderGroup[] +``` + +Returns all header groups for the table. + +### `getLeftHeaderGroups` + +```tsx +type getLeftHeaderGroups = () => HeaderGroup[] +``` + +If pinning, returns the header groups for the left pinned columns. + +### `getCenterHeaderGroups` + +```tsx +type getCenterHeaderGroups = () => HeaderGroup[] +``` + +If pinning, returns the header groups for columns that are not pinned. + +### `getRightHeaderGroups` + +```tsx +type getRightHeaderGroups = () => HeaderGroup[] +``` + +If pinning, returns the header groups for the right pinned columns. + +### `getFooterGroups` + +```tsx +type getFooterGroups = () => HeaderGroup[] +``` + +Returns all footer groups for the table. + +### `getLeftFooterGroups` + +```tsx +type getLeftFooterGroups = () => HeaderGroup[] +``` + +If pinning, returns the footer groups for the left pinned columns. + +### `getCenterFooterGroups` + +```tsx +type getCenterFooterGroups = () => HeaderGroup[] +``` + +If pinning, returns the footer groups for columns that are not pinned. + +### `getRightFooterGroups` + +```tsx +type getRightFooterGroups = () => HeaderGroup[] +``` + +If pinning, returns the footer groups for the right pinned columns. + +### `getFlatHeaders` + +```tsx +type getFlatHeaders = () => Header[] +``` + +Returns headers for all columns in the table, including parent headers. + +### `getLeftFlatHeaders` + +```tsx +type getLeftFlatHeaders = () => Header[] +``` + +If pinning, returns headers for all left pinned columns in the table, including parent headers. + +### `getCenterFlatHeaders` + +```tsx +type getCenterFlatHeaders = () => Header[] +``` + +If pinning, returns headers for all columns that are not pinned, including parent headers. + +### `getRightFlatHeaders` + +```tsx +type getRightFlatHeaders = () => Header[] +``` + +If pinning, returns headers for all right pinned columns in the table, including parent headers. + +### `getLeafHeaders` + +```tsx +type getLeafHeaders = () => Header[] +``` + +Returns headers for all leaf columns in the table, (not including parent headers). + +### `getLeftLeafHeaders` + +```tsx +type getLeftLeafHeaders = () => Header[] +``` + +If pinning, returns headers for all left pinned leaf columns in the table, (not including parent headers). + +### `getCenterLeafHeaders` + +```tsx +type getCenterLeafHeaders = () => Header[] +``` + +If pinning, returns headers for all columns that are not pinned, (not including parent headers). + +### `getRightLeafHeaders` + +```tsx +type getRightLeafHeaders = () => Header[] +``` + +If pinning, returns headers for all right pinned leaf columns in the table, (not including parent headers). diff --git a/.cursor/tanstack/table-main/docs/api/core/row.md b/.cursor/tanstack/table-main/docs/api/core/row.md new file mode 100644 index 0000000..da74859 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/core/row.md @@ -0,0 +1,123 @@ +--- +title: Row APIs +--- + +These are **core** options and API properties for all rows. More options and API properties are available for other [table features](../guide/features). + +## Row API + +All row objects have the following properties: + +### `id` + +```tsx +id: string +``` + +The resolved unique identifier for the row resolved via the `options.getRowId` option. Defaults to the row's index (or relative index if it is a subRow) + +### `depth` + +```tsx +depth: number +``` + +The depth of the row (if nested or grouped) relative to the root row array. + +### `index` + +```tsx +index: number +``` + +The index of the row within its parent array (or the root data array) + +### `original` + +```tsx +original: TData +``` + +The original row object provided to the table. + +> 🧠 If the row is a grouped row, the original row object will be the first original in the group. + +### `parentId` + +```tsx +parentId?: string +``` + +If nested, this row's parent row id. + +### `getValue` + +```tsx +getValue: (columnId: string) => TValue +``` + +Returns the value from the row for a given columnId + +### `renderValue` + +```tsx +renderValue: (columnId: string) => TValue +``` + +Renders the value from the row for a given columnId, but will return the `renderFallbackValue` if no value is found. + +### `getUniqueValues` + +```tsx +getUniqueValues: (columnId: string) => TValue[] +``` + +Returns a unique array of values from the row for a given columnId. + +### `subRows` + +```tsx +type subRows = Row[] +``` + +An array of subRows for the row as returned and created by the `options.getSubRows` option. + +### `getParentRow` + +```tsx +type getParentRow = () => Row | undefined +``` + +Returns the parent row for the row, if it exists. + +### `getParentRows` + +```tsx +type getParentRows = () => Row[] +``` + +Returns the parent rows for the row, all the way up to a root row. + +### `getLeafRows` + +```tsx +type getLeafRows = () => Row[] +``` + +Returns the leaf rows for the row, not including any parent rows. + +### `originalSubRows` + +```tsx +originalSubRows?: TData[] +``` + +An array of the original subRows as returned by the `options.getSubRows` option. + +### `getAllCells` + +```tsx +type getAllCells = () => Cell[] +``` + +Returns all of the [Cells](../api/core/cell) for the row. diff --git a/.cursor/tanstack/table-main/docs/api/core/table.md b/.cursor/tanstack/table-main/docs/api/core/table.md new file mode 100644 index 0000000..e5cf07c --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/core/table.md @@ -0,0 +1,385 @@ +--- +title: Table APIs +--- + +## `createAngularTable` / `useReactTable` / `createSolidTable` / `useQwikTable` / `useVueTable` / `createSvelteTable` + +```tsx +type useReactTable = ( + options: TableOptions +) => Table +``` + +These functions are used to create a table. Which one you use depends on which framework adapter you are using. + +## Options + +These are **core** options and API properties for the table. More options and API properties are available for other [table features](../guide/features). + +### `data` + +```tsx +data: TData[] +``` + +The data for the table to display. This array should match the type you provided to `table.setRowType<...>`, but in theory could be an array of anything. It's common for each item in the array to be an object of key/values but this is not required. Columns can access this data via string/index or a functional accessor to return anything they want. + +When the `data` option changes reference (compared via `Object.is`), the table will reprocess the data. Any other data processing that relies on the core data model (such as grouping, sorting, filtering, etc) will also be reprocessed. + +> 🧠 Make sure your `data` option is only changing when you want the table to reprocess. Providing an inline `[]` or constructing the data array as a new object every time you want to render the table will result in a _lot_ of unnecessary re-processing. This can easily go unnoticed in smaller tables, but you will likely notice it in larger tables. + +### `columns` + +```tsx +type columns = ColumnDef[] +``` + +The array of column defs to use for the table. See the [Column Defs Guide](../../docs/guide/column-defs) for more information on creating column definitions. + +### `defaultColumn` + +```tsx +defaultColumn?: Partial> +``` + +Default column options to use for all column defs supplied to the table. This is useful for providing default cell/header/footer renderers, sorting/filtering/grouping options, etc. All column definitions passed to `options.columns` are merged with this default column definition to produce the final column definitions. + +### `initialState` + +```tsx +initialState?: Partial< + VisibilityTableState & + ColumnOrderTableState & + ColumnPinningTableState & + FiltersTableState & + SortingTableState & + ExpandedTableState & + GroupingTableState & + ColumnSizingTableState & + PaginationTableState & + RowSelectionTableState +> +``` + +Use this option to optionally pass initial state to the table. This state will be used when resetting various table states either automatically by the table (eg. `options.autoResetPageIndex`) or via functions like `table.resetRowSelection()`. Most reset function allow you optionally pass a flag to reset to a blank/default state instead of the initial state. + +> 🧠 Table state will not be reset when this object changes, which also means that the initial state object does not need to be stable. + +### `autoResetAll` + +```tsx +autoResetAll?: boolean +``` + +Set this option to override any of the `autoReset...` feature options. + +### `meta` + +```tsx +meta?: TableMeta // This interface is extensible via declaration merging. See below! +``` + +You can pass any object to `options.meta` and access it anywhere the `table` is available via `table.options.meta` This type is global to all tables and can be extended like so: + +```tsx +declare module '@tanstack/table-core' { + interface TableMeta { + foo: string + } +} +``` + +> 🧠 Think of this option as an arbitrary "context" for your table. This is a great way to pass arbitrary data or functions to your table without having to pass it to every thing the table touches. A good example is passing a locale object to your table to use for formatting dates, numbers, etc or even a function that can be used to update editable data like in the [editable-data example](../framework/react/examples/editable-data). + +### `state` + +```tsx +state?: Partial< + VisibilityTableState & + ColumnOrderTableState & + ColumnPinningTableState & + FiltersTableState & + SortingTableState & + ExpandedTableState & + GroupingTableState & + ColumnSizingTableState & + PaginationTableState & + RowSelectionTableState +> +``` + +The `state` option can be used to optionally _control_ part or all of the table state. The state you pass here will merge with and overwrite the internal automatically-managed state to produce the final state for the table. You can also listen to state changes via the `onStateChange` option. + +### `onStateChange` + +```tsx +onStateChange: (updater: Updater) => void +``` + +The `onStateChange` option can be used to optionally listen to state changes within the table. If you provide this options, you will be responsible for controlling and updating the table state yourself. You can provide the state back to the table with the `state` option. + +### `debugAll` + +> ⚠️ Debugging is only available in development mode. + +```tsx +debugAll?: boolean +``` + +Set this option to true to output all debugging information to the console. + +### `debugTable` + +> ⚠️ Debugging is only available in development mode. + +```tsx +debugTable?: boolean +``` + +Set this option to true to output table debugging information to the console. + +### `debugHeaders` + +> ⚠️ Debugging is only available in development mode. + +```tsx +debugHeaders?: boolean +``` + +Set this option to true to output header debugging information to the console. + +### `debugColumns` + +> ⚠️ Debugging is only available in development mode. + +```tsx +debugColumns?: boolean +``` + +Set this option to true to output column debugging information to the console. + +### `debugRows` + +> ⚠️ Debugging is only available in development mode. + +```tsx +debugRows?: boolean +``` + +Set this option to true to output row debugging information to the console. + +### `_features` + +```tsx +_features?: TableFeature[] +``` + +An array of extra features that you can add to the table instance. + +### `render` + +> ⚠️ This option is only necessary if you are implementing a table adapter. + +```tsx +type render = (template: Renderable, props: TProps) => any +``` + +The `render` option provides a renderer implementation for the table. This implementation is used to turn a table's various column header and cell templates into a result that is supported by the user's framework. + +### `mergeOptions` + +> ⚠️ This option is only necessary if you are implementing a table adapter. + +```tsx +type mergeOptions = (defaultOptions: T, options: Partial) => T +``` + +This option is used to optionally implement the merging of table options. Some framework like solid-js use proxies to track reactivity and usage, so merging reactive objects needs to be handled carefully. This option inverts control of this process to the adapter. + +### `getCoreRowModel` + +```tsx +getCoreRowModel: (table: Table) => () => RowModel +``` + +This required option is a factory for a function that computes and returns the core row model for the table. It is called **once** per table and should return a **new function** which will calculate and return the row model for the table. + +A default implementation is provided via any table adapter's `{ getCoreRowModel }` export. + +### `getSubRows` + +```tsx +getSubRows?: ( + originalRow: TData, + index: number +) => undefined | TData[] +``` + +This optional function is used to access the sub rows for any given row. If you are using nested rows, you will need to use this function to return the sub rows object (or undefined) from the row. + +### `getRowId` + +```tsx +getRowId?: ( + originalRow: TData, + index: number, + parent?: Row +) => string +``` + +This optional function is used to derive a unique ID for any given row. If not provided the rows index is used (nested rows join together with `.` using their grandparents' index eg. `index.index.index`). If you need to identify individual rows that are originating from any server-side operations, it's suggested you use this function to return an ID that makes sense regardless of network IO/ambiguity eg. a userId, taskId, database ID field, etc. + +## Table API + +These properties and methods are available on the table object: + +### `initialState` + +```tsx +initialState: VisibilityTableState & + ColumnOrderTableState & + ColumnPinningTableState & + FiltersTableState & + SortingTableState & + ExpandedTableState & + GroupingTableState & + ColumnSizingTableState & + PaginationTableState & + RowSelectionTableState +``` + +This is the resolved initial state of the table. + +### `reset` + +```tsx +reset: () => void +``` + +Call this function to reset the table state to the initial state. + +### `getState` + +```tsx +getState: () => TableState +``` + +Call this function to get the table's current state. It's recommended to use this function and its state, especially when managing the table state manually. It is the exact same state used internally by the table for every feature and function it provides. + +> 🧠 The state returned by this function is the shallow-merged result of the automatically-managed internal table-state and any manually-managed state passed via `options.state`. + +### `setState` + +```tsx +setState: (updater: Updater) => void +``` + +Call this function to update the table state. It's recommended you pass an updater function in the form of `(prevState) => newState` to update the state, but a direct object can also be passed. + +> 🧠 If `options.onStateChange` is provided, it will be triggered by this function with the new state. + +### `options` + +```tsx +options: TableOptions +``` + +A read-only reference to the table's current options. + +> ⚠️ This property is generally used internally or by adapters. It can be updated by passing new options to your table. This is different per adapter. For adapters themselves, table options must be updated via the `setOptions` function. + +### `setOptions` + +```tsx +setOptions: (newOptions: Updater>) => void +``` + +> ⚠️ This function is generally used by adapters to update the table options. It can be used to update the table options directly, but it is generally not recommended to bypass your adapters strategy for updating table options. + +### `getCoreRowModel` + +```tsx +getCoreRowModel: () => { + rows: Row[], + flatRows: Row[], + rowsById: Record>, +} +``` + +Returns the core row model before any processing has been applied. + +### `getRowModel` + +```tsx +getRowModel: () => { + rows: Row[], + flatRows: Row[], + rowsById: Record>, +} +``` + +Returns the final model after all processing from other used features has been applied. + +### `getAllColumns` + +```tsx +type getAllColumns = () => Column[] +``` + +Returns all columns in the table in their normalized and nested hierarchy, mirrored from the column defs passed to the table. + +### `getAllFlatColumns` + +```tsx +type getAllFlatColumns = () => Column[] +``` + +Returns all columns in the table flattened to a single level. This includes parent column objects throughout the hierarchy. + +### `getAllLeafColumns` + +```tsx +type getAllLeafColumns = () => Column[] +``` + +Returns all leaf-node columns in the table flattened to a single level. This does not include parent columns. + +### `getColumn` + +```tsx +type getColumn = (id: string) => Column | undefined +``` + +Returns a single column by its ID. + +### `getHeaderGroups` + +```tsx +type getHeaderGroups = () => HeaderGroup[] +``` + +Returns the header groups for the table. + +### `getFooterGroups` + +```tsx +type getFooterGroups = () => HeaderGroup[] +``` + +Returns the footer groups for the table. + +### `getFlatHeaders` + +```tsx +type getFlatHeaders = () => Header[] +``` + +Returns a flattened array of Header objects for the table, including parent headers. + +### `getLeafHeaders` + +```tsx +type getLeafHeaders = () => Header[] +``` + +Returns a flattened array of leaf-node Header objects for the table. diff --git a/.cursor/tanstack/table-main/docs/api/features/column-faceting.md b/.cursor/tanstack/table-main/docs/api/features/column-faceting.md new file mode 100644 index 0000000..2a951da --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/column-faceting.md @@ -0,0 +1,46 @@ +--- +title: Column Faceting APIs +id: column-faceting +--- + +## Column API + +### `getFacetedRowModel` + +```tsx +type getFacetedRowModel = () => RowModel +``` + +> ⚠️ Requires that you pass a valid `getFacetedRowModel` function to `options.facetedRowModel`. A default implementation is provided via the exported `getFacetedRowModel` function. + +Returns the row model with all other column filters applied, excluding its own filter. Useful for displaying faceted result counts. + +### `getFacetedUniqueValues` + +```tsx +getFacetedUniqueValues: () => Map +``` + +> ⚠️ Requires that you pass a valid `getFacetedUniqueValues` function to `options.getFacetedUniqueValues`. A default implementation is provided via the exported `getFacetedUniqueValues` function. + +A function that **computes and returns** a `Map` of unique values and their occurrences derived from `column.getFacetedRowModel`. Useful for displaying faceted result values. + +### `getFacetedMinMaxValues` + +```tsx +getFacetedMinMaxValues: () => Map +``` + +> ⚠️ Requires that you pass a valid `getFacetedMinMaxValues` function to `options.getFacetedMinMaxValues`. A default implementation is provided via the exported `getFacetedMinMaxValues` function. + +A function that **computes and returns** a min/max tuple derived from `column.getFacetedRowModel`. Useful for displaying faceted result values. + +## Table Options + +### `getColumnFacetedRowModel` + +```tsx +getColumnFacetedRowModel: (columnId: string) => RowModel +``` + +Returns the faceted row model for a given columnId. diff --git a/.cursor/tanstack/table-main/docs/api/features/column-filtering.md b/.cursor/tanstack/table-main/docs/api/features/column-filtering.md new file mode 100644 index 0000000..b32d4f3 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/column-filtering.md @@ -0,0 +1,396 @@ +--- +title: Column Filtering APIs +id: column-filtering +--- + +## Can-Filter + +The ability for a column to be **column** filtered is determined by the following: + +- The column was defined with a valid `accessorKey`/`accessorFn`. +- `column.enableColumnFilter` is not set to `false` +- `options.enableColumnFilters` is not set to `false` +- `options.enableFilters` is not set to `false` + +## State + +Filter state is stored on the table using the following shape: + +```tsx +export interface ColumnFiltersTableState { + columnFilters: ColumnFiltersState +} + +export type ColumnFiltersState = ColumnFilter[] + +export interface ColumnFilter { + id: string + value: unknown +} +``` + +## Filter Functions + +The following filter functions are built-in to the table core: + +- `includesString` + - Case-insensitive string inclusion +- `includesStringSensitive` + - Case-sensitive string inclusion +- `equalsString` + - Case-insensitive string equality +- `equalsStringSensitive` + - Case-sensitive string equality +- `arrIncludes` + - Item inclusion within an array +- `arrIncludesAll` + - All items included in an array +- `arrIncludesSome` + - Some items included in an array +- `equals` + - Object/referential equality `Object.is`/`===` +- `weakEquals` + - Weak object/referential equality `==` +- `inNumberRange` + - Number range inclusion + +Every filter function receives: + +- The row to filter +- The columnId to use to retrieve the row's value +- The filter value + +and should return `true` if the row should be included in the filtered rows, and `false` if it should be removed. + +This is the type signature for every filter function: + +```tsx +export type FilterFn = { + ( + row: Row, + columnId: string, + filterValue: any, + addMeta: (meta: any) => void + ): boolean + resolveFilterValue?: TransformFilterValueFn + autoRemove?: ColumnFilterAutoRemoveTestFn + addMeta?: (meta?: any) => void +} + +export type TransformFilterValueFn = ( + value: any, + column?: Column +) => unknown + +export type ColumnFilterAutoRemoveTestFn = ( + value: any, + column?: Column +) => boolean + +export type CustomFilterFns = Record< + string, + FilterFn +> +``` + +### `filterFn.resolveFilterValue` + +This optional "hanging" method on any given `filterFn` allows the filter function to transform/sanitize/format the filter value before it is passed to the filter function. + +### `filterFn.autoRemove` + +This optional "hanging" method on any given `filterFn` is passed a filter value and expected to return `true` if the filter value should be removed from the filter state. eg. Some boolean-style filters may want to remove the filter value from the table state if the filter value is set to `false`. + +#### Using Filter Functions + +Filter functions can be used/referenced/defined by passing the following to `columnDefinition.filterFn`: + +- A `string` that references a built-in filter function +- A function directly provided to the `columnDefinition.filterFn` option + +The final list of filter functions available for the `columnDef.filterFn` option use the following type: + +```tsx +export type FilterFnOption = + | 'auto' + | BuiltInFilterFn + | FilterFn +``` + +#### Filter Meta + +Filtering data can often expose additional information about the data that can be used to aid other future operations on the same data. A good example of this concept is a ranking-system like that of [`match-sorter`](https://github.com/kentcdodds/match-sorter) that simultaneously ranks, filters and sorts data. While utilities like `match-sorter` make a lot of sense for single-dimensional filter+sort tasks, the decoupled filtering/sorting architecture of building a table makes them very difficult and slow to use. + +To make a ranking/filtering/sorting system work with tables, `filterFn`s can optionally mark results with a **filter meta** value that can be used later to sort/group/etc the data to your liking. This is done by calling the `addMeta` function supplied to your custom `filterFn`. + +Below is an example using our own `match-sorter-utils` package (a utility fork of `match-sorter`) to rank, filter, and sort the data + +```tsx +import { sortingFns } from '@tanstack/react-table' + +import { rankItem, compareItems } from '@tanstack/match-sorter-utils' + +const fuzzyFilter = (row, columnId, value, addMeta) => { + // Rank the item + const itemRank = rankItem(row.getValue(columnId), value) + + // Store the ranking info + addMeta(itemRank) + + // Return if the item should be filtered in/out + return itemRank.passed +} + +const fuzzySort = (rowA, rowB, columnId) => { + let dir = 0 + + // Only sort by rank if the column has ranking information + if (rowA.columnFiltersMeta[columnId]) { + dir = compareItems( + rowA.columnFiltersMeta[columnId]!, + rowB.columnFiltersMeta[columnId]! + ) + } + + // Provide an alphanumeric fallback for when the item ranks are equal + return dir === 0 ? sortingFns.alphanumeric(rowA, rowB, columnId) : dir +} +``` + +## Column Def Options + +### `filterFn` + +```tsx +filterFn?: FilterFn | keyof FilterFns | keyof BuiltInFilterFns +``` + +The filter function to use with this column. + +Options: + +- A `string` referencing a [built-in filter function](#filter-functions)) +- A [custom filter function](#filter-functions) + +### `enableColumnFilter` + +```tsx +enableColumnFilter?: boolean +``` + +Enables/disables the **column** filter for this column. + +## Column API + +### `getCanFilter` + +```tsx +getCanFilter: () => boolean +``` + +Returns whether or not the column can be **column** filtered. + +### `getFilterIndex` + +```tsx +getFilterIndex: () => number +``` + +Returns the index (including `-1`) of the column filter in the table's `state.columnFilters` array. + +### `getIsFiltered` + +```tsx +getIsFiltered: () => boolean +``` + +Returns whether or not the column is currently filtered. + +### `getFilterValue` + +```tsx +getFilterValue: () => unknown +``` + +Returns the current filter value of the column. + +### `setFilterValue` + +```tsx +setFilterValue: (updater: Updater) => void +``` + +A function that sets the current filter value for the column. You can pass it a value or an updater function for immutability-safe operations on existing values. + +### `getAutoFilterFn` + +```tsx +getAutoFilterFn: (columnId: string) => FilterFn | undefined +``` + +Returns an automatically calculated filter function for the column based off of the columns first known value. + +### `getFilterFn` + +```tsx +getFilterFn: (columnId: string) => FilterFn | undefined +``` + +Returns the filter function (either user-defined or automatic, depending on configuration) for the columnId specified. + +## Row API + +### `columnFilters` + +```tsx +columnFilters: Record +``` + +The column filters map for the row. This object tracks whether a row is passing/failing specific filters by their column ID. + +### `columnFiltersMeta` + +```tsx +columnFiltersMeta: Record +``` + +The column filters meta map for the row. This object tracks any filter meta for a row as optionally provided during the filtering process. + +## Table Options + +### `filterFns` + +```tsx +filterFns?: Record +``` + +This option allows you to define custom filter functions that can be referenced in a column's `filterFn` option by their key. +Example: + +```tsx +declare module '@tanstack/[adapter]-table' { + interface FilterFns { + myCustomFilter: FilterFn + } +} + +const column = columnHelper.data('key', { + filterFn: 'myCustomFilter', +}) + +const table = useReactTable({ + columns: [column], + filterFns: { + myCustomFilter: (rows, columnIds, filterValue) => { + // return the filtered rows + }, + }, +}) +``` + +### `filterFromLeafRows` + +```tsx +filterFromLeafRows?: boolean +``` + +By default, filtering is done from parent rows down (so if a parent row is filtered out, all of its children will be filtered out as well). Setting this option to `true` will cause filtering to be done from leaf rows up (which means parent rows will be included so long as one of their child or grand-child rows is also included). + +### `maxLeafRowFilterDepth` + +```tsx +maxLeafRowFilterDepth?: number +``` + +By default, filtering is done for all rows (max depth of 100), no matter if they are root level parent rows or the child leaf rows of a parent row. Setting this option to `0` will cause filtering to only be applied to the root level parent rows, with all sub-rows remaining unfiltered. Similarly, setting this option to `1` will cause filtering to only be applied to child leaf rows 1 level deep, and so on. + +This is useful for situations where you want a row's entire child hierarchy to be visible regardless of the applied filter. + +### `enableFilters` + +```tsx +enableFilters?: boolean +``` + +Enables/disables all filters for the table. + +### `manualFiltering` + +```tsx +manualFiltering?: boolean +``` + +Disables the `getFilteredRowModel` from being used to filter data. This may be useful if your table needs to dynamically support both client-side and server-side filtering. + +### `onColumnFiltersChange` + +```tsx +onColumnFiltersChange?: OnChangeFn +``` + +If provided, this function will be called with an `updaterFn` when `state.columnFilters` changes. This overrides the default internal state management, so you will need to persist the state change either fully or partially outside of the table. + +### `enableColumnFilters` + +```tsx +enableColumnFilters?: boolean +``` + +Enables/disables **all** column filters for the table. + +### `getFilteredRowModel` + +```tsx +getFilteredRowModel?: ( + table: Table +) => () => RowModel +``` + +If provided, this function is called **once** per table and should return a **new function** which will calculate and return the row model for the table when it's filtered. + +- For server-side filtering, this function is unnecessary and can be ignored since the server should already return the filtered row model. +- For client-side filtering, this function is required. A default implementation is provided via any table adapter's `{ getFilteredRowModel }` export. + +Example: + +```tsx +import { getFilteredRowModel } from '@tanstack/[adapter]-table' + + + getFilteredRowModel: getFilteredRowModel(), +}) +``` + +## Table API + +### `setColumnFilters` + +```tsx +setColumnFilters: (updater: Updater) => void +``` + +Sets or updates the `state.columnFilters` state. + +### `resetColumnFilters` + +```tsx +resetColumnFilters: (defaultState?: boolean) => void +``` + +Resets the **columnFilters** state to `initialState.columnFilters`, or `true` can be passed to force a default blank state reset to `[]`. + +### `getPreFilteredRowModel` + +```tsx +getPreFilteredRowModel: () => RowModel +``` + +Returns the row model for the table before any **column** filtering has been applied. + +### `getFilteredRowModel` + +```tsx +getFilteredRowModel: () => RowModel +``` + +Returns the row model for the table after **column** filtering has been applied. diff --git a/.cursor/tanstack/table-main/docs/api/features/column-ordering.md b/.cursor/tanstack/table-main/docs/api/features/column-ordering.md new file mode 100644 index 0000000..37bfb95 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/column-ordering.md @@ -0,0 +1,70 @@ +--- +title: Column Ordering APIs +id: column-ordering +--- + +## State + +Column ordering state is stored on the table using the following shape: + +```tsx +export type ColumnOrderTableState = { + columnOrder: ColumnOrderState +} + +export type ColumnOrderState = string[] +``` + +## Table Options + +### `onColumnOrderChange` + +```tsx +onColumnOrderChange?: OnChangeFn +``` + +If provided, this function will be called with an `updaterFn` when `state.columnOrder` changes. This overrides the default internal state management, so you will need to persist the state change either fully or partially outside of the table. + +## Table API + +### `setColumnOrder` + +```tsx +setColumnOrder: (updater: Updater) => void +``` + +Sets or updates the `state.columnOrder` state. + +### `resetColumnOrder` + +```tsx +resetColumnOrder: (defaultState?: boolean) => void +``` + +Resets the **columnOrder** state to `initialState.columnOrder`, or `true` can be passed to force a default blank state reset to `[]`. + +## Column API + +### `getIndex` + +```tsx +getIndex: (position?: ColumnPinningPosition) => number +``` + +Returns the index of the column in the order of the visible columns. Optionally pass a `position` parameter to get the index of the column in a sub-section of the table. + +### `getIsFirstColumn` + +```tsx +getIsFirstColumn: (position?: ColumnPinningPosition) => boolean +``` + +Returns `true` if the column is the first column in the order of the visible columns. Optionally pass a `position` parameter to check if the column is the first in a sub-section of the table. + +### `getIsLastColumn` + +```tsx +getIsLastColumn: (position?: ColumnPinningPosition) => boolean +``` + +Returns `true` if the column is the last column in the order of the visible columns. Optionally pass a `position` parameter to check if the column is the last in a sub-section of the table. \ No newline at end of file diff --git a/.cursor/tanstack/table-main/docs/api/features/column-pinning.md b/.cursor/tanstack/table-main/docs/api/features/column-pinning.md new file mode 100644 index 0000000..a312b33 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/column-pinning.md @@ -0,0 +1,266 @@ +--- +title: Column Pinning APIs +id: column-pinning +--- + +## Can-Pin + +The ability for a column to be **pinned** is determined by the following: + +- `options.enablePinning` is not set to `false` +- `options.enableColumnPinning` is not set to `false` +- `columnDefinition.enablePinning` is not set to `false` + +## State + +Pinning state is stored on the table using the following shape: + +```tsx +export type ColumnPinningPosition = false | 'left' | 'right' + +export type ColumnPinningState = { + left?: string[] + right?: string[] +} + + +export type ColumnPinningTableState = { + columnPinning: ColumnPinningState +} +``` + +## Table Options + +### `enableColumnPinning` + +```tsx +enableColumnPinning?: boolean +``` + +Enables/disables column pinning for all columns in the table. + +### `onColumnPinningChange` + +```tsx +onColumnPinningChange?: OnChangeFn +``` + +If provided, this function will be called with an `updaterFn` when `state.columnPinning` changes. This overrides the default internal state management, so you will also need to supply `state.columnPinning` from your own managed state. + +## Column Def Options + +### `enablePinning` + +```tsx +enablePinning?: boolean +``` + +Enables/disables pinning for the column. + +## Table API + +### `setColumnPinning` + +```tsx +setColumnPinning: (updater: Updater) => void +``` + +Sets or updates the `state.columnPinning` state. + +### `resetColumnPinning` + +```tsx +resetColumnPinning: (defaultState?: boolean) => void +``` + +Resets the **columnPinning** state to `initialState.columnPinning`, or `true` can be passed to force a default blank state reset to `{ left: [], right: [], }`. + +### `getIsSomeColumnsPinned` + +```tsx +getIsSomeColumnsPinned: (position?: ColumnPinningPosition) => boolean +``` + +Returns whether or not any columns are pinned. Optionally specify to only check for pinned columns in either the `left` or `right` position. + +_Note: Does not account for column visibility_ + +### `getLeftHeaderGroups` + +```tsx +getLeftHeaderGroups: () => HeaderGroup[] +``` + +Returns the left pinned header groups for the table. + +### `getCenterHeaderGroups` + +```tsx +getCenterHeaderGroups: () => HeaderGroup[] +``` + +Returns the unpinned/center header groups for the table. + +### `getRightHeaderGroups` + +```tsx +getRightHeaderGroups: () => HeaderGroup[] +``` + +Returns the right pinned header groups for the table. + +### `getLeftFooterGroups` + +```tsx +getLeftFooterGroups: () => HeaderGroup[] +``` + +Returns the left pinned footer groups for the table. + +### `getCenterFooterGroups` + +```tsx +getCenterFooterGroups: () => HeaderGroup[] +``` + +Returns the unpinned/center footer groups for the table. + +### `getRightFooterGroups` + +```tsx +getRightFooterGroups: () => HeaderGroup[] +``` + +Returns the right pinned footer groups for the table. + +### `getLeftFlatHeaders` + +```tsx +getLeftFlatHeaders: () => Header[] +``` + +Returns a flat array of left pinned headers for the table, including parent headers. + +### `getCenterFlatHeaders` + +```tsx +getCenterFlatHeaders: () => Header[] +``` + +Returns a flat array of unpinned/center headers for the table, including parent headers. + +### `getRightFlatHeaders` + +```tsx +getRightFlatHeaders: () => Header[] +``` + +Returns a flat array of right pinned headers for the table, including parent headers. + +### `getLeftLeafHeaders` + +```tsx +getLeftLeafHeaders: () => Header[] +``` + +Returns a flat array of leaf-node left pinned headers for the table. + +### `getCenterLeafHeaders` + +```tsx +getCenterLeafHeaders: () => Header[] +``` + +Returns a flat array of leaf-node unpinned/center headers for the table. + +### `getRightLeafHeaders` + +```tsx +getRightLeafHeaders: () => Header[] +``` + +Returns a flat array of leaf-node right pinned headers for the table. + +### `getLeftLeafColumns` + +```tsx +getLeftLeafColumns: () => Column[] +``` + +Returns all left pinned leaf columns. + +### `getRightLeafColumns` + +```tsx +getRightLeafColumns: () => Column[] +``` + +Returns all right pinned leaf columns. + +### `getCenterLeafColumns` + +```tsx +getCenterLeafColumns: () => Column[] +``` + +Returns all center pinned (unpinned) leaf columns. + +## Column API + +### `getCanPin` + +```tsx +getCanPin: () => boolean +``` + +Returns whether or not the column can be pinned. + +### `getPinnedIndex` + +```tsx +getPinnedIndex: () => number +``` + +Returns the numeric pinned index of the column within a pinned column group. + +### `getIsPinned` + +```tsx +getIsPinned: () => ColumnPinningPosition +``` + +Returns the pinned position of the column. (`'left'`, `'right'` or `false`) + +### `pin` + +```tsx +pin: (position: ColumnPinningPosition) => void +``` + +Pins a column to the `'left'` or `'right'`, or unpins the column to the center if `false` is passed. + +## Row API + +### `getLeftVisibleCells` + +```tsx +getLeftVisibleCells: () => Cell[] +``` + +Returns all left pinned leaf cells in the row. + +### `getRightVisibleCells` + +```tsx +getRightVisibleCells: () => Cell[] +``` + +Returns all right pinned leaf cells in the row. + +### `getCenterVisibleCells` + +```tsx +getCenterVisibleCells: () => Cell[] +``` + +Returns all center pinned (unpinned) leaf cells in the row. diff --git a/.cursor/tanstack/table-main/docs/api/features/column-sizing.md b/.cursor/tanstack/table-main/docs/api/features/column-sizing.md new file mode 100644 index 0000000..0bf7631 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/column-sizing.md @@ -0,0 +1,253 @@ +--- +title: Column Sizing APIs +id: column-sizing +--- + +## State + +Column sizing state is stored on the table using the following shape: + +```tsx +export type ColumnSizingTableState = { + columnSizing: ColumnSizing + columnSizingInfo: ColumnSizingInfoState +} + +export type ColumnSizing = Record + +export type ColumnSizingInfoState = { + startOffset: null | number + startSize: null | number + deltaOffset: null | number + deltaPercentage: null | number + isResizingColumn: false | string + columnSizingStart: [string, number][] +} +``` + +## Column Def Options + +### `enableResizing` + +```tsx +enableResizing?: boolean +``` + +Enables or disables column resizing for the column. + +### `size` + +```tsx +size?: number +``` + +The desired size for the column + +### `minSize` + +```tsx +minSize?: number +``` + +The minimum allowed size for the column + +### `maxSize` + +```tsx +maxSize?: number +``` + +The maximum allowed size for the column + +## Column API + +### `getSize` + +```tsx +getSize: () => number +``` + +Returns the current size of the column + +### `getStart` + +```tsx +getStart: (position?: ColumnPinningPosition) => number +``` + +Returns the offset measurement along the row-axis (usually the x-axis for standard tables) for the column, measuring the size of all preceding columns. + +Useful for sticky or absolute positioning of columns. (e.g. `left` or `transform`) + +### `getAfter` + +```tsx +getAfter: (position?: ColumnPinningPosition) => number +``` + +Returns the offset measurement along the row-axis (usually the x-axis for standard tables) for the column, measuring the size of all succeeding columns. + +Useful for sticky or absolute positioning of columns. (e.g. `right` or `transform`) + +### `getCanResize` + +```tsx +getCanResize: () => boolean +``` + +Returns `true` if the column can be resized. + +### `getIsResizing` + +```tsx +getIsResizing: () => boolean +``` + +Returns `true` if the column is currently being resized. + +### `resetSize` + +```tsx +resetSize: () => void +``` + +Resets the column size to its initial size. + +## Header API + +### `getSize` + +```tsx +getSize: () => number +``` + +Returns the size for the header, calculated by summing the size of all leaf-columns that belong to it. + +### `getStart` + +```tsx +getStart: (position?: ColumnPinningPosition) => number +``` + +Returns the offset measurement along the row-axis (usually the x-axis for standard tables) for the header. This is effectively a sum of the offset measurements of all preceding headers. + +### `getResizeHandler` + +```tsx +getResizeHandler: () => (event: unknown) => void +``` + +Returns an event handler function that can be used to resize the header. It can be used as an: + +- `onMouseDown` handler +- `onTouchStart` handler + +The dragging and release events are automatically handled for you. + +## Table Options + +### `enableColumnResizing` + +```tsx +enableColumnResizing?: boolean +``` + +Enables/disables column resizing for \*all columns\*\*. + +### `columnResizeMode` + +```tsx +columnResizeMode?: 'onChange' | 'onEnd' +``` + +Determines when the columnSizing state is updated. `onChange` updates the state when the user is dragging the resize handle. `onEnd` updates the state when the user releases the resize handle. + +### `columnResizeDirection` + +```tsx +columnResizeDirection?: 'ltr' | 'rtl' +``` + +Enables or disables right-to-left support for resizing the column. defaults to 'ltr'. + +### `onColumnSizingChange` + +```tsx +onColumnSizingChange?: OnChangeFn +``` + +This optional function will be called when the columnSizing state changes. If you provide this function, you will be responsible for maintaining its state yourself. You can pass this state back to the table via the `state.columnSizing` table option. + +### `onColumnSizingInfoChange` + +```tsx +onColumnSizingInfoChange?: OnChangeFn +``` + +This optional function will be called when the columnSizingInfo state changes. If you provide this function, you will be responsible for maintaining its state yourself. You can pass this state back to the table via the `state.columnSizingInfo` table option. + +## Table API + +### `setColumnSizing` + +```tsx +setColumnSizing: (updater: Updater) => void +``` + +Sets the column sizing state using an updater function or a value. This will trigger the underlying `onColumnSizingChange` function if one is passed to the table options, otherwise the state will be managed automatically by the table. + +### `setColumnSizingInfo` + +```tsx +setColumnSizingInfo: (updater: Updater) => void +``` + +Sets the column sizing info state using an updater function or a value. This will trigger the underlying `onColumnSizingInfoChange` function if one is passed to the table options, otherwise the state will be managed automatically by the table. + +### `resetColumnSizing` + +```tsx +resetColumnSizing: (defaultState?: boolean) => void +``` + +Resets column sizing to its initial state. If `defaultState` is `true`, the default state for the table will be used instead of the initialValue provided to the table. + +### `resetHeaderSizeInfo` + +```tsx +resetHeaderSizeInfo: (defaultState?: boolean) => void +``` + +Resets column sizing info to its initial state. If `defaultState` is `true`, the default state for the table will be used instead of the initialValue provided to the table. + +### `getTotalSize` + +```tsx +getTotalSize: () => number +``` + +Returns the total size of the table by calculating the sum of the sizes of all leaf-columns. + +### `getLeftTotalSize` + +```tsx +getLeftTotalSize: () => number +``` + +If pinning, returns the total size of the left portion of the table by calculating the sum of the sizes of all left leaf-columns. + +### `getCenterTotalSize` + +```tsx +getCenterTotalSize: () => number +``` + +If pinning, returns the total size of the center portion of the table by calculating the sum of the sizes of all unpinned/center leaf-columns. + +### `getRightTotalSize` + +```tsx +getRightTotalSize: () => number +``` + +If pinning, returns the total size of the right portion of the table by calculating the sum of the sizes of all right leaf-columns. diff --git a/.cursor/tanstack/table-main/docs/api/features/column-visibility.md b/.cursor/tanstack/table-main/docs/api/features/column-visibility.md new file mode 100644 index 0000000..e1280e7 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/column-visibility.md @@ -0,0 +1,178 @@ +--- +title: Column Visibility APIs +id: column-visibility +--- + +## State + +Column visibility state is stored on the table using the following shape: + +```tsx +export type VisibilityState = Record + +export type VisibilityTableState = { + columnVisibility: VisibilityState +} +``` + +## Column Def Options + +### `enableHiding` + +```tsx +enableHiding?: boolean +``` + +Enables/disables hiding the column + +## Column API + +### `getCanHide` + +```tsx +getCanHide: () => boolean +``` + +Returns whether the column can be hidden + +### `getIsVisible` + +```tsx +getIsVisible: () => boolean +``` + +Returns whether the column is visible + +### `toggleVisibility` + +```tsx +toggleVisibility: (value?: boolean) => void +``` + +Toggles the column visibility + +### `getToggleVisibilityHandler` + +```tsx +getToggleVisibilityHandler: () => (event: unknown) => void +``` + +Returns a function that can be used to toggle the column visibility. This function can be used to bind to an event handler to a checkbox. + +## Table Options + +### `onColumnVisibilityChange` + +```tsx +onColumnVisibilityChange?: OnChangeFn +``` + +If provided, this function will be called with an `updaterFn` when `state.columnVisibility` changes. This overrides the default internal state management, so you will need to persist the state change either fully or partially outside of the table. + +### `enableHiding` + +```tsx +enableHiding?: boolean +``` + +Enables/disables hiding of columns. + +## Table API + +### `getVisibleFlatColumns` + +```tsx +getVisibleFlatColumns: () => Column[] +``` + +Returns a flat array of columns that are visible, including parent columns. + +### `getVisibleLeafColumns` + +```tsx +getVisibleLeafColumns: () => Column[] +``` + +Returns a flat array of leaf-node columns that are visible. + +### `getLeftVisibleLeafColumns` + +```tsx +getLeftVisibleLeafColumns: () => Column[] +``` + +If column pinning, returns a flat array of leaf-node columns that are visible in the left portion of the table. + +### `getRightVisibleLeafColumns` + +```tsx +getRightVisibleLeafColumns: () => Column[] +``` + +If column pinning, returns a flat array of leaf-node columns that are visible in the right portion of the table. + +### `getCenterVisibleLeafColumns` + +```tsx +getCenterVisibleLeafColumns: () => Column[] +``` + +If column pinning, returns a flat array of leaf-node columns that are visible in the unpinned/center portion of the table. + +### `setColumnVisibility` + +```tsx +setColumnVisibility: (updater: Updater) => void +``` + +Updates the column visibility state via an updater function or value + +### `resetColumnVisibility` + +```tsx +resetColumnVisibility: (defaultState?: boolean) => void +``` + +Resets the column visibility state to the initial state. If `defaultState` is provided, the state will be reset to `{}` + +### `toggleAllColumnsVisible` + +```tsx +toggleAllColumnsVisible: (value?: boolean) => void +``` + +Toggles the visibility of all columns + +### `getIsAllColumnsVisible` + +```tsx +getIsAllColumnsVisible: () => boolean +``` + +Returns whether all columns are visible + +### `getIsSomeColumnsVisible` + +```tsx +getIsSomeColumnsVisible: () => boolean +``` + +Returns whether some columns are visible + +### `getToggleAllColumnsVisibilityHandler` + +```tsx +getToggleAllColumnsVisibilityHandler: () => ((event: unknown) => void) +``` + +Returns a handler for toggling the visibility of all columns, meant to be bound to a `input[type=checkbox]` element. + +## Row API + +### `getVisibleCells` + +```tsx +getVisibleCells: () => Cell[] +``` + +Returns an array of cells that account for column visibility for the row. \ No newline at end of file diff --git a/.cursor/tanstack/table-main/docs/api/features/expanding.md b/.cursor/tanstack/table-main/docs/api/features/expanding.md new file mode 100644 index 0000000..af7ab0d --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/expanding.md @@ -0,0 +1,208 @@ +--- +title: Expanding APIs +id: expanding +--- + +## State + +Expanding state is stored on the table using the following shape: + +```tsx +export type ExpandedState = true | Record + +export type ExpandedTableState = { + expanded: ExpandedState +} +``` + +## Row API + +### `toggleExpanded` + +```tsx +toggleExpanded: (expanded?: boolean) => void +``` + +Toggles the expanded state (or sets it if `expanded` is provided) for the row. + +### `getIsExpanded` + +```tsx +getIsExpanded: () => boolean +``` + +Returns whether the row is expanded. + +### `getIsAllParentsExpanded` + +```tsx +getIsAllParentsExpanded: () => boolean +``` + +Returns whether all parent rows of the row are expanded. + +### `getCanExpand` + +```tsx +getCanExpand: () => boolean +``` + +Returns whether the row can be expanded. + +### `getToggleExpandedHandler` + +```tsx +getToggleExpandedHandler: () => () => void +``` + +Returns a function that can be used to toggle the expanded state of the row. This function can be used to bind to an event handler to a button. + +## Table Options + +### `manualExpanding` + +```tsx +manualExpanding?: boolean +``` + +Enables manual row expansion. If this is set to `true`, `getExpandedRowModel` will not be used to expand rows and you would be expected to perform the expansion in your own data model. This is useful if you are doing server-side expansion. + +### `onExpandedChange` + +```tsx +onExpandedChange?: OnChangeFn +``` + +This function is called when the `expanded` table state changes. If a function is provided, you will be responsible for managing this state on your own. To pass the managed state back to the table, use the `tableOptions.state.expanded` option. + +### `autoResetExpanded` + +```tsx +autoResetExpanded?: boolean +``` + +Enable this setting to automatically reset the expanded state of the table when expanding state changes. + +### `enableExpanding` + +```tsx +enableExpanding?: boolean +``` + +Enable/disable expanding for all rows. + +### `getExpandedRowModel` + +```tsx +getExpandedRowModel?: (table: Table) => () => RowModel +``` + +This function is responsible for returning the expanded row model. If this function is not provided, the table will not expand rows. You can use the default exported `getExpandedRowModel` function to get the expanded row model or implement your own. + +### `getIsRowExpanded` + +```tsx +getIsRowExpanded?: (row: Row) => boolean +``` + +If provided, allows you to override the default behavior of determining whether a row is currently expanded. + +### `getRowCanExpand` + +```tsx +getRowCanExpand?: (row: Row) => boolean +``` + +If provided, allows you to override the default behavior of determining whether a row can be expanded. + +### `paginateExpandedRows` + +```tsx +paginateExpandedRows?: boolean +``` + +If `true` expanded rows will be paginated along with the rest of the table (which means expanded rows may span multiple pages). + +If `false` expanded rows will not be considered for pagination (which means expanded rows will always render on their parents page. This also means more rows will be rendered than the set page size) + +## Table API + +### `setExpanded` + +```tsx +setExpanded: (updater: Updater) => void +``` + +Updates the expanded state of the table via an update function or value + +### `toggleAllRowsExpanded` + +```tsx +toggleAllRowsExpanded: (expanded?: boolean) => void +``` + +Toggles the expanded state for all rows. Optionally, provide a value to set the expanded state to. + +### `resetExpanded` + +```tsx +resetExpanded: (defaultState?: boolean) => void +``` + +Reset the expanded state of the table to the initial state. If `defaultState` is provided, the expanded state will be reset to `{}` + +### `getCanSomeRowsExpand` + +```tsx +getCanSomeRowsExpand: () => boolean +``` + +Returns whether there are any rows that can be expanded. + +### `getToggleAllRowsExpandedHandler` + +```tsx +getToggleAllRowsExpandedHandler: () => (event: unknown) => void +``` + +Returns a handler that can be used to toggle the expanded state of all rows. This handler is meant to be used with an `input[type=checkbox]` element. + +### `getIsSomeRowsExpanded` + +```tsx +getIsSomeRowsExpanded: () => boolean +``` + +Returns whether there are any rows that are currently expanded. + +### `getIsAllRowsExpanded` + +```tsx +getIsAllRowsExpanded: () => boolean +``` + +Returns whether all rows are currently expanded. + +### `getExpandedDepth` + +```tsx +getExpandedDepth: () => number +``` + +Returns the maximum depth of the expanded rows. + +### `getExpandedRowModel` + +```tsx +getExpandedRowModel: () => RowModel +``` + +Returns the row model after expansion has been applied. + +### `getPreExpandedRowModel` + +```tsx +getPreExpandedRowModel: () => RowModel +``` + +Returns the row model before expansion has been applied. diff --git a/.cursor/tanstack/table-main/docs/api/features/filters.md b/.cursor/tanstack/table-main/docs/api/features/filters.md new file mode 100644 index 0000000..ea3d718 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/filters.md @@ -0,0 +1,13 @@ +--- +title: Filter APIs +id: filters +--- + + + +The Filtering API docs are now split into multiple API doc pages: + +- [Column Faceting](../api/features/column-faceting) +- [Global Faceting](../api/features/global-faceting) +- [Column Filtering](../api/features/column-filtering) +- [Global Filtering](../api/features/global-filtering) diff --git a/.cursor/tanstack/table-main/docs/api/features/global-faceting.md b/.cursor/tanstack/table-main/docs/api/features/global-faceting.md new file mode 100644 index 0000000..820df88 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/global-faceting.md @@ -0,0 +1,30 @@ +--- +title: Global Faceting APIs +id: global-faceting +--- + +## Table API + +### `getGlobalFacetedRowModel` + +```tsx +getGlobalFacetedRowModel: () => RowModel +``` + +Returns the faceted row model for the global filter. + +### `getGlobalFacetedUniqueValues` + +```tsx +getGlobalFacetedUniqueValues: () => Map +``` + +Returns the faceted unique values for the global filter. + +### `getGlobalFacetedMinMaxValues` + +```tsx +getGlobalFacetedMinMaxValues: () => [number, number] +``` + +Returns the faceted min and max values for the global filter. diff --git a/.cursor/tanstack/table-main/docs/api/features/global-filtering.md b/.cursor/tanstack/table-main/docs/api/features/global-filtering.md new file mode 100644 index 0000000..7f7f27e --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/global-filtering.md @@ -0,0 +1,291 @@ +--- +title: Global Filtering APIs +id: global-filtering +--- + +## Can-Filter + +The ability for a column to be **globally** filtered is determined by the following: + +- The column was defined a valid `accessorKey`/`accessorFn`. +- If provided, `options.getColumnCanGlobalFilter` returns `true` for the given column. If it is not provided, the column is assumed to be globally filterable if the value in the first row is a `string` or `number` type. +- `column.enableColumnFilter` is not set to `false` +- `options.enableColumnFilters` is not set to `false` +- `options.enableFilters` is not set to `false` + +## State + +Filter state is stored on the table using the following shape: + +```tsx +export interface GlobalFilterTableState { + globalFilter: any +} +``` + +## Filter Functions + +You can use the same filter functions that are available for column filtering for global filtering. See the [Column Filtering APIs](../api/features/column-filtering) to learn more about filter functions. + +#### Using Filter Functions + +Filter functions can be used/referenced/defined by passing the following to `options.globalFilterFn`: + +- A `string` that references a built-in filter function +- A function directly provided to the `options.globalFilterFn` option + +The final list of filter functions available for the `tableOptions.globalFilterFn` options use the following type: + +```tsx +export type FilterFnOption = + | 'auto' + | BuiltInFilterFn + | FilterFn +``` + +#### Filter Meta + +Filtering data can often expose additional information about the data that can be used to aid other future operations on the same data. A good example of this concept is a ranking-system like that of [`match-sorter`](https://github.com/kentcdodds/match-sorter) that simultaneously ranks, filters and sorts data. While utilities like `match-sorter` make a lot of sense for single-dimensional filter+sort tasks, the decoupled filtering/sorting architecture of building a table makes them very difficult and slow to use. + +To make a ranking/filtering/sorting system work with tables, `filterFn`s can optionally mark results with a **filter meta** value that can be used later to sort/group/etc the data to your liking. This is done by calling the `addMeta` function supplied to your custom `filterFn`. + +Below is an example using our own `match-sorter-utils` package (a utility fork of `match-sorter`) to rank, filter, and sort the data + +```tsx +import { sortingFns } from '@tanstack/[adapter]-table' + +import { rankItem, compareItems } from '@tanstack/match-sorter-utils' + +const fuzzyFilter = (row, columnId, value, addMeta) => { + // Rank the item + const itemRank = rankItem(row.getValue(columnId), value) + + // Store the ranking info + addMeta(itemRank) + + // Return if the item should be filtered in/out + return itemRank.passed +} + +const fuzzySort = (rowA, rowB, columnId) => { + let dir = 0 + + // Only sort by rank if the column has ranking information + if (rowA.columnFiltersMeta[columnId]) { + dir = compareItems( + rowA.columnFiltersMeta[columnId]!, + rowB.columnFiltersMeta[columnId]! + ) + } + + // Provide an alphanumeric fallback for when the item ranks are equal + return dir === 0 ? sortingFns.alphanumeric(rowA, rowB, columnId) : dir +} +``` + +## Column Def Options + +### `enableGlobalFilter` + +```tsx +enableGlobalFilter?: boolean +``` + +Enables/disables the **global** filter for this column. + +## Column API + +### `getCanGlobalFilter` + +```tsx +getCanGlobalFilter: () => boolean +``` + +Returns whether or not the column can be **globally** filtered. Set to `false` to disable a column from being scanned during global filtering. + +## Row API + +### `columnFiltersMeta` + +```tsx +columnFiltersMeta: Record +``` + +The column filters meta map for the row. This object tracks any filter meta for a row as optionally provided during the filtering process. + +## Table Options + +### `filterFns` + +```tsx +filterFns?: Record +``` + +This option allows you to define custom filter functions that can be referenced in a column's `filterFn` option by their key. +Example: + +```tsx +declare module '@tanstack/table-core' { + interface FilterFns { + myCustomFilter: FilterFn + } +} + +const column = columnHelper.data('key', { + filterFn: 'myCustomFilter', +}) + +const table = useReactTable({ + columns: [column], + filterFns: { + myCustomFilter: (rows, columnIds, filterValue) => { + // return the filtered rows + }, + }, +}) +``` + +### `filterFromLeafRows` + +```tsx +filterFromLeafRows?: boolean +``` + +By default, filtering is done from parent rows down (so if a parent row is filtered out, all of its children will be filtered out as well). Setting this option to `true` will cause filtering to be done from leaf rows up (which means parent rows will be included so long as one of their child or grand-child rows is also included). + +### `maxLeafRowFilterDepth` + +```tsx +maxLeafRowFilterDepth?: number +``` + +By default, filtering is done for all rows (max depth of 100), no matter if they are root level parent rows or the child leaf rows of a parent row. Setting this option to `0` will cause filtering to only be applied to the root level parent rows, with all sub-rows remaining unfiltered. Similarly, setting this option to `1` will cause filtering to only be applied to child leaf rows 1 level deep, and so on. + +This is useful for situations where you want a row's entire child hierarchy to be visible regardless of the applied filter. + +### `enableFilters` + +```tsx +enableFilters?: boolean +``` + +Enables/disables all filters for the table. + +### `manualFiltering` + +```tsx +manualFiltering?: boolean +``` + +Disables the `getFilteredRowModel` from being used to filter data. This may be useful if your table needs to dynamically support both client-side and server-side filtering. + +### `getFilteredRowModel` + +```tsx +getFilteredRowModel?: ( + table: Table +) => () => RowModel +``` + +If provided, this function is called **once** per table and should return a **new function** which will calculate and return the row model for the table when it's filtered. + +- For server-side filtering, this function is unnecessary and can be ignored since the server should already return the filtered row model. +- For client-side filtering, this function is required. A default implementation is provided via any table adapter's `{ getFilteredRowModel }` export. + +Example: + +```tsx +import { getFilteredRowModel } from '@tanstack/[adapter]-table' + + getFilteredRowModel: getFilteredRowModel(), +}) +``` + +### `globalFilterFn` + +```tsx +globalFilterFn?: FilterFn | keyof FilterFns | keyof BuiltInFilterFns +``` + +The filter function to use for global filtering. + +Options: + +- A `string` referencing a [built-in filter function](#filter-functions)) +- A `string` that references a custom filter functions provided via the `tableOptions.filterFns` option +- A [custom filter function](#filter-functions) + +### `onGlobalFilterChange` + +```tsx +onGlobalFilterChange?: OnChangeFn +``` + +If provided, this function will be called with an `updaterFn` when `state.globalFilter` changes. This overrides the default internal state management, so you will need to persist the state change either fully or partially outside of the table. + +### `enableGlobalFilter` + +```tsx +enableGlobalFilter?: boolean +``` + +Enables/disables the global filter for the table. + +### `getColumnCanGlobalFilter` + +```tsx +getColumnCanGlobalFilter?: (column: Column) => boolean +``` + +If provided, this function will be called with the column and should return `true` or `false` to indicate whether this column should be used for global filtering. +This is useful if the column can contain data that is not `string` or `number` (i.e. `undefined`). + +## Table API + +### `getPreFilteredRowModel` + +```tsx +getPreFilteredRowModel: () => RowModel +``` + +Returns the row model for the table before any **column** filtering has been applied. + +### `getFilteredRowModel` + +```tsx +getFilteredRowModel: () => RowModel +``` + +Returns the row model for the table after **column** filtering has been applied. + +### `setGlobalFilter` + +```tsx +setGlobalFilter: (updater: Updater) => void +``` + +Sets or updates the `state.globalFilter` state. + +### `resetGlobalFilter` + +```tsx +resetGlobalFilter: (defaultState?: boolean) => void +``` + +Resets the **globalFilter** state to `initialState.globalFilter`, or `true` can be passed to force a default blank state reset to `undefined`. + +### `getGlobalAutoFilterFn` + +```tsx +getGlobalAutoFilterFn: (columnId: string) => FilterFn | undefined +``` + +Currently, this function returns the built-in `includesString` filter function. In future releases, it may return more dynamic filter functions based on the nature of the data provided. + +### `getGlobalFilterFn` + +```tsx +getGlobalFilterFn: (columnId: string) => FilterFn | undefined +``` + +Returns the global filter function (either user-defined or automatic, depending on configuration) for the table. diff --git a/.cursor/tanstack/table-main/docs/api/features/grouping.md b/.cursor/tanstack/table-main/docs/api/features/grouping.md new file mode 100644 index 0000000..b9c2163 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/grouping.md @@ -0,0 +1,353 @@ +--- +title: Grouping APIs +id: grouping +--- + +## State + +Grouping state is stored on the table using the following shape: + +```tsx +export type GroupingState = string[] + +export type GroupingTableState = { + grouping: GroupingState +} +``` + +## Aggregation Functions + +The following aggregation functions are built-in to the table core: + +- `sum` + - Sums the values of a group of rows +- `min` + - Finds the minimum value of a group of rows +- `max` + - Finds the maximum value of a group of rows +- `extent` + - Finds the minimum and maximum values of a group of rows +- `mean` + - Finds the mean/average value of a group of rows +- `median` + - Finds the median value of a group of rows +- `unique` + - Finds the unique values of a group of rows +- `uniqueCount` + - Finds the number of unique values of a group of rows +- `count` + - Calculates the number of rows in a group + +Every grouping function receives: + +- A function to retrieve the leaf values of the groups rows +- A function to retrieve the immediate-child values of the groups rows + +and should return a value (usually primitive) to build the aggregated row model. + +This is the type signature for every aggregation function: + +```tsx +export type AggregationFn = ( + getLeafRows: () => Row[], + getChildRows: () => Row[] +) => any +``` + +#### Using Aggregation Functions + +Aggregation functions can be used/referenced/defined by passing the following to `columnDefinition.aggregationFn`: + +- A `string` that references a built-in aggregation function +- A `string` that references a custom aggregation functions provided via the `tableOptions.aggregationFns` option +- A function directly provided to the `columnDefinition.aggregationFn` option + +The final list of aggregation functions available for the `columnDef.aggregationFn` use the following type: + +```tsx +export type AggregationFnOption = + | 'auto' + | keyof AggregationFns + | BuiltInAggregationFn + | AggregationFn +``` + +## Column Def Options + +### `aggregationFn` + +```tsx +aggregationFn?: AggregationFn | keyof AggregationFns | keyof BuiltInAggregationFns +``` + +The aggregation function to use with this column. + +Options: + +- A `string` referencing a [built-in aggregation function](#aggregation-functions)) +- A [custom aggregation function](#aggregation-functions) + +### `aggregatedCell` + +```tsx +aggregatedCell?: Renderable< + { + table: Table + row: Row + column: Column + cell: Cell + getValue: () => any + renderValue: () => any + } +> +``` + +The cell to display each row for the column if the cell is an aggregate. If a function is passed, it will be passed a props object with the context of the cell and should return the property type for your adapter (the exact type depends on the adapter being used). + +### `enableGrouping` + +```tsx +enableGrouping?: boolean +``` + +Enables/disables grouping for this column. + +### `getGroupingValue` + +```tsx +getGroupingValue?: (row: TData) => any +``` + +Specify a value to be used for grouping rows on this column. If this option is not specified, the value derived from `accessorKey` / `accessorFn` will be used instead. + +## Column API + +### `aggregationFn` + +```tsx +aggregationFn?: AggregationFnOption +``` + +The resolved aggregation function for the column. + +### `getCanGroup` + +```tsx +getCanGroup: () => boolean +``` + +Returns whether or not the column can be grouped. + +### `getIsGrouped` + +```tsx +getIsGrouped: () => boolean +``` + +Returns whether or not the column is currently grouped. + +### `getGroupedIndex` + +```tsx +getGroupedIndex: () => number +``` + +Returns the index of the column in the grouping state. + +### `toggleGrouping` + +```tsx +toggleGrouping: () => void +``` + +Toggles the grouping state of the column. + +### `getToggleGroupingHandler` + +```tsx +getToggleGroupingHandler: () => () => void +``` + +Returns a function that toggles the grouping state of the column. This is useful for passing to the `onClick` prop of a button. + +### `getAutoAggregationFn` + +```tsx +getAutoAggregationFn: () => AggregationFn | undefined +``` + +Returns the automatically inferred aggregation function for the column. + +### `getAggregationFn` + +```tsx +getAggregationFn: () => AggregationFn | undefined +``` + +Returns the aggregation function for the column. + +## Row API + +### `groupingColumnId` + +```tsx +groupingColumnId?: string +``` + +If this row is grouped, this is the id of the column that this row is grouped by. + +### `groupingValue` + +```tsx +groupingValue?: any +``` + +If this row is grouped, this is the unique/shared value for the `groupingColumnId` for all of the rows in this group. + +### `getIsGrouped` + +```tsx +getIsGrouped: () => boolean +``` + +Returns whether or not the row is currently grouped. + +### `getGroupingValue` + +```tsx +getGroupingValue: (columnId: string) => unknown +``` + +Returns the grouping value for any row and column (including leaf rows). + +## Table Options + +### `aggregationFns` + +```tsx +aggregationFns?: Record +``` + +This option allows you to define custom aggregation functions that can be referenced in a column's `aggregationFn` option by their key. +Example: + +```tsx +declare module '@tanstack/table-core' { + interface AggregationFns { + myCustomAggregation: AggregationFn + } +} + +const column = columnHelper.data('key', { + aggregationFn: 'myCustomAggregation', +}) + +const table = useReactTable({ + columns: [column], + aggregationFns: { + myCustomAggregation: (columnId, leafRows, childRows) => { + // return the aggregated value + }, + }, +}) +``` + +### `manualGrouping` + +```tsx +manualGrouping?: boolean +``` + +Enables manual grouping. If this option is set to `true`, the table will not automatically group rows using `getGroupedRowModel()` and instead will expect you to manually group the rows before passing them to the table. This is useful if you are doing server-side grouping and aggregation. + +### `onGroupingChange` + +```tsx +onGroupingChange?: OnChangeFn +``` + +If this function is provided, it will be called when the grouping state changes and you will be expected to manage the state yourself. You can pass the managed state back to the table via the `tableOptions.state.grouping` option. + +### `enableGrouping` + +```tsx +enableGrouping?: boolean +``` + +Enables/disables grouping for all columns. + +### `getGroupedRowModel` + +```tsx +getGroupedRowModel?: (table: Table) => () => RowModel +``` + +Returns the row model after grouping has taken place, but no further. + +### `groupedColumnMode` + +```tsx +groupedColumnMode?: false | 'reorder' | 'remove' // default: `reorder` +``` + +Grouping columns are automatically reordered by default to the start of the columns list. If you would rather remove them or leave them as-is, set the appropriate mode here. + +## Table API + +### `setGrouping` + +```tsx +setGrouping: (updater: Updater) => void +``` + +Sets or updates the `state.grouping` state. + +### `resetGrouping` + +```tsx +resetGrouping: (defaultState?: boolean) => void +``` + +Resets the **grouping** state to `initialState.grouping`, or `true` can be passed to force a default blank state reset to `[]`. + +### `getPreGroupedRowModel` + +```tsx +getPreGroupedRowModel: () => RowModel +``` + +Returns the row model for the table before any grouping has been applied. + +### `getGroupedRowModel` + +```tsx +getGroupedRowModel: () => RowModel +``` + +Returns the row model for the table after grouping has been applied. + +## Cell API + +### `getIsAggregated` + +```tsx +getIsAggregated: () => boolean +``` + +Returns whether or not the cell is currently aggregated. + +### `getIsGrouped` + +```tsx +getIsGrouped: () => boolean +``` + +Returns whether or not the cell is currently grouped. + +### `getIsPlaceholder` + +```tsx +getIsPlaceholder: () => boolean +``` + +Returns whether or not the cell is currently a placeholder. \ No newline at end of file diff --git a/.cursor/tanstack/table-main/docs/api/features/pagination.md b/.cursor/tanstack/table-main/docs/api/features/pagination.md new file mode 100644 index 0000000..5e80d5a --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/pagination.md @@ -0,0 +1,207 @@ +--- +title: Pagination APIs +id: pagination +--- + +## State + +Pagination state is stored on the table using the following shape: + +```tsx +export type PaginationState = { + pageIndex: number + pageSize: number +} + +export type PaginationTableState = { + pagination: PaginationState +} + +export type PaginationInitialTableState = { + pagination?: Partial +} +``` + +## Table Options + +### `manualPagination` + +```tsx +manualPagination?: boolean +``` + +Enables manual pagination. If this option is set to `true`, the table will not automatically paginate rows using `getPaginationRowModel()` and instead will expect you to manually paginate the rows before passing them to the table. This is useful if you are doing server-side pagination and aggregation. + +### `pageCount` + +```tsx +pageCount?: number +``` + +When manually controlling pagination, you can supply a total `pageCount` value to the table if you know it. If you do not know how many pages there are, you can set this to `-1`. Alternatively, you can provide a `rowCount` value and the table will calculate the `pageCount` internally. + +### `rowCount` + +```tsx +rowCount?: number +``` + +When manually controlling pagination, you can supply a total `rowCount` value to the table if you know it. `pageCount` will be calculated internally from `rowCount` and `pageSize`. + +### `autoResetPageIndex` + +```tsx +autoResetPageIndex?: boolean +``` + +If set to `true`, pagination will be reset to the first page when page-altering state changes eg. `data` is updated, filters change, grouping changes, etc. + +> 🧠 Note: This option defaults to `false` if `manualPagination` is set to `true` + +### `onPaginationChange` + +```tsx +onPaginationChange?: OnChangeFn +``` + +If this function is provided, it will be called when the pagination state changes and you will be expected to manage the state yourself. You can pass the managed state back to the table via the `tableOptions.state.pagination` option. + +### `getPaginationRowModel` + +```tsx +getPaginationRowModel?: (table: Table) => () => RowModel +``` + +Returns the row model after pagination has taken place, but no further. + +Pagination columns are automatically reordered by default to the start of the columns list. If you would rather remove them or leave them as-is, set the appropriate mode here. + +## Table API + +### `setPagination` + +```tsx +setPagination: (updater: Updater) => void +``` + +Sets or updates the `state.pagination` state. + +### `resetPagination` + +```tsx +resetPagination: (defaultState?: boolean) => void +``` + +Resets the **pagination** state to `initialState.pagination`, or `true` can be passed to force a default blank state reset to `[]`. + +### `setPageIndex` + +```tsx +setPageIndex: (updater: Updater) => void +``` + +Updates the page index using the provided function or value. + +### `resetPageIndex` + +```tsx +resetPageIndex: (defaultState?: boolean) => void +``` + +Resets the page index to its initial state. If `defaultState` is `true`, the page index will be reset to `0` regardless of initial state. + +### `setPageSize` + +```tsx +setPageSize: (updater: Updater) => void +``` + +Updates the page size using the provided function or value. + +### `resetPageSize` + +```tsx +resetPageSize: (defaultState?: boolean) => void +``` + +Resets the page size to its initial state. If `defaultState` is `true`, the page size will be reset to `10` regardless of initial state. + +### `getPageOptions` + +```tsx +getPageOptions: () => number[] +``` + +Returns an array of page options (zero-index-based) for the current page size. + +### `getCanPreviousPage` + +```tsx +getCanPreviousPage: () => boolean +``` + +Returns whether the table can go to the previous page. + +### `getCanNextPage` + +```tsx +getCanNextPage: () => boolean +``` + +Returns whether the table can go to the next page. + +### `previousPage` + +```tsx +previousPage: () => void +``` + +Decrements the page index by one, if possible. + +### `nextPage` + +```tsx +nextPage: () => void +``` + +Increments the page index by one, if possible. + +### `firstPage` + +```tsx +firstPage: () => void +``` + +Sets the page index to `0`. + +### `lastPage` + +```tsx +lastPage: () => void +``` + +Sets the page index to the last available page. + +### `getPageCount` + +```tsx +getPageCount: () => number +``` + +Returns the page count. If manually paginating or controlling the pagination state, this will come directly from the `options.pageCount` table option, otherwise it will be calculated from the table data using the total row count and current page size. + +### `getPrePaginationRowModel` + +```tsx +getPrePaginationRowModel: () => RowModel +``` + +Returns the row model for the table before any pagination has been applied. + +### `getPaginationRowModel` + +```tsx +getPaginationRowModel: () => RowModel +``` + +Returns the row model for the table after pagination has been applied. diff --git a/.cursor/tanstack/table-main/docs/api/features/pinning.md b/.cursor/tanstack/table-main/docs/api/features/pinning.md new file mode 100644 index 0000000..dd9f58c --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/pinning.md @@ -0,0 +1,11 @@ +--- +title: Pinning APIs +id: pinning +--- + + + +The pinning apis are now split into multiple api pages: + +- [Column Pinning](../api/features/column-pinning) +- [Row Pinning](../api/features/row-pinning) diff --git a/.cursor/tanstack/table-main/docs/api/features/row-pinning.md b/.cursor/tanstack/table-main/docs/api/features/row-pinning.md new file mode 100644 index 0000000..52e46d5 --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/row-pinning.md @@ -0,0 +1,138 @@ +--- +title: Row Pinning APIs +id: row-pinning +--- + +## Can-Pin + +The ability for a row to be **pinned** is determined by the following: + +- `options.enableRowPinning` resolves to `true` +- `options.enablePinning` is not set to `false` + +## State + +Pinning state is stored on the table using the following shape: + +```tsx +export type RowPinningPosition = false | 'top' | 'bottom' + +export type RowPinningState = { + top?: string[] + bottom?: string[] +} + +export type RowPinningRowState = { + rowPinning: RowPinningState +} +``` + +## Table Options + +### `enableRowPinning` + +```tsx +enableRowPinning?: boolean | ((row: Row) => boolean) +``` + +Enables/disables row pinning for all rows in the table. + +### `keepPinnedRows` + +```tsx +keepPinnedRows?: boolean +``` + +When `false`, pinned rows will not be visible if they are filtered or paginated out of the table. When `true`, pinned rows will always be visible regardless of filtering or pagination. Defaults to `true`. + +### `onRowPinningChange` + +```tsx +onRowPinningChange?: OnChangeFn +``` + +If provided, this function will be called with an `updaterFn` when `state.rowPinning` changes. This overrides the default internal state management, so you will also need to supply `state.rowPinning` from your own managed state. + +## Table API + +### `setRowPinning` + +```tsx +setRowPinning: (updater: Updater) => void +``` + +Sets or updates the `state.rowPinning` state. + +### `resetRowPinning` + +```tsx +resetRowPinning: (defaultState?: boolean) => void +``` + +Resets the **rowPinning** state to `initialState.rowPinning`, or `true` can be passed to force a default blank state reset to `{}`. + +### `getIsSomeRowsPinned` + +```tsx +getIsSomeRowsPinned: (position?: RowPinningPosition) => boolean +``` + +Returns whether or not any rows are pinned. Optionally specify to only check for pinned rows in either the `top` or `bottom` position. + +### `getTopRows` + +```tsx +getTopRows: () => Row[] +``` + +Returns all top pinned rows. + +### `getBottomRows` + +```tsx +getBottomRows: () => Row[] +``` + +Returns all bottom pinned rows. + +### `getCenterRows` + +```tsx +getCenterRows: () => Row[] +``` + +Returns all rows that are not pinned to the top or bottom. + +## Row API + +### `pin` + +```tsx +pin: (position: RowPinningPosition) => void +``` + +Pins a row to the `'top'` or `'bottom'`, or unpins the row to the center if `false` is passed. + +### `getCanPin` + +```tsx +getCanPin: () => boolean +``` + +Returns whether or not the row can be pinned. + +### `getIsPinned` + +```tsx +getIsPinned: () => RowPinningPosition +``` + +Returns the pinned position of the row. (`'top'`, `'bottom'` or `false`) + +### `getPinnedIndex` + +```tsx +getPinnedIndex: () => number +``` + +Returns the numeric pinned index of the row within a pinned row group. \ No newline at end of file diff --git a/.cursor/tanstack/table-main/docs/api/features/row-selection.md b/.cursor/tanstack/table-main/docs/api/features/row-selection.md new file mode 100644 index 0000000..abbbb2a --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/row-selection.md @@ -0,0 +1,230 @@ +--- +title: Row Selection APIs +id: row-selection +--- + +## State + +Row selection state is stored on the table using the following shape: + +```tsx +export type RowSelectionState = Record + +export type RowSelectionTableState = { + rowSelection: RowSelectionState +} +``` + +By default, the row selection state uses the index of each row as the row identifiers. Row selection state can instead be tracked with a custom unique row id by passing in a custom [getRowId](../core/table.md#getrowid) function to the the table. + +## Table Options + +### `enableRowSelection` + +```tsx +enableRowSelection?: boolean | ((row: Row) => boolean) +``` + +- Enables/disables row selection for all rows in the table OR +- A function that given a row, returns whether to enable/disable row selection for that row + +### `enableMultiRowSelection` + +```tsx +enableMultiRowSelection?: boolean | ((row: Row) => boolean) +``` + +- Enables/disables multiple row selection for all rows in the table OR +- A function that given a row, returns whether to enable/disable multiple row selection for that row's children/grandchildren + +### `enableSubRowSelection` + +```tsx +enableSubRowSelection?: boolean | ((row: Row) => boolean) +``` + +Enables/disables automatic sub-row selection when a parent row is selected, or a function that enables/disables automatic sub-row selection for each row. + +(Use in combination with expanding or grouping features) + +### `onRowSelectionChange` + +```tsx +onRowSelectionChange?: OnChangeFn +``` + +If provided, this function will be called with an `updaterFn` when `state.rowSelection` changes. This overrides the default internal state management, so you will need to persist the state change either fully or partially outside of the table. + +## Table API + +### `getToggleAllRowsSelectedHandler` + +```tsx +getToggleAllRowsSelectedHandler: () => (event: unknown) => void +``` + +Returns a handler that can be used to toggle all rows in the table. + +### `getToggleAllPageRowsSelectedHandler` + +```tsx +getToggleAllPageRowsSelectedHandler: () => (event: unknown) => void +``` + +Returns a handler that can be used to toggle all rows on the current page. + +### `setRowSelection` + +```tsx +setRowSelection: (updater: Updater) => void +``` + +Sets or updates the `state.rowSelection` state. + +### `resetRowSelection` + +```tsx +resetRowSelection: (defaultState?: boolean) => void +``` + +Resets the **rowSelection** state to the `initialState.rowSelection`, or `true` can be passed to force a default blank state reset to `{}`. + +### `getIsAllRowsSelected` + +```tsx +getIsAllRowsSelected: () => boolean +``` + +Returns whether or not all rows in the table are selected. + +### `getIsAllPageRowsSelected` + +```tsx +getIsAllPageRowsSelected: () => boolean +``` + +Returns whether or not all rows on the current page are selected. + +### `getIsSomeRowsSelected` + +```tsx +getIsSomeRowsSelected: () => boolean +``` + +Returns whether or not any rows in the table are selected. + +NOTE: Returns `false` if all rows are selected. + +### `getIsSomePageRowsSelected` + +```tsx +getIsSomePageRowsSelected: () => boolean +``` + +Returns whether or not any rows on the current page are selected. + +### `toggleAllRowsSelected` + +```tsx +toggleAllRowsSelected: (value: boolean) => void +``` + +Selects/deselects all rows in the table. + +### `toggleAllPageRowsSelected` + +```tsx +toggleAllPageRowsSelected: (value: boolean) => void +``` + +Selects/deselects all rows on the current page. + +### `getPreSelectedRowModel` + +```tsx +getPreSelectedRowModel: () => RowModel +``` + +### `getSelectedRowModel` + +```tsx +getSelectedRowModel: () => RowModel +``` + +### `getFilteredSelectedRowModel` + +```tsx +getFilteredSelectedRowModel: () => RowModel +``` + +### `getGroupedSelectedRowModel` + +```tsx +getGroupedSelectedRowModel: () => RowModel +``` + +## Row API + +### `getIsSelected` + +```tsx +getIsSelected: () => boolean +``` + +Returns whether or not the row is selected. + +### `getIsSomeSelected` + +```tsx +getIsSomeSelected: () => boolean +``` + +Returns whether or not some of the row's sub rows are selected. + +### `getIsAllSubRowsSelected` + +```tsx +getIsAllSubRowsSelected: () => boolean +``` + +Returns whether or not all of the row's sub rows are selected. + +### `getCanSelect` + +```tsx +getCanSelect: () => boolean +``` + +Returns whether or not the row can be selected. + +### `getCanMultiSelect` + +```tsx +getCanMultiSelect: () => boolean +``` + +Returns whether or not the row can multi-select. + +### `getCanSelectSubRows` + +```tsx +getCanSelectSubRows: () => boolean +``` + +Returns whether or not the row can select sub rows automatically when the parent row is selected. + +### `toggleSelected` + +```tsx +toggleSelected: (value?: boolean) => void +``` + +Selects/deselects the row. + +### `getToggleSelectedHandler` + +```tsx +getToggleSelectedHandler: () => (event: unknown) => void +``` + +Returns a handler that can be used to toggle the row. diff --git a/.cursor/tanstack/table-main/docs/api/features/sorting.md b/.cursor/tanstack/table-main/docs/api/features/sorting.md new file mode 100644 index 0000000..e5b60ee --- /dev/null +++ b/.cursor/tanstack/table-main/docs/api/features/sorting.md @@ -0,0 +1,385 @@ +--- +title: Sorting APIs +id: sorting +--- + +## State + +Sorting state is stored on the table using the following shape: + +```tsx +export type SortDirection = 'asc' | 'desc' + +export type ColumnSort = { + id: string + desc: boolean +} + +export type SortingState = ColumnSort[] + +export type SortingTableState = { + sorting: SortingState +} +``` + +## Sorting Functions + +The following sorting functions are built-in to the table core: + +- `alphanumeric` + - Sorts by mixed alphanumeric values without case-sensitivity. Slower, but more accurate if your strings contain numbers that need to be naturally sorted. +- `alphanumericCaseSensitive` + - Sorts by mixed alphanumeric values with case-sensitivity. Slower, but more accurate if your strings contain numbers that need to be naturally sorted. +- `text` + - Sorts by text/string values without case-sensitivity. Faster, but less accurate if your strings contain numbers that need to be naturally sorted. +- `textCaseSensitive` + - Sorts by text/string values with case-sensitivity. Faster, but less accurate if your strings contain numbers that need to be naturally sorted. +- `datetime` + - Sorts by time, use this if your values are `Date` objects. +- `basic` + - Sorts using a basic/standard `a > b ? 1 : a < b ? -1 : 0` comparison. This is the fastest sorting function, but may not be the most accurate. + +Every sorting function receives 2 rows and a column ID and are expected to compare the two rows using the column ID to return `-1`, `0`, or `1` in ascending order. Here's a cheat sheet: + +| Return | Ascending Order | +| ------ | --------------- | +| `-1` | `a < b` | +| `0` | `a === b` | +| `1` | `a > b` | + +This is the type signature for every sorting function: + +```tsx +export type SortingFn = { + (rowA: Row, rowB: Row, columnId: string): number +} +``` + +#### Using Sorting Functions + +Sorting functions can be used/referenced/defined by passing the following to `columnDefinition.sortingFn`: + +- A `string` that references a built-in sorting function +- A `string` that references a custom sorting functions provided via the `tableOptions.sortingFns` option +- A function directly provided to the `columnDefinition.sortingFn` option + +The final list of sorting functions available for the `columnDef.sortingFn` use the following type: + +```tsx +export type SortingFnOption = + | 'auto' + | SortingFns + | BuiltInSortingFns + | SortingFn +``` + +## Column Def Options + +### `sortingFn` + +```tsx +sortingFn?: SortingFn | keyof SortingFns | keyof BuiltInSortingFns +``` + +The sorting function to use with this column. + +Options: + +- A `string` referencing a [built-in sorting function](#sorting-functions)) +- A [custom sorting function](#sorting-functions) + +### `sortDescFirst` + +```tsx +sortDescFirst?: boolean +``` + +Set to `true` for sorting toggles on this column to start in the descending direction. + +### `enableSorting` + +```tsx +enableSorting?: boolean +``` + +Enables/Disables sorting for this column. + +### `enableMultiSort` + +```tsx +enableMultiSort?: boolean +``` + +Enables/Disables multi-sorting for this column. + +### `invertSorting` + +```tsx +invertSorting?: boolean +``` + +Inverts the order of the sorting for this column. This is useful for values that have an inverted best/worst scale where lower numbers are better, eg. a ranking (1st, 2nd, 3rd) or golf-like scoring + +### `sortUndefined` + +```tsx +sortUndefined?: 'first' | 'last' | false | -1 | 1 // defaults to 1 +``` + +- `'first'` + - Undefined values will be pushed to the beginning of the list +- `'last'` + - Undefined values will be pushed to the end of the list +- `false` + - Undefined values will be considered tied and need to be sorted by the next column filter or original index (whichever applies) +- `-1` + - Undefined values will be sorted with higher priority (ascending) (if ascending, undefined will appear on the beginning of the list) +- `1` + - Undefined values will be sorted with lower priority (descending) (if ascending, undefined will appear on the end of the list) + +> NOTE: `'first'` and `'last'` options are new in v8.16.0 + +## Column API + +### `getAutoSortingFn` + +```tsx +getAutoSortingFn: () => SortingFn +``` + +Returns a sorting function automatically inferred based on the columns values. + +### `getAutoSortDir` + +```tsx +getAutoSortDir: () => SortDirection +``` + +Returns a sort direction automatically inferred based on the columns values. + +### `getSortingFn` + +```tsx +getSortingFn: () => SortingFn +``` + +Returns the resolved sorting function to be used for this column + +### `getNextSortingOrder` + +```tsx +getNextSortingOrder: () => SortDirection | false +``` + +Returns the next sorting order. + +### `getCanSort` + +```tsx +getCanSort: () => boolean +``` + +Returns whether this column can be sorted. + +### `getCanMultiSort` + +```tsx +getCanMultiSort: () => boolean +``` + +Returns whether this column can be multi-sorted. + +### `getSortIndex` + +```tsx +getSortIndex: () => number +``` + +Returns the index position of this column's sorting within the sorting state + +### `getIsSorted` + +```tsx +getIsSorted: () => false | SortDirection +``` + +Returns whether this column is sorted. + +### `getFirstSortDir` + +```tsx +getFirstSortDir: () => SortDirection +``` + +Returns the first direction that should be used when sorting this column. + +### `clearSorting` + +```tsx +clearSorting: () => void +``` + +Removes this column from the table's sorting state + +### `toggleSorting` + +```tsx +toggleSorting: (desc?: boolean, isMulti?: boolean) => void +``` + +Toggles this columns sorting state. If `desc` is provided, it will force the sort direction to that value. If `isMulti` is provided, it will additivity multi-sort the column (or toggle it if it is already sorted). + +### `getToggleSortingHandler` + +```tsx +getToggleSortingHandler: () => undefined | ((event: unknown) => void) +``` + +Returns a function that can be used to toggle this column's sorting state. This is useful for attaching a click handler to the column header. + +## Table Options + +### `sortingFns` + +```tsx +sortingFns?: Record +``` + +This option allows you to define custom sorting functions that can be referenced in a column's `sortingFn` option by their key. +Example: + +```tsx +declare module '@tanstack/table-core' { + interface SortingFns { + myCustomSorting: SortingFn + } +} + +const column = columnHelper.data('key', { + sortingFn: 'myCustomSorting', +}) + +const table = useReactTable({ + columns: [column], + sortingFns: { + myCustomSorting: (rowA: any, rowB: any, columnId: any): number => + rowA.getValue(columnId).value < rowB.getValue(columnId).value ? 1 : -1, + }, +}) +``` + +### `manualSorting` + +```tsx +manualSorting?: boolean +``` + +Enables manual sorting for the table. If this is `true`, you will be expected to sort your data before it is passed to the table. This is useful if you are doing server-side sorting. + +### `onSortingChange` + +```tsx +onSortingChange?: OnChangeFn +``` + +If provided, this function will be called with an `updaterFn` when `state.sorting` changes. This overrides the default internal state management, so you will need to persist the state change either fully or partially outside of the table. + +### `enableSorting` + +```tsx +enableSorting?: boolean +``` + +Enables/Disables sorting for the table. + +### `enableSortingRemoval` + +```tsx +enableSortingRemoval?: boolean +``` + +Enables/Disables the ability to remove sorting for the table. +- If `true` then changing sort order will circle like: 'none' -> 'desc' -> 'asc' -> 'none' -> ... +- If `false` then changing sort order will circle like: 'none' -> 'desc' -> 'asc' -> 'desc' -> 'asc' -> ... + +### `enableMultiRemove` + +```tsx +enableMultiRemove?: boolean +``` + +Enables/disables the ability to remove multi-sorts + +### `enableMultiSort` + +```tsx +enableMultiSort?: boolean +``` + +Enables/Disables multi-sorting for the table. + +### `sortDescFirst` + +```tsx +sortDescFirst?: boolean +``` + +If `true`, all sorts will default to descending as their first toggle state. + +### `getSortedRowModel` + +```tsx +getSortedRowModel?: (table: Table) => () => RowModel +``` + +This function is used to retrieve the sorted row model. If using server-side sorting, this function is not required. To use client-side sorting, pass the exported `getSortedRowModel()` from your adapter to your table or implement your own. + +### `maxMultiSortColCount` + +```tsx +maxMultiSortColCount?: number +``` + +Set a maximum number of columns that can be multi-sorted. + +### `isMultiSortEvent` + +```tsx +isMultiSortEvent?: (e: unknown) => boolean +``` + +Pass a custom function that will be used to determine if a multi-sort event should be triggered. It is passed the event from the sort toggle handler and should return `true` if the event should trigger a multi-sort. + +## Table API + +### `setSorting` + +```tsx +setSorting: (updater: Updater) => void +``` + +Sets or updates the `state.sorting` state. + +### `resetSorting` + +```tsx +resetSorting: (defaultState?: boolean) => void +``` + +Resets the **sorting** state to `initialState.sorting`, or `true` can be passed to force a default blank state reset to `[]`. + +### `getPreSortedRowModel` + +```tsx +getPreSortedRowModel: () => RowModel +``` + +Returns the row model for the table before any sorting has been applied. + +### `getSortedRowModel` + +```tsx +getSortedRowModel: () => RowModel +``` + +Returns the row model for the table after sorting has been applied. diff --git a/.gitignore b/.gitignore index 719fe3c..68b2bef 100644 --- a/.gitignore +++ b/.gitignore @@ -45,8 +45,12 @@ template-radiant-tailwindui # export v0-context/ -.cursor/rules/rules-technique-prompt.mdc -.cursor/rules/rules-stack-technique.mdc -.cursor/rules/rules-cahier-des-charges.mdc -tsconfig.tsbuildinfo -bun.lock \ No newline at end of file +# .cursor/rules/rules-technique-prompt.mdc +# .cursor/rules/rules-stack-technique.mdc +# .cursor/rules/rules-cahier-des-charges.mdc +# tsconfig.tsbuildinfo +# bun.lock +# src/app/(marketing) +# src/app/(marketing)/marketing-components/ +# src/app/(marketing) +# /home/andycinquin/clonedrepo/for-tooling/src/app/(marketing) \ No newline at end of file diff --git a/README.md b/README.md index 2b9a44e..88126b9 100644 --- a/README.md +++ b/README.md @@ -3,5 +3,83 @@ -- api : -- https://api.fortooling.forhives.fr/_/ +- - See our vaultwarden for password and credentials + +## Dev + +## how to run the project + +`bun install` +`bun run dev` +`ngrok http 3000` + +We get the ngrok url , and we setup this one in the webhook list from clerk +-> we need to change every webhook secret / endpoint for every webhook locally needed + +for example, we can have : + +- +- +- + +# QR/NFC Equipment Management System + +A SaaS platform for managing equipment inventory and tracking using QR codes and NFC technology. + +## Project Structure + +The project follows a modular structure with clear separation of concerns: + +### Models Organization + +All models are centralized in the `/models` directory, combining types and validation schemas in a cohesive location: + +``` +/models + /pocketbase # PocketBase related models + /base.model.ts # Base types, schemas and utilities + /collections.model.ts # Collection name constants + /equipment.model.ts # Equipment specific types and schemas + /organization.model.ts # Organization specific types and schemas + /app-user.model.ts # App user specific types and schemas + /index.ts # Re-exports for easier imports + # Other model categories can be added here +``` + +Each model file follows a consistent pattern: + +1. Type definitions section (interfaces, types) +2. Schema definitions section (Zod validation schemas) +3. Helper functions (if needed) + +This approach offers several benefits: + +- **Colocation** - Types and their validation schemas are kept together +- **Discoverability** - Easy to find both the type and its validation in one place +- **Maintainability** - Changes to a model only require editing a single file +- **Consistency** - Consistent naming convention with `.model.ts` suffix + +> **Note on TypeScript compatibility:** There are some TypeScript compatibility issues between Zod schemas and TypeScript interfaces. We've addressed these with `@ts-expect-error` comments in the service constructors. These are harmless and don't affect runtime functionality. + +### Services + +Services use the centralized models: + +``` +/app/actions/services + /pocketbase + /api_client # Base API client + /equipment_service.ts # Equipment specific service + /organization_service.ts # Organization specific service + /app_user_service.ts # App user specific service + # Other services +``` + +This structure ensures: + +- No type duplication (DRY principle) +- Clear separation of concerns +- Easy maintenance and refactoring +- Type safety across the application +- Logical grouping of related code diff --git a/bun.lock b/bun.lock index 3968d72..8027d0a 100644 --- a/bun.lock +++ b/bun.lock @@ -4,32 +4,43 @@ "": { "name": "for-tooling", "dependencies": { - "@clerk/nextjs": "6.12.12", - "@eslint/js": "9.23.0", - "@headlessui/react": "2.2.0", + "@clerk/nextjs": "6.13.0", + "@eslint/js": "9.24.0", + "@headlessui/react": "2.2.1", "@heroicons/react": "2.2.0", + "@radix-ui/react-alert-dialog": "1.1.6", "@radix-ui/react-avatar": "1.1.3", + "@radix-ui/react-checkbox": "1.1.4", "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-dropdown-menu": "2.1.6", "@radix-ui/react-icons": "1.3.2", + "@radix-ui/react-label": "2.1.2", + "@radix-ui/react-popover": "1.1.6", + "@radix-ui/react-select": "2.1.6", "@radix-ui/react-separator": "1.1.2", - "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tooltip": "1.1.8", + "@tanstack/react-table": "8.21.2", "@types/canvas-confetti": "1.9.0", "autoprefixer": "10.4.21", "canvas-confetti": "1.9.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "crypto": "1.0.1", + "date-fns": "^4.1.0", "dayjs": "1.11.13", - "framer-motion": "12.6.2", + "framer-motion": "12.6.3", "heroicons": "2.2.0", - "lucide-react": "0.485.0", + "lucide-react": "0.487.0", "next": "15.2.4", "pocketbase": "0.25.2", "postcss": "8.5.3", "react": "19.1.0", + "react-day-picker": "^9.6.4", "react-dom": "19.1.0", "react-use-measure": "2.1.7", - "tailwind-merge": "3.0.2", + "svix": "1.63.1", + "tailwind-merge": "3.1.0", "tw-animate-css": "1.2.5", "zod": "3.24.2", "zustand": "5.0.3", @@ -37,40 +48,42 @@ "devDependencies": { "@eslint/eslintrc": "3.3.1", "@next/eslint-plugin-next": "15.2.4", - "@tailwindcss/postcss": "4.0.17", - "@types/node": "22.13.14", - "@types/react": "19.0.12", - "@types/react-dom": "19.0.4", - "@typescript-eslint/eslint-plugin": "8.28.0", - "@typescript-eslint/parser": "8.28.0", - "eslint": "9.23.0", + "@tailwindcss/postcss": "4.1.3", + "@types/node": "22.14.0", + "@types/react": "19.1.0", + "@types/react-dom": "19.1.1", + "@typescript-eslint/eslint-plugin": "8.29.0", + "@typescript-eslint/parser": "8.29.0", + "eslint": "9.24.0", "eslint-config-next": "15.2.4", "eslint-config-prettier": "10.1.1", - "eslint-plugin-perfectionist": "4.10.1", - "eslint-plugin-prettier": "5.2.5", - "eslint-plugin-react": "7.37.4", + "eslint-plugin-perfectionist": "4.11.0", + "eslint-plugin-prettier": "5.2.6", + "eslint-plugin-react": "7.37.5", "husky": "9.1.7", "prettier": "3.5.3", "prettier-plugin-tailwindcss": "0.6.11", - "tailwindcss": "4.0.17", + "tailwindcss": "4.1.3", "tailwindcss-animate": "1.0.7", - "typescript": "5.8.2", - "typescript-eslint": "8.28.0", + "typescript": "5.8.3", + "typescript-eslint": "8.29.0", }, }, }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], - "@clerk/backend": ["@clerk/backend@1.25.8", "", { "dependencies": { "@clerk/shared": "^3.2.3", "@clerk/types": "^4.50.1", "cookie": "1.0.2", "snakecase-keys": "8.0.1", "tslib": "2.8.1" } }, "sha512-DmIc5pNQeTLHLCLN8ajcNhYNCfqmvwSwyGqr5aCHiJdWqGb9DGaws7PXU9btBiXVbI+NK/CJwjGv09+2rGpgAg=="], + "@clerk/backend": ["@clerk/backend@1.26.0", "", { "dependencies": { "@clerk/shared": "^3.3.0", "@clerk/types": "^4.50.2", "cookie": "1.0.2", "snakecase-keys": "8.0.1", "tslib": "2.8.1" }, "peerDependencies": { "svix": "^1.62.0" }, "optionalPeers": ["svix"] }, "sha512-ioZBMnwm4DD8IVPGDjFW3wkyn1JTMvTlsmdHGYsjdbXLtbRFVRJelAIMMGLcSmqMgzTKxnrJSOz8PxPjSMUFtw=="], - "@clerk/clerk-react": ["@clerk/clerk-react@5.25.5", "", { "dependencies": { "@clerk/shared": "^3.2.3", "@clerk/types": "^4.50.1", "tslib": "2.8.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" } }, "sha512-euG4T9EaN4af4YH7N8Fl6hIKnXQl+KSZv1WTLgD4KP90hSpVTMPkhdWeOiRFpNQ5I6WwtkaUPY16nce5y/NTQA=="], + "@clerk/clerk-react": ["@clerk/clerk-react@5.25.6", "", { "dependencies": { "@clerk/shared": "^3.3.0", "@clerk/types": "^4.50.2", "tslib": "2.8.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" } }, "sha512-QXISFiW4xI96nIE8MEfqpy+ISjtfYa2wWYeS8Nyo+K34dK1aNpawpTopRKRirqUy2QRSF/yXaCY9IF/v22XlJg=="], - "@clerk/nextjs": ["@clerk/nextjs@6.12.12", "", { "dependencies": { "@clerk/backend": "^1.25.8", "@clerk/clerk-react": "^5.25.5", "@clerk/shared": "^3.2.3", "@clerk/types": "^4.50.1", "server-only": "0.0.1", "tslib": "2.8.1" }, "peerDependencies": { "next": "^13.5.7 || ^14.2.25 || ^15.2.3", "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" } }, "sha512-V1Vb1a5pTZArNrCy/YvpXCFQZsrRb54G+crzZ55kiuqvPaVGPguEoSCqjoaJ1RolyagXMhLKvht3Te6DYMSZEg=="], + "@clerk/nextjs": ["@clerk/nextjs@6.13.0", "", { "dependencies": { "@clerk/backend": "^1.26.0", "@clerk/clerk-react": "^5.25.6", "@clerk/shared": "^3.3.0", "@clerk/types": "^4.50.2", "server-only": "0.0.1", "tslib": "2.8.1" }, "peerDependencies": { "next": "^13.5.7 || ^14.2.25 || ^15.2.3", "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" } }, "sha512-xikvFU8JWBtbgh3pe76yWrlOQzIACdUNsinHP06qeRJIIg8yci8sYa93ASjd0TNPzj9cInF+owMj6mDQw7HZ5Q=="], - "@clerk/shared": ["@clerk/shared@3.2.3", "", { "dependencies": { "@clerk/types": "^4.50.1", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.7.0", "swr": "^2.2.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-F8P7SqpcaLTV/wwCB3/1AkboO3YqFjb7qS6GoSDtVTFHMfpHJgHKhZ0vUBQFaLh/8ZV1kyRuiI/hrrbwIOF1EQ=="], + "@clerk/shared": ["@clerk/shared@3.3.0", "", { "dependencies": { "@clerk/types": "^4.50.2", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", "std-env": "^3.8.1", "swr": "^2.3.3" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-hO1M5aRMzJVqkw6lWJ7NFVG5hWEnTZBUZGeHMYRwSPQzQNsgqsRMvpmaJO0Y2o0HNk50PpwZHaiFHcghUpfMiw=="], - "@clerk/types": ["@clerk/types@4.50.1", "", { "dependencies": { "csstype": "3.1.3" } }, "sha512-GwsW/6LPHavHghh2QpmDbhyIuDP61OYV0T6x5hnjgAxjfexpRymbewR7Qez7H4kOo4gtnCNUrgTZ6nyresLEEg=="], + "@clerk/types": ["@clerk/types@4.50.2", "", { "dependencies": { "csstype": "3.1.3" } }, "sha512-4m1RlV/Dl3ZGW5FAXmKfdCbhF7uTDDvaADZH1F6L3d3lRBdI6i7GppK1KqscOSgoC8OwJqGaiDVUPsg+Pp8usg=="], + + "@date-fns/tz": ["@date-fns/tz@1.2.0", "", {}, "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg=="], "@emnapi/runtime": ["@emnapi/runtime@1.3.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw=="], @@ -78,7 +91,7 @@ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], - "@eslint/config-array": ["@eslint/config-array@0.19.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w=="], + "@eslint/config-array": ["@eslint/config-array@0.20.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ=="], "@eslint/config-helpers": ["@eslint/config-helpers@0.2.0", "", {}, "sha512-yJLLmLexii32mGrhW29qvU3QBVTu0GUmEf/J4XsBtVhp4JkIUFN/BjWqTF63yRvGApIDpZm5fa97LtYtINmfeQ=="], @@ -86,7 +99,7 @@ "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], - "@eslint/js": ["@eslint/js@9.23.0", "", {}, "sha512-35MJ8vCPU0ZMxo7zfev2pypqTwWTofFZO6m4KAtdoFhRpLJUpHTZZ+KB3C7Hb1d7bULYwO4lJXGCi5Se+8OMbw=="], + "@eslint/js": ["@eslint/js@9.24.0", "", {}, "sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA=="], "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="], @@ -102,7 +115,7 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="], - "@headlessui/react": ["@headlessui/react@2.2.0", "", { "dependencies": { "@floating-ui/react": "^0.26.16", "@react-aria/focus": "^3.17.1", "@react-aria/interactions": "^3.21.3", "@tanstack/react-virtual": "^3.8.1" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ=="], + "@headlessui/react": ["@headlessui/react@2.2.1", "", { "dependencies": { "@floating-ui/react": "^0.26.16", "@react-aria/focus": "^3.17.1", "@react-aria/interactions": "^3.21.3", "@tanstack/react-virtual": "^3.11.1" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-daiUqVLae8CKVjEVT19P/izW0aGK0GNhMSAeMlrDebKmoVZHcRRwbxzgtnEadUVDXyBsWo9/UH4KHeniO+0tMg=="], "@heroicons/react": ["@heroicons/react@2.2.0", "", { "peerDependencies": { "react": ">= 16 || ^19.0.0-rc" } }, "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ=="], @@ -182,20 +195,32 @@ "@pkgr/core": ["@pkgr/core@0.2.0", "", {}, "sha512-vsJDAkYR6qCPu+ioGScGiMYR7LvZYIXh/dlQeviqoTWNCVfKTLYD/LkNWH4Mxsv2a5vpIRc77FN5DnmK1eBggQ=="], + "@radix-ui/number": ["@radix-ui/number@1.1.0", "", {}, "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="], + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dialog": "1.1.6", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg=="], "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.3", "", { "dependencies": { "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Paen00T4P8L8gd9bNsRMw7Cbaz85oxiv+hzomsRZgFm2byltPFDtfcoqlWJ8GyZlIBWgLssJlzLCnKU0G0302g=="], + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.1.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="], "@radix-ui/react-context": ["@radix-ui/react-context@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q=="], "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw=="], + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg=="], + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg=="], + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-menu": "2.1.6", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA=="], + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg=="], "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA=="], @@ -204,6 +229,12 @@ "@radix-ui/react-id": ["@radix-ui/react-id@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA=="], + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.2", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-roving-focus": "1.1.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-callback-ref": "1.1.0", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.2", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg=="], + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.2", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-rect": "1.1.0", "@radix-ui/react-use-size": "1.1.0", "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA=="], "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA=="], @@ -212,6 +243,10 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.2", "", { "dependencies": { "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w=="], + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.1.6", "", { "dependencies": { "@radix-ui/number": "1.1.0", "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.2", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-visually-hidden": "1.1.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg=="], + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oZfHcaAp2Y6KFBX6I5P1u7CQoy4lheCGiYj+pGFrHy8E/VNRb5E39TkTr3JrV520csPBTZjkuKFdEsjS5EUNKQ=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="], @@ -226,6 +261,8 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="], + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og=="], + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.0", "", { "dependencies": { "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ=="], "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw=="], @@ -252,40 +289,46 @@ "@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.11.0", "", {}, "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], - "@tailwindcss/node": ["@tailwindcss/node@4.0.17", "", { "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "tailwindcss": "4.0.17" } }, "sha512-LIdNwcqyY7578VpofXyqjH6f+3fP4nrz7FBLki5HpzqjYfXdF2m/eW18ZfoKePtDGg90Bvvfpov9d2gy5XVCbg=="], + "@tailwindcss/node": ["@tailwindcss/node@4.1.3", "", { "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.29.2", "tailwindcss": "4.1.3" } }, "sha512-H/6r6IPFJkCfBJZ2dKZiPJ7Ueb2wbL592+9bQEl2r73qbX6yGnmQVIfiUvDRB2YI0a3PWDrzUwkvQx1XW1bNkA=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.3", "@tailwindcss/oxide-darwin-arm64": "4.1.3", "@tailwindcss/oxide-darwin-x64": "4.1.3", "@tailwindcss/oxide-freebsd-x64": "4.1.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.3", "@tailwindcss/oxide-linux-arm64-musl": "4.1.3", "@tailwindcss/oxide-linux-x64-gnu": "4.1.3", "@tailwindcss/oxide-linux-x64-musl": "4.1.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.3", "@tailwindcss/oxide-win32-x64-msvc": "4.1.3" } }, "sha512-t16lpHCU7LBxDe/8dCj9ntyNpXaSTAgxWm1u2XQP5NiIu4KGSyrDJJRlK9hJ4U9yJxx0UKCVI67MJWFNll5mOQ=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.0.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.0.17", "@tailwindcss/oxide-darwin-arm64": "4.0.17", "@tailwindcss/oxide-darwin-x64": "4.0.17", "@tailwindcss/oxide-freebsd-x64": "4.0.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.0.17", "@tailwindcss/oxide-linux-arm64-musl": "4.0.17", "@tailwindcss/oxide-linux-x64-gnu": "4.0.17", "@tailwindcss/oxide-linux-x64-musl": "4.0.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.0.17", "@tailwindcss/oxide-win32-x64-msvc": "4.0.17" } }, "sha512-B4OaUIRD2uVrULpAD1Yksx2+wNarQr2rQh65nXqaqbLY1jCd8fO+3KLh/+TH4Hzh2NTHQvgxVbPdUDOtLk7vAw=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.3", "", { "os": "android", "cpu": "arm64" }, "sha512-cxklKjtNLwFl3mDYw4XpEfBY+G8ssSg9ADL4Wm6//5woi3XGqlxFsnV5Zb6v07dxw1NvEX2uoqsxO/zWQsgR+g=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.0.17", "", { "os": "android", "cpu": "arm64" }, "sha512-3RfO0ZK64WAhop+EbHeyxGThyDr/fYhxPzDbEQjD2+v7ZhKTb2svTWy+KK+J1PHATus2/CQGAGp7pHY/8M8ugg=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mqkf2tLR5VCrjBvuRDwzKNShRu99gCAVMkVsaEOFvv6cCjlEKXRecPu9DEnxp6STk5z+Vlbh1M5zY3nQCXMXhw=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.0.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-e1uayxFQCCDuzTk9s8q7MC5jFN42IY7nzcr5n0Mw/AcUHwD6JaBkXnATkD924ZsHyPDvddnusIEvkgLd2CiREg=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-7sGraGaWzXvCLyxrc7d+CCpUN3fYnkkcso3rCzwUmo/LteAl2ZGCDlGvDD8Y/1D3ngxT8KgDj1DSwOnNewKhmg=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.0.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-d6z7HSdOKfXQ0HPlVx1jduUf/YtBuCCtEDIEFeBCzgRRtDsUuRtofPqxIVaSCUTOk5+OfRLonje6n9dF6AH8wQ=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-E2+PbcbzIReaAYZe997wb9rId246yDkCwAakllAWSGqe6VTg9hHle67hfH6ExjpV2LSK/siRzBUs5wVff3RW9w=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.0.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EjrVa6lx3wzXz3l5MsdOGtYIsRjgs5Mru6lDv4RuiXpguWeOb3UzGJ7vw7PEzcFadKNvNslEQqoAABeMezprxQ=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.3", "", { "os": "linux", "cpu": "arm" }, "sha512-GvfbJ8wjSSjbLFFE3UYz4Eh8i4L6GiEYqCtA8j2Zd2oXriPuom/Ah/64pg/szWycQpzRnbDiJozoxFU2oJZyfg=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.0.17", "", { "os": "linux", "cpu": "arm" }, "sha512-65zXfCOdi8wuaY0Ye6qMR5LAXokHYtrGvo9t/NmxvSZtCCitXV/gzJ/WP5ksXPhff1SV5rov0S+ZIZU+/4eyCQ=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-35UkuCWQTeG9BHcBQXndDOrpsnt3Pj9NVIB4CgNiKmpG8GnCNXeMczkUpOoqcOhO6Cc/mM2W7kaQ/MTEENDDXg=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.0.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-+aaq6hJ8ioTdbJV5IA1WjWgLmun4T7eYLTvJIToiXLHy5JzUERRbIZjAcjgK9qXMwnvuu7rqpxzej+hGoEcG5g=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-dm18aQiML5QCj9DQo7wMbt1Z2tl3Giht54uVR87a84X8qRtuXxUqnKQkRDK5B4bCOmcZ580lF9YcoMkbDYTXHQ=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.0.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-/FhWgZCdUGAeYHYnZKekiOC0aXFiBIoNCA0bwzkICiMYS5Rtx2KxFfMUXQVnl4uZRblG5ypt5vpPhVaXgGk80w=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-LMdTmGe/NPtGOaOfV2HuO7w07jI3cflPrVq5CXl+2O93DCewADK0uW1ORNAcfu2YxDUS035eY2W38TxrsqngxA=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.0.17", "", { "os": "linux", "cpu": "x64" }, "sha512-gELJzOHK6GDoIpm/539Golvk+QWZjxQcbkKq9eB2kzNkOvrP0xc5UPgO9bIMNt1M48mO8ZeNenCMGt6tfkvVBg=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-aalNWwIi54bbFEizwl1/XpmdDrOaCjRFQRgtbv9slWjmNPuJJTIKPHf5/XXDARc9CneW9FkSTqTbyvNecYAEGw=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.0.17", "", { "os": "linux", "cpu": "x64" }, "sha512-68NwxcJrZn94IOW4TysMIbYv5AlM6So1luTlbYUDIGnKma1yTFGBRNEJ+SacJ3PZE2rgcTBNRHX1TB4EQ/XEHw=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-PEj7XR4OGTGoboTIAdXicKuWl4EQIjKHKuR+bFy9oYN7CFZo0eu74+70O4XuERX4yjqVZGAkCdglBODlgqcCXg=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.0.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-AkBO8efP2/7wkEXkNlXzRD4f/7WerqKHlc6PWb5v0jGbbm22DFBLbIM19IJQ3b+tNewQZa+WnPOaGm0SmwMNjw=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-T8gfxECWDBENotpw3HR9SmNiHC9AOJdxs+woasRZ8Q/J4VHN0OMs7F+4yVNZ9EVN26Wv6mZbK0jv7eHYuLJLwA=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.0.17", "", { "os": "win32", "cpu": "x64" }, "sha512-7/DTEvXcoWlqX0dAlcN0zlmcEu9xSermuo7VNGX9tJ3nYMdo735SHvbrHDln1+LYfF6NhJ3hjbpbjkMOAGmkDg=="], + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.3", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.3", "@tailwindcss/oxide": "4.1.3", "postcss": "^8.4.41", "tailwindcss": "4.1.3" } }, "sha512-6s5nJODm98F++QT49qn8xJKHQRamhYHfMi3X7/ltxiSQ9dyRsaFSfFkfaMsanWzf+TMYQtbk8mt5f6cCVXJwfg=="], - "@tailwindcss/postcss": ["@tailwindcss/postcss@4.0.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.0.17", "@tailwindcss/oxide": "4.0.17", "lightningcss": "1.29.2", "postcss": "^8.4.41", "tailwindcss": "4.0.17" } }, "sha512-qeJbRTB5FMZXmuJF+eePd235EGY6IyJZF0Bh0YM6uMcCI4L9Z7dy+lPuLAhxOJzxnajsbjPoDAKOuAqZRtf1PQ=="], + "@tanstack/react-table": ["@tanstack/react-table@8.21.2", "", { "dependencies": { "@tanstack/table-core": "8.21.2" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-11tNlEDTdIhMJba2RBH+ecJ9l1zgS2kjmexDPAraulc8jeNA4xocSNeyzextT0XJyASil4XsCYlJmf5jEWAtYg=="], "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.4", "", { "dependencies": { "@tanstack/virtual-core": "3.13.4" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-jPWC3BXvVLHsMX67NEHpJaZ+/FySoNxFfBEiF4GBc1+/nVwdRm+UcSCYnKP3pXQr0eEsDpXi/PQZhNfJNopH0g=="], + "@tanstack/table-core": ["@tanstack/table-core@8.21.2", "", {}, "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.4", "", {}, "sha512-fNGO9fjjSLns87tlcto106enQQLycCKR4DPNpgq3djP5IdcPFdPAmaKjsgzIeRhH7hWrELgW12hYnRthS5kLUw=="], "@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="], @@ -296,27 +339,27 @@ "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], - "@types/node": ["@types/node@22.13.14", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w=="], + "@types/node": ["@types/node@22.14.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA=="], - "@types/react": ["@types/react@19.0.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA=="], + "@types/react": ["@types/react@19.1.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w=="], - "@types/react-dom": ["@types/react-dom@19.0.4", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg=="], + "@types/react-dom": ["@types/react-dom@19.1.1", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.28.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.28.0", "@typescript-eslint/type-utils": "8.28.0", "@typescript-eslint/utils": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lvFK3TCGAHsItNdWZ/1FkvpzCxTHUVuFrdnOGLMa0GGCFIbCgQWVk3CzCGdA7kM3qGVc+dfW9tr0Z/sHnGDFyg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.29.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.29.0", "@typescript-eslint/type-utils": "8.29.0", "@typescript-eslint/utils": "8.29.0", "@typescript-eslint/visitor-keys": "8.29.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-PAIpk/U7NIS6H7TEtN45SPGLQaHNgB7wSjsQV/8+KYokAb2T/gloOA/Bee2yd4/yKVhPKe5LlaUGhAZk5zmSaQ=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.28.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.28.0", "@typescript-eslint/types": "8.28.0", "@typescript-eslint/typescript-estree": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-LPcw1yHD3ToaDEoljFEfQ9j2xShY367h7FZ1sq5NJT9I3yj4LHer1Xd1yRSOdYy9BpsrxU7R+eoDokChYM53lQ=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.29.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.29.0", "@typescript-eslint/types": "8.29.0", "@typescript-eslint/typescript-estree": "8.29.0", "@typescript-eslint/visitor-keys": "8.29.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-8C0+jlNJOwQso2GapCVWWfW/rzaq7Lbme+vGUFKE31djwNncIpgXD7Cd4weEsDdkoZDjH0lwwr3QDQFuyrMg9g=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0" } }, "sha512-u2oITX3BJwzWCapoZ/pXw6BCOl8rJP4Ij/3wPoGvY8XwvXflOzd1kLrDUUUAIEdJSFh+ASwdTHqtan9xSg8buw=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.29.0", "", { "dependencies": { "@typescript-eslint/types": "8.29.0", "@typescript-eslint/visitor-keys": "8.29.0" } }, "sha512-aO1PVsq7Gm+tcghabUpzEnVSFMCU4/nYIgC2GOatJcllvWfnhrgW0ZEbnTxm36QsikmCN1K/6ZgM7fok2I7xNw=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.28.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.28.0", "@typescript-eslint/utils": "8.28.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-oRoXu2v0Rsy/VoOGhtWrOKDiIehvI+YNrDk5Oqj40Mwm0Yt01FC/Q7nFqg088d3yAsR1ZcZFVfPCTTFCe/KPwg=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.29.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.29.0", "@typescript-eslint/utils": "8.29.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-ahaWQ42JAOx+NKEf5++WC/ua17q5l+j1GFrbbpVKzFL/tKVc0aYY8rVSYUpUvt2hUP1YBr7mwXzx+E/DfUWI9Q=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.28.0", "", {}, "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.29.0", "", {}, "sha512-wcJL/+cOXV+RE3gjCyl/V2G877+2faqvlgtso/ZRbTCnZazh0gXhe+7gbAnfubzN2bNsBtZjDvlh7ero8uIbzg=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-H74nHEeBGeklctAVUvmDkxB1mk+PAZ9FiOMPFncdqeRBXxk1lWSYraHw8V12b7aa6Sg9HOBNbGdSHobBPuQSuA=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.29.0", "", { "dependencies": { "@typescript-eslint/types": "8.29.0", "@typescript-eslint/visitor-keys": "8.29.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-yOfen3jE9ISZR/hHpU/bmNvTtBW1NjRbkSFdZOksL1N+ybPEE7UVGMwqvS6CP022Rp00Sb0tdiIkhSCe6NI8ow=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.28.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "8.28.0", "@typescript-eslint/types": "8.28.0", "@typescript-eslint/typescript-estree": "8.28.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-OELa9hbTYciYITqgurT1u/SzpQVtDLmQMFzy/N8pQE+tefOyCWT79jHsav294aTqV1q1u+VzqDGbuujvRYaeSQ=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.29.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "8.29.0", "@typescript-eslint/types": "8.29.0", "@typescript-eslint/typescript-estree": "8.29.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-gX/A0Mz9Bskm8avSWFcK0gP7cZpbY4AIo6B0hWYFCaIsz750oaiWR4Jr2CI+PQhfW1CpcQr9OlfPS+kMFegjXA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-hbn8SZ8w4u2pRwgQ1GlUrPKE+t2XvcCW5tTRF7j6SMYIuYG37XuzIW44JCZPa36evi0Oy2SnM664BlIaAuQcvg=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.29.0", "", { "dependencies": { "@typescript-eslint/types": "8.29.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-Sne/pVz8ryR03NFK21VpN88dZ2FdQXOlq3VIklbrTYEt8yXtRFr9tvUhqvCeKjqYk5FSim37sHbooT6vzBTZcg=="], "acorn": ["acorn@8.14.1", "", { "bin": "bin/acorn" }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="], @@ -404,6 +447,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "crypto": ["crypto@1.0.1", "", {}, "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], @@ -414,6 +459,10 @@ "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + + "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], + "dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="], "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], @@ -458,11 +507,13 @@ "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + "es6-promise": ["es6-promise@4.2.8", "", {}, "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@9.23.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.19.2", "@eslint/config-helpers": "^0.2.0", "@eslint/core": "^0.12.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.23.0", "@eslint/plugin-kit": "^0.2.7", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.3.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-jV7AbNoFPAY1EkFYpLq5bslU9NLNO8xnEeQXwErNibVryjk67wHVmddTBilc5srIttJDBrB0eMHKZBFbSIABCw=="], + "eslint": ["eslint@9.24.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.0", "@eslint/config-helpers": "^0.2.0", "@eslint/core": "^0.12.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.24.0", "@eslint/plugin-kit": "^0.2.7", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.3.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ=="], "eslint-config-next": ["eslint-config-next@15.2.4", "", { "dependencies": { "@next/eslint-plugin-next": "15.2.4", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-v4gYjd4eYIme8qzaJItpR5MMBXJ0/YV07u7eb50kEnlEmX7yhOjdUdzz70v4fiINYRjLf8X8TbogF0k7wlz6sA=="], @@ -478,11 +529,11 @@ "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], - "eslint-plugin-perfectionist": ["eslint-plugin-perfectionist@4.10.1", "", { "dependencies": { "@typescript-eslint/types": "^8.26.0", "@typescript-eslint/utils": "^8.26.0", "natural-orderby": "^5.0.0" }, "peerDependencies": { "eslint": ">=8.45.0" } }, "sha512-GXwFfL47RfBLZRGQdrvGZw9Ali2T2GPW8p4Gyj2fyWQ9396R/HgJMf0m9kn7D6WXRwrINfTDGLS+QYIeok9qEg=="], + "eslint-plugin-perfectionist": ["eslint-plugin-perfectionist@4.11.0", "", { "dependencies": { "@typescript-eslint/types": "^8.29.0", "@typescript-eslint/utils": "^8.29.0", "natural-orderby": "^5.0.0" }, "peerDependencies": { "eslint": ">=8.45.0" } }, "sha512-5s+ehXydnLPQpLDj5mJ0CnYj2fQe6v6gKA3tS+FZVBLzwMOh8skH+l+1Gni08rG0SdEcNhJyjQp/mEkDYK8czw=="], - "eslint-plugin-prettier": ["eslint-plugin-prettier@5.2.5", "", { "dependencies": { "prettier-linter-helpers": "^1.0.0", "synckit": "^0.10.2" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-IKKP8R87pJyMl7WWamLgPkloB16dagPIdd2FjBDbyRYPKo93wS/NbCOPh6gH+ieNLC+XZrhJt/kWj0PS/DFdmg=="], + "eslint-plugin-prettier": ["eslint-plugin-prettier@5.2.6", "", { "dependencies": { "prettier-linter-helpers": "^1.0.0", "synckit": "^0.11.0" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ=="], - "eslint-plugin-react": ["eslint-plugin-react@7.37.4", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.8", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ=="], + "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], @@ -510,6 +561,8 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], "fdir": ["fdir@6.4.3", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw=="], @@ -528,7 +581,7 @@ "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], - "framer-motion": ["framer-motion@12.6.2", "", { "dependencies": { "motion-dom": "^12.6.1", "motion-utils": "^12.5.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-7LgPRlPs5aG8UxeZiMCMZz8firC53+2+9TnWV22tuSi38D3IFRxHRUqOREKckAkt6ztX+Dn6weLcatQilJTMcg=="], + "framer-motion": ["framer-motion@12.6.3", "", { "dependencies": { "motion-dom": "^12.6.3", "motion-utils": "^12.6.3", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-2hsqknz23aloK85bzMc9nSR2/JP+fValQ459ZTVElFQ0xgwR2YqNjYSuDZdFBPOwVCt4Q9jgyTt6hg6sVOALzw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -702,7 +755,7 @@ "lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="], - "lucide-react": ["lucide-react@0.485.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-NvyQJ0LKyyCxL23nPKESlr/jmz8r7fJO1bkuptSNYSy0s8VVj4ojhX0YAgmE1e0ewfxUZjIlZpvH+otfTnla8Q=="], + "lucide-react": ["lucide-react@0.487.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aKqhOQ+YmFnwq8dWgGjOuLc8V1R9/c/yOd+zDY4+ohsR2Jo05lSGc3WsstYPIzcTpeosN7LoCkLReUUITvaIvw=="], "map-obj": ["map-obj@4.3.0", "", {}, "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ=="], @@ -716,9 +769,9 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "motion-dom": ["motion-dom@12.6.1", "", { "dependencies": { "motion-utils": "^12.5.0" } }, "sha512-8XVsriTUEVOepoIDgE/LDGdg7qaKXWdt+wQA/8z0p8YzJDLYL8gbimZ3YkCLlj7bB2i/4UBD/g+VO7y9ZY0zHQ=="], + "motion-dom": ["motion-dom@12.6.3", "", { "dependencies": { "motion-utils": "^12.6.3" } }, "sha512-gRY08RjcnzgFYLemUZ1lo/e9RkBxR+6d4BRvoeZDSeArG4XQXERSPapKl3LNQRu22Sndjf1h+iavgY0O4NrYqA=="], - "motion-utils": ["motion-utils@12.5.0", "", {}, "sha512-+hFFzvimn0sBMP9iPxBa9OtRX35ZQ3py0UHnb8U29VD+d8lQ8zH3dTygJWqK7av2v6yhg7scj9iZuvTS0f4+SA=="], + "motion-utils": ["motion-utils@12.6.3", "", {}, "sha512-R/b3Ia2VxtTNZ4LTEO5pKYau1OUNHOuUfxuP0WFCTDYdHkeTBR9UtxR1cc8mDmKr8PEhmmfnTKGz3rSMjNRoRg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -732,6 +785,8 @@ "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="], @@ -744,7 +799,7 @@ "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], - "object.entries": ["object.entries@1.1.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ=="], + "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], @@ -792,10 +847,14 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], + "react-day-picker": ["react-day-picker@9.6.4", "", { "dependencies": { "@date-fns/tz": "^1.2.0", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-OekyAWfaypSFN5zms4CD6Bcas5R0KbWdARkWTyQ2phJHQOolDfpLwrN6Q+U3ifPGNmKLf9ngXuSz25NKHMkR6w=="], + "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], @@ -812,6 +871,8 @@ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + "resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], @@ -890,15 +951,19 @@ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "svix": ["svix@1.63.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "@types/node": "^22.7.5", "es6-promise": "^4.2.8", "fast-sha256": "^1.3.0", "svix-fetch": "^3.0.0", "url-parse": "^1.5.10" } }, "sha512-1NdTJ4YI4jd8vbRLjGNg8ZCFlIb+t2iTtt1ddm+DsNKQC4GkhgjDMi7wRcXiWraBonYSlr/KARSknUW6iLM4fA=="], + + "svix-fetch": ["svix-fetch@3.0.0", "", { "dependencies": { "node-fetch": "^2.6.1", "whatwg-fetch": "^3.4.1" } }, "sha512-rcADxEFhSqHbraZIsjyZNh4TF6V+koloX1OzZ+AQuObX9mZ2LIMhm1buZeuc5BIZPftZpJCMBsSiBaeszo9tRw=="], + "swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="], - "synckit": ["synckit@0.10.3", "", { "dependencies": { "@pkgr/core": "^0.2.0", "tslib": "^2.8.1" } }, "sha512-R1urvuyiTaWfeCggqEvpDJwAlDVdsT9NM+IP//Tk2x7qHCkSvBk/fwFgw/TLAHzZlrAnnazMcRw0ZD8HlYFTEQ=="], + "synckit": ["synckit@0.11.2", "", { "dependencies": { "@pkgr/core": "^0.2.0", "tslib": "^2.8.1" } }, "sha512-1IUffI8zZ8qUMB3NUJIjk0RpLroG/8NkQDAWH1NbB2iJ0/5pn3M8rxfNzMz4GH9OnYaGYn31LEDSXJp/qIlxgA=="], "tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="], - "tailwind-merge": ["tailwind-merge@3.0.2", "", {}, "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw=="], + "tailwind-merge": ["tailwind-merge@3.1.0", "", {}, "sha512-aV27Oj8B7U/tAOMhJsSGdWqelfmudnGMdXIlMnk1JfsjwSjts6o8HyfN7SFH3EztzH4YH8kk6GbLTHzITJO39Q=="], - "tailwindcss": ["tailwindcss@4.0.17", "", {}, "sha512-OErSiGzRa6rLiOvaipsDZvLMSpsBZ4ysB4f0VKGXUrjw2jfkJRd6kjRKV2+ZmTCNvwtvgdDam5D7w6WXsdLJZw=="], + "tailwindcss": ["tailwindcss@4.1.3", "", {}, "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g=="], "tailwindcss-animate": ["tailwindcss-animate@1.0.7", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders" } }, "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA=="], @@ -908,6 +973,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "ts-api-utils": ["ts-api-utils@2.0.1", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w=="], "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], @@ -928,24 +995,32 @@ "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], - "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], - "typescript-eslint": ["typescript-eslint@8.28.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.28.0", "@typescript-eslint/parser": "8.28.0", "@typescript-eslint/utils": "8.28.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-jfZtxJoHm59bvoCMYCe2BM0/baMswRhMmYhy+w6VfcyHrjxZ0OJe0tGasydCpIpA+A/WIJhTyZfb3EtwNC/kHQ=="], + "typescript-eslint": ["typescript-eslint@8.29.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.29.0", "@typescript-eslint/parser": "8.29.0", "@typescript-eslint/utils": "8.29.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-ep9rVd9B4kQsZ7ZnWCVxUE/xDLUUUsRzE0poAeNu+4CkFErLfuvPt/qtm2EpnSyfvsR0S6QzDFSrPCFBwf64fg=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], - "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "use-sync-external-store": ["use-sync-external-store@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], @@ -976,6 +1051,12 @@ "debug/ms": ["ms@2.1.3", "", { "bundled": true }, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "eslint-config-next/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.28.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.28.0", "@typescript-eslint/type-utils": "8.28.0", "@typescript-eslint/utils": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lvFK3TCGAHsItNdWZ/1FkvpzCxTHUVuFrdnOGLMa0GGCFIbCgQWVk3CzCGdA7kM3qGVc+dfW9tr0Z/sHnGDFyg=="], + + "eslint-config-next/@typescript-eslint/parser": ["@typescript-eslint/parser@8.28.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.28.0", "@typescript-eslint/types": "8.28.0", "@typescript-eslint/typescript-estree": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-LPcw1yHD3ToaDEoljFEfQ9j2xShY367h7FZ1sq5NJT9I3yj4LHer1Xd1yRSOdYy9BpsrxU7R+eoDokChYM53lQ=="], + + "eslint-config-next/eslint-plugin-react": ["eslint-plugin-react@7.37.4", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.8", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ=="], + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-import-resolver-node/resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], @@ -984,10 +1065,6 @@ "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], - "eslint-plugin-perfectionist/@typescript-eslint/types": ["@typescript-eslint/types@8.26.1", "", {}, "sha512-n4THUQW27VmQMx+3P+B0Yptl7ydfceUj4ON/AQILAASwgYdZ/2dhfymRMh5egRUrvK5lSmaOm77Ry+lmXPOgBQ=="], - - "eslint-plugin-perfectionist/@typescript-eslint/utils": ["@typescript-eslint/utils@8.26.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "8.26.1", "@typescript-eslint/types": "8.26.1", "@typescript-eslint/typescript-estree": "8.26.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-V4Urxa/XtSUroUrnI7q6yUTD3hDtfJ2jzVfeT3VK0ciizfK2q/zGC0iDh1lFMUZR8cImRrep6/q0xd/1ZGPQpg=="], - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fdir/picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], @@ -1004,22 +1081,64 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.26.1", "", { "dependencies": { "@typescript-eslint/types": "8.26.1", "@typescript-eslint/visitor-keys": "8.26.1" } }, "sha512-6EIvbE5cNER8sqBu6V7+KeMZIC1664d2Yjt+B9EWUXrsyWpxx4lEZrmvxgSKRC6gX+efDL/UY9OpPZ267io3mg=="], + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0" } }, "sha512-u2oITX3BJwzWCapoZ/pXw6BCOl8rJP4Ij/3wPoGvY8XwvXflOzd1kLrDUUUAIEdJSFh+ASwdTHqtan9xSg8buw=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.28.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.28.0", "@typescript-eslint/utils": "8.28.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-oRoXu2v0Rsy/VoOGhtWrOKDiIehvI+YNrDk5Oqj40Mwm0Yt01FC/Q7nFqg088d3yAsR1ZcZFVfPCTTFCe/KPwg=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.28.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "8.28.0", "@typescript-eslint/types": "8.28.0", "@typescript-eslint/typescript-estree": "8.28.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-OELa9hbTYciYITqgurT1u/SzpQVtDLmQMFzy/N8pQE+tefOyCWT79jHsav294aTqV1q1u+VzqDGbuujvRYaeSQ=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-hbn8SZ8w4u2pRwgQ1GlUrPKE+t2XvcCW5tTRF7j6SMYIuYG37XuzIW44JCZPa36evi0Oy2SnM664BlIaAuQcvg=="], + + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0" } }, "sha512-u2oITX3BJwzWCapoZ/pXw6BCOl8rJP4Ij/3wPoGvY8XwvXflOzd1kLrDUUUAIEdJSFh+ASwdTHqtan9xSg8buw=="], + + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.28.0", "", {}, "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA=="], + + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-H74nHEeBGeklctAVUvmDkxB1mk+PAZ9FiOMPFncdqeRBXxk1lWSYraHw8V12b7aa6Sg9HOBNbGdSHobBPuQSuA=="], + + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-hbn8SZ8w4u2pRwgQ1GlUrPKE+t2XvcCW5tTRF7j6SMYIuYG37XuzIW44JCZPa36evi0Oy2SnM664BlIaAuQcvg=="], + + "eslint-config-next/eslint-plugin-react/object.entries": ["object.entries@1.1.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.28.0", "", {}, "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-H74nHEeBGeklctAVUvmDkxB1mk+PAZ9FiOMPFncdqeRBXxk1lWSYraHw8V12b7aa6Sg9HOBNbGdSHobBPuQSuA=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.28.0", "", {}, "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.28.0", "", { "dependencies": { "@typescript-eslint/types": "8.28.0", "@typescript-eslint/visitor-keys": "8.28.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-H74nHEeBGeklctAVUvmDkxB1mk+PAZ9FiOMPFncdqeRBXxk1lWSYraHw8V12b7aa6Sg9HOBNbGdSHobBPuQSuA=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.28.0", "", {}, "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA=="], + + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": "bin/semver.js" }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.28.0", "", {}, "sha512-bn4WS1bkKEjx7HqiwG2JNB3YJdC1q6Ue7GyGlwPHyt0TnVq6TtD/hiOdTZt71sq0s7UzqBFXD8t8o2e63tXgwA=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": "bin/semver.js" }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], + + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.26.1", "", { "dependencies": { "@typescript-eslint/types": "8.26.1", "@typescript-eslint/visitor-keys": "8.26.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-yUwPpUHDgdrv1QJ7YQal3cMVBGWfnuCdKbXw1yyjArax3353rEJP1ZA+4F8nOlQ3RfS2hUN/wze3nlY+ZOhvoA=="], + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.26.1", "", { "dependencies": { "@typescript-eslint/types": "8.26.1", "eslint-visitor-keys": "^4.2.0" } }, "sha512-AjOC3zfnxd6S4Eiy3jwktJPclqhFHNyd8L6Gycf9WUPoKZpgM5PjkxY1X7uSy61xVpiJDhhk7XT2NVsN3ALTWg=="], + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": "bin/semver.js" }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.26.1", "", { "dependencies": { "@typescript-eslint/types": "8.26.1", "eslint-visitor-keys": "^4.2.0" } }, "sha512-AjOC3zfnxd6S4Eiy3jwktJPclqhFHNyd8L6Gycf9WUPoKZpgM5PjkxY1X7uSy61xVpiJDhhk7XT2NVsN3ALTWg=="], + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + "eslint-config-next/@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/semver": ["semver@7.7.1", "", { "bin": "bin/semver.js" }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "eslint-plugin-perfectionist/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + "eslint-config-next/@typescript-eslint/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], } } diff --git a/docs-and-prompts/cahier-des-charges.md b/docs-and-prompts/cahier-des-charges.md deleted file mode 100644 index 8290389..0000000 --- a/docs-and-prompts/cahier-des-charges.md +++ /dev/null @@ -1,207 +0,0 @@ -## 1. Contexte et problématique générale - -### 1.1 Problématique adressée - -De nombreuses entreprises possèdent et gèrent un parc d'équipements qu'elles doivent suivre, attribuer et entretenir. Ces équipements peuvent représenter plusieurs dizaines à centaines d'articles différents (outils, matériel technique, appareils spécialisés, etc.). - -Les systèmes traditionnels de gestion présentent des lacunes importantes : - -- Suivi manuel chronophage et source d'erreurs -- Difficulté à localiser rapidement les équipements -- Absence d'historique fiable des mouvements et utilisations -- Complexité pour gérer les attributions -- Manque de visibilité globale sur l'état du parc -- Coût très elevé pour des balises gps précies (e.g hilti etc) -- Impossibilité d'appliquer ça sur des éléments autres - -## 2. Objectifs de la plateforme SaaS - -Développer une plateforme SaaS de gestion d'équipements qui permettra de : - -- Centraliser l'inventaire complet du parc matériel -- Suivre la localisation de chaque équipement en temps réel grâce à des étiquettes nfc/qr -- Gérer l'attribution des équipements aux utilisateurs et aux projets/emplacements -- Automatiser la détection des entrées/sorties d'équipements via des points de scan -- Conserver l'historique de tous les mouvements et utilisations -- Fournir des analyses et statistiques d'utilisation avancées -- Offrir une solution adaptable à différents secteurs d'activité - -## 3. Besoins fonctionnels détaillés - -### 3.1 Gestion multi-organisations - -- Support de plusieurs organisations clientes avec isolation complète des données -- Paramétrage par organisation (terminologie, champs personnalisés, flux de travail) -- Gestion des rôles et permissions par organisation - -### 3.2 Gestion des équipements - -- Inventaire complet avec informations détaillées : - - Référence unique et code NFC/qr associé - - Nom et description - - Date d'acquisition et valeur - - État et niveau d'usure - - Spécifications techniques (type, marque, modèle, etc.) - - Catégorie de rattachement - - Champs personnalisables selon le secteur d'activité -- Création, modification et suppression d'équipements -- Association d'un équipement à une catégorie spécifique -- Support pour documentation technique, photos et fichiers associés -- Gestion des maintenances préventives et curatives - -### 3.3 Suivi automatisé par NFC // ou SCAN QR Code - -- Intégration avec des étiquettes nfc/qr à faible coût / ou équivalent -- Points de scan aux entrées/sorties des zones de stockage -- Scan mobile via smartphones/tablettes pour vérification terrain -- Détection automatique des mouvements d'équipements -- Alertes en cas de sortie non autorisée - - mail - - sms - - alerte perso -- Cartographie des dernières localisations connues - -### 3.4 Gestion des affectations - -- Attribution d'équipements à : - - Un utilisateur/employé - - Un projet/chantier - - Un emplacement physique -- Enregistrement des dates de début et fin d'affectation -- Affectation groupée de plusieurs équipements simultanément -- Workflows d'approbation configurables -- Historique complet des affectations - -### 3.5 Gestion des utilisateurs - -- Enregistrement des informations sur les utilisateurs : - - Profil complet (nom, prénom, contact, etc.) - - Rôle et permissions dans le système - - Département/équipe de rattachement -- Suivi des équipements attribués à chaque utilisateur -- Gestion des accès par niveau de permission - -### 3.6 Gestion des projets/emplacements/chantiers - -- Structure flexible adaptable selon les besoins : - - Projets temporaires avec dates de début/fin - - Emplacements physiques permanents - - Zones géographiques -- Hiérarchisation possible (bâtiment > étage > pièce) -- Géolocalisation et cartographie -- Suivi des équipements affectés - -### 3.7 Catégorisation des équipements - -- Système de catégories et sous-catégories multiniveau -- Attributs spécifiques par catégorie d'équipement -- Système de préfixage automatique des références -- Organisation logique adaptée au secteur d'activité - -### 3.8 Analyses et statistiques avancées - -- Dashboard personnalisable avec indicateurs clés -- Rapports sur les taux d'utilisation des équipements -- Analyses prédictives pour planification des besoins -- Alertes sur équipements sous-utilisés ou sur-utilisés -- Statistiques par utilisateur, projet, catégorie et équipement -- Rapports exportables dans différents formats - -### 3.9 Intégration et API - -- API REST complète pour intégration avec d'autres systèmes -- Intégration possible avec des ERP, GMAO, ou logiciels comptables -- Export/import de données en différents formats -- Webhooks pour événements système - -## 4. Description fonctionnelle détaillée - -### 4.1 Structure générale - -- Interface responsive accessible sur tous supports -- Cinq modules principaux : Utilisateurs, Projets/Emplacements, Catégories, Équipements, Affectations -- Navigation intuitive avec accès contextuel aux fonctionnalités -- Dashboard personnalisable par type d'utilisateur - -### 4.2 Module de gestion des utilisateurs - -- Annuaire complet avec recherche avancée et filtres -- Gestion des profils avec historique d'activité -- Vue des équipements actuellement affectés -- Statistiques d'utilisation et de responsabilité matérielle -- Système de notification personnalisable - -### 4.3 Module de gestion des projets/emplacements - -- Structure adaptable selon le secteur d'activité -- Visualisation des équipements actuellement présents -- Timeline d'occupation des ressources -- Planification des besoins futurs -- Cartographie des emplacements physiques - -### 4.4 Module de gestion des catégories - -- Arborescence des catégories personnalisable -- Gestion des attributs spécifiques par catégorie -- Règles de nommage et d'attribution automatisées -- Templates pour accélérer la création d'équipements similaires -- Rapports analytiques par catégorie - -### 4.5 Module de gestion des équipements - -- Interface complète de gestion d'inventaire -- Fiche détaillée avec historique complet de chaque équipement -- Journal d'activité avec tous les mouvements et scans nfc/qr -- Suivi du cycle de vie (de l'acquisition à la mise au rebut) -- Planning de maintenance préventive -- Système d'alerte pour maintenance ou certification à renouveler - -### 4.6 Module de gestion des affectations - -- Processus guidé d'affectation avec validation -- Scan nfc/qr pour confirmation de prise en charge -- Vue calendaire des disponibilités -- Système de réservation anticipée -- Alertes de retour pour affectations arrivant à échéance -- Workflows configurables avec approbations multi-niveaux - -### 4.7 Fonctionnalités de recherche avancée - -- Recherche globale intelligente sur tous les critères -- Filtres contextuels et sauvegarde de recherches favorites -- Recherche par scan nfc/qr pour identification rapide -- Suggestions intelligentes basées sur l'historique - -### 4.8 Module d'administration et paramétrage - -- Configuration complète adaptée à chaque organisation -- Personnalisation de la terminologie et des champs -- Gestion des droits et rôles utilisateurs -- Audit logs pour toutes les actions système -- Paramétrage des notifications et alertes - -## 5. Interactions et automatisations - -### 5.1 Workflow de scan nfc/qr - -- Scan à l'entrée/sortie des zones de stockage -- Mise à jour automatique de la localisation -- Vérification de la légitimité du mouvement -- Création automatique d'affectation sur scan sortant -- Clôture automatique d'affectation sur scan entrant - -### 5.2 Interactions entre équipements - -- Gestion des relations parent/enfant entre équipements -- Suivi des assemblages/désassemblages -- Alertes sur incompatibilités potentielles -- Recommandations d'équipements complémentaires - -### 5.3 Automatisation des processus - -- Rappels automatiques pour retours d'équipements -- Alertes de maintenance basées sur l'utilisation réelle -- Détection d'anomalies dans les patterns d'utilisation -- Suggestions d'optimisation du parc - -Ce cahier des charges est destiné à servir de référence pour le développement d'une plateforme SaaS de gestion d'équipements adaptable à différents secteurs d'activité, avec un accent particulier sur l'automatisation via technologie nfc/qr et l'analyse avancée des données. diff --git a/docs-and-prompts/diagram-mermaid.md b/docs-and-prompts/diagram-mermaid.md deleted file mode 100644 index 785b93f..0000000 --- a/docs-and-prompts/diagram-mermaid.md +++ /dev/null @@ -1,93 +0,0 @@ -erDiagram -Organization { - string id PK - string name - string email - string phone - string address - json settings - string clerkId - string stripeCustomerId - string subscriptionId - string subscriptionStatus - string priceId - date created - date updated -} - -User { - string id PK - string name - string email - string phone - string role - boolean isAdmin - boolean canLogin - string lastLogin - file avatar - boolean verified - boolean emailVisibility - string clerkId - date created - date updated -} - -Equipment { - string id PK - string organizationId FK - string name - string qrNfcCode - string tags - editor notes - date acquisitionDate - string parentEquipmentId FK - date created - date updated -} - -Project { - string id PK - string organizationId FK - string name - string address - editor notes - date startDate - date endDate - date created - date updated -} - -Assignment { - string id PK - string organizationId FK - string equipmentId FK - string assignedToUserId FK - string assignedToProjectId FK - date startDate - date endDate - editor notes - date created - date updated -} - -Image { - string id PK - string title - string alt - string caption - file image - date created - date updated -} - -Organization ||--o{ User : has -Organization ||--o{ Equipment : owns -Organization ||--o{ Project : manages -Organization ||--o{ Assignment : oversees - -User }o--o{ Assignment : "is assigned to" - -Equipment }o--o{ Assignment : "is assigned via" -Equipment }o--o{ Equipment : "parent/child" - -Project }o--o{ Assignment : includes diff --git a/docs-and-prompts/market/contenu-landing-page.md b/docs-and-prompts/market/contenu-landing-page.md deleted file mode 100644 index 2bb26eb..0000000 --- a/docs-and-prompts/market/contenu-landing-page.md +++ /dev/null @@ -1,167 +0,0 @@ -# Contenu Optimisé pour Landing Page ForTooling - -## 1. Hero Section - -### Titre Principal (Options) - -- "Gérez enfin vos équipements BTP sans vous ruiner" -- "Suivez tous vos équipements BTP pour moins de 2€ par jour" -- "Solution innovante pour localiser votre matériel de chantier" - -### Sous-titre - -"Solution simple par QR code - Mise en place en 48h - Sans engagement" - -### CTA Principal - -"ESSAYEZ GRATUITEMENT PENDANT 14 JOURS" - -### Mention Offre de Lancement - -"OFFRE SPÉCIALE LANCEMENT: -50% pour nos 20 premiers clients + implémentation offerte" - -### Visuel Stratégique - -Image/vidéo montrant la solution en action sur un chantier réel - -- Scan QR code sur un équipement -- Vue du dashboard avec localisation -- Interface mobile en situation de chantier - -## 2. Section Problème-Solution - -### Introduction - -"Les entreprises du BTP font face à des défis quotidiens dans la gestion de leur matériel. ForTooling apporte une solution simple et abordable." - -### Tableau Problème-Solution - -| PROBLÈME DANS LE SECTEUR | NOTRE SOLUTION | BÉNÉFICE POTENTIEL | -| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------- | -| Les entreprises BTP perdent en moyenne 15-20% de leurs équipements chaque année\* | Localisation instantanée par QR code et historique complet des mouvements | Réduction drastique des pertes et vols d'équipements | -| Jusqu'à 30 minutes par jour perdues à chercher du matériel sur les chantiers\* | Inventaire accessible en 3 clics avec localisation précise | Gain de temps quotidien pour vos équipes | -| Attribution floue menant à la déresponsabilisation | Traçabilité complète par utilisateur et notifications de non-retour | Responsabilisation des équipes et meilleur soin du matériel | -| Solutions traditionnelles complexes et onéreuses (5-10K€) | Prix fixe ultra-compétitif sans matériel coûteux | ROI rapide et budget maîtrisé | - -\*Selon étude sectorielle BTP Magazine 2023 - -## 3. Comment Ça Marche - -### Étape 1: ÉTIQUETEZ - -"Appliquez nos QR codes ultra-résistants sur vos équipements" - -- Étiquettes waterproof et résistantes aux chocs -- Installation en quelques secondes par équipement -- Compatible avec tous types d'outils et machines - -### Étape 2: SCANNEZ - -"Utilisez votre smartphone pour scanner lors des mouvements" - -- Scan rapide (3 secondes) lors des prises/retours -- Attribution à un utilisateur, projet ou emplacement -- Fonctionne même sans connexion internet sur le chantier - -### Étape 3: CONTRÔLEZ - -"Accédez à votre tableau de bord pour tout visualiser" - -- Vue d'ensemble de votre parc matériel -- Localisation actualisée de chaque équipement -- Historique complet des mouvements et utilisations -- Alertes automatiques pour équipements non retournés - -## 4. Avantages Clés ForTooling - -### Simplicité Extrême - -"Interface conçue pour être utilisée sur chantier, même avec des gants" - -- Prise en main en moins de 5 minutes -- Pas de formation complexe nécessaire -- Utilisable par tous vos collaborateurs - -### Prix Imbattable - -"Solution jusqu'à 70% moins chère que les alternatives traditionnelles" - -- À partir de 1,90€ par jour pour une PME -- Sans achat de matériel coûteux -- ROI généralement atteint dès le premier mois - -### Adapté au Terrain - -"Conçu pour résister aux conditions difficiles des chantiers" - -- Étiquettes ultra-résistantes (poussière, eau, chocs) -- Application mobile robuste et réactive -- Mode hors-ligne pour chantiers isolés - -### Déploiement Express - -"Opérationnel en 48h, sans perturber votre activité" - -- Assistance à la mise en place incluse -- Import facile de vos inventaires existants -- Support réactif par téléphone et email - -## 5. Offre Spéciale Lancement - -### Programme Pionnier ForTooling - -"Rejoignez nos premiers utilisateurs et bénéficiez d'avantages exclusifs" - -- **50% de réduction** sur l'abonnement première année -- **Mise en place et formation offertes** (valeur 500€) -- **Support prioritaire** avec accès direct à l'équipe -- **Influence sur les futures fonctionnalités** - -_Limité aux 20 premiers clients_ - -### CTA Principal Renforcé - -"RÉSERVEZ VOTRE PLACE DANS LE PROGRAMME PIONNIER" - -### Garantie Satisfaction - -"Essai 14 jours sans engagement - Satisfait ou remboursé 30 jours" - -## 6. FAQ Stratégique - -Questions traitant directement les objections potentielles: - -- **Q: Est-ce vraiment adapté à une entreprise qui débute avec la gestion numérique?** - R: Absolument! ForTooling a été conçu pour être aussi simple que possible, même pour les entreprises sans compétences techniques particulières. - -- **Q: Les QR codes résistent-ils vraiment aux conditions de chantier?** - R: Nos étiquettes sont spécialement conçues pour l'environnement BTP - résistantes à l'eau, la poussière, les UV et les chocs modérés. - -- **Q: Comment ForTooling se compare aux grandes solutions du marché?** - R: Nous offrons les fonctionnalités essentielles des grandes solutions (suivi, attribution, historique) mais à une fraction du prix et sans la complexité inutile. - -- **Q: Que se passe-t-il si nous n'avons pas de connexion sur le chantier?** - R: L'application fonctionne parfaitement hors-ligne et synchronise automatiquement les données dès qu'une connexion est disponible. - -- **Q: Combien de temps pour être opérationnel?** - R: La plupart de nos clients sont pleinement opérationnels en 24-48h, incluant l'étiquetage de leurs premiers équipements. - -## 7. CTA Final - -### Appel à l'action de clôture - -"Rejoignez les entreprises BTP qui transforment leur gestion de matériel" - -### Formulaire Simplifié - -- Email professionnel -- Numéro de téléphone -- Taille approximative du parc d'équipements - -### Bouton d'envoi - -"DÉMARRER MON ESSAI GRATUIT" - -### Réassurance finale - -"Sans engagement - Configuration en 48h - Support inclus" diff --git a/docs-and-prompts/market/pages-essentielles.md b/docs-and-prompts/market/pages-essentielles.md deleted file mode 100644 index 03a88c2..0000000 --- a/docs-and-prompts/market/pages-essentielles.md +++ /dev/null @@ -1,114 +0,0 @@ -# Pages Essentielles à Développer pour ForTooling - -## Page "Fonctionnalités" détaillée - -### Structure recommandée - -- Introduction avec bénéfices globaux -- Sections par fonctionnalité majeure avec captures d'écran -- Comparaison discrète avec solutions concurrentes -- Cas d'usage par fonctionnalité -- CTA: "Essayer gratuitement" + "Demander une démo" - -### Éléments clés à inclure - -- **Module Inventaire** - - - Catalogage complet des équipements - - Catégorisation flexible adaptée au BTP - - Informations techniques et commerciales - - Gestion des cycles de vie (acquisition → maintenance → retrait) - -- **Module Suivi QR/NFC** - - - Processus de scan rapide (3 secondes) - - Localisation instantanée des équipements - - Historique complet des mouvements - - Fonctionnement hors-ligne sur chantier - -- **Module Attribution** - - - Assignation aux utilisateurs/équipes - - Attribution à des projets/chantiers - - Gestion des dates de retour prévues - - Alertes de non-retour automatiques - -- **Module Reporting** - - - Dashboard personnalisable - - Statistiques d'utilisation et disponibilité - - Calcul ROI et économies réalisées - - Exports PDF/Excel des rapports clés - -- **Fonctionnalités spéciales BTP** - - Étiquettes ultra-résistantes (poussière, eau, UV) - - Interface utilisable avec gants de chantier - - Vocabulaire et catégories pré-configurés BTP - - Champs personnalisés adaptés au secteur - -## Page "Tarifs" transparente - -### Structure recommandée - -- Introduction sur approche tarifaire (transparence, simplicité) -- 3 plans avec options clairement définies -- Comparaison des fonctionnalités par plan -- FAQ spécifiques aux prix -- CTA: "Démarrer avec [plan]" + "Contact commercial" - -### Plans tarifaires - -1. **Plan Essentiel** - - - Pour TPE/artisans (1-5 utilisateurs) - - Jusqu'à 100 équipements - - Fonctionnalités de base - - Prix: [X]€/mois ou [Y]€/jour - -2. **Plan Business** - - - Pour PME (5-20 utilisateurs) - - Jusqu'à 500 équipements - - Fonctionnalités avancées + rapports - - Prix: [X]€/mois ou [Y]€/jour - -3. **Plan Enterprise** - - Pour grandes entreprises (20+ utilisateurs) - - Équipements illimités - - Toutes fonctionnalités + personnalisation - - Prix: [X]€/mois ou [Y]€/jour - -### Avantages tarifaires à mettre en avant - -- Pas de coût matériel supplémentaire -- Économies réalisées vs pertes actuelles -- Prix par jour (perception moins coûteuse) -- Engagement flexible ou remise annuelle -- Offre spéciale de lancement (-50% premiers clients) - -## Page "À propos / Notre histoire" - -### Structure recommandée - -- Histoire de création (problème observé → solution) -- Mission et vision (démocratiser la gestion d'équipements BTP) -- Équipe (même petite, montrer les visages) -- Valeurs (simplicité, accessibilité, innovation) -- Programme Pionnier mis en avant -- CTA: "Rejoindre l'aventure ForTooling" - -### Éléments narratifs à développer - -- Origine de l'idée (expérience terrain BTP, observation problématiques) -- Approche différenciatrice (simplicité vs solutions complexes existantes) -- Vision du futur de la gestion d'équipements -- Parcours de développement du produit -- Ambitions et roadmap produit à venir - -### Éléments de confiance - -- Expertise combinée BTP et technologie -- Approche centrée sur problématiques terrain -- Témoignages experts/consultants sectoriels -- Mentions médias/partenaires (si disponibles) -- Engagement qualité et support réactif diff --git a/docs-and-prompts/market/pages-seo-sectorielles.md b/docs-and-prompts/market/pages-seo-sectorielles.md deleted file mode 100644 index 3ca28a7..0000000 --- a/docs-and-prompts/market/pages-seo-sectorielles.md +++ /dev/null @@ -1,252 +0,0 @@ -# Pages Stratégiques SEO et Contenu Sectoriel - -## Pages sectorielles/cas d'usage - -Ces pages ciblent des sous-segments spécifiques avec un contenu optimisé pour le référencement et la conversion. - -### 1. "ForTooling pour la maçonnerie" - -#### Structure recommandée - -- Introduction aux défis spécifiques de gestion d'équipements en maçonnerie -- Statistiques sectorielles (pertes d'outils, temps de recherche) -- Fonctionnalités ForTooling adaptées à la maçonnerie -- Catégories d'équipements pré-configurées -- Cas d'usage concrets (chantier type) -- Bénéfices chiffrés spécifiques -- Témoignage expert/consultant secteur (si pas encore de client) -- CTA sectoriel - -#### Mots-clés à cibler - -- gestion équipement maçonnerie -- suivi matériel chantier construction -- localisation outils maçons -- QR code suivi bétonnière/coffrage -- gestion prêt matériel maçonnerie - -#### Équipements spécifiques à mentionner - -- Bétonnières et malaxeurs -- Échafaudages et étais -- Outillage manuel spécifique -- Coffrages et banches -- Équipements de mesure et niveau - -### 2. "ForTooling pour l'électricité/plomberie" - -#### Structure recommandée - -- Introduction aux problématiques des artisans multi-sites -- Coût des outils spécialisés et impact des pertes -- Fonctionnalités ForTooling pour interventions multiples -- Gestion des équipements techniques coûteux -- Attribution aux techniciens itinérants -- Suivi et maintenance des outils de mesure -- ROI calculé pour artisan type -- CTA adapté - -#### Mots-clés à cibler - -- gestion outillage électricien -- suivi matériel plomberie -- attribution équipement techniciens -- traçabilité outils électroportatifs -- inventaire camion artisan - -#### Équipements spécifiques à mentionner - -- Outillage électroportatif -- Appareils de mesure et test -- Échelles et accès en hauteur -- Équipements de sécurité -- Stock véhicules d'intervention - -### 3. "ForTooling pour les locations de matériel" - -#### Structure recommandée - -- Introduction aux défis de la location d'équipements -- Problématique des retours et suivi -- Fonctionnalités de gestion entrées/sorties -- Traçabilité complète et historique -- Suivi état et maintenance des équipements -- Intégration facturation et gestion client -- Avantages compétitifs pour loueurs -- CTA spécifique location - -#### Mots-clés à cibler - -- gestion parc location BTP -- suivi retour équipements loués -- QR code location matériel -- logiciel gestion entrées sorties matériel -- traçabilité équipements location - -#### Fonctionnalités spécifiques à mettre en avant - -- Check-in/check-out rapide -- Suivi état avant/après location -- Historique par client/équipement -- Alertes retards de retour -- Planning disponibilité matériel - -### 4. "ForTooling pour les chefs de chantier" - -#### Structure recommandée - -- Introduction aux défis quotidiens des chefs de chantier -- Impact sur la productivité et planning -- Fonctionnalités d'allocation des ressources -- Visibilité en temps réel sur les équipements -- Planification besoins matériels par phase -- Responsabilisation des équipes -- Gain de temps quotidien estimé -- CTA orienté productivité - -#### Mots-clés à cibler - -- gestion équipement chef chantier -- planification ressources matérielles BTP -- disponibilité outils chantier -- responsabilisation équipe matériel -- optimisation utilisation équipements construction - -#### Avantages spécifiques à mettre en avant - -- Réduction temps recherche matériel -- Anticipation besoins par phase chantier -- Suivi utilisation par équipe/ouvrier -- Réduction conflits attribution matériel -- Meilleure planification ressources - -## Blog et Ressources - -### Catégories d'articles à développer - -1. **Guides pratiques** - - - Conseils d'optimisation de gestion d'équipements - - Tutoriels étape par étape - - Check-lists et processus - -2. **Études sectorielles** - - - Statistiques et tendances BTP - - Benchmarks et comparatifs - - Analyse coûts cachés - -3. **Conseils et meilleures pratiques** - - - Organisation et méthodes - - Responsabilisation des équipes - - Optimisation des processus - -4. **Innovations technologiques** - - - Nouveautés dans le BTP - - Technologies de traçabilité - - Digitalisation des chantiers - -5. **Témoignages et cas d'usage** - - Interviews experts - - Retours d'expérience - - Études de cas - -### Articles initiaux prioritaires - -1. **"Les coûts cachés d'une mauvaise gestion d'équipements BTP"** - - - Chiffrer les pertes financières réelles - - Impact sur productivité et délais - - Coûts indirects (recherche, remplacement, conflits) - - Solution et calcul ROI - -2. **"Comment réduire de 70% vos pertes de matériel sur chantier"** - - - Statistiques pertes secteur BTP - - Causes principales identifiées - - Méthodologie de réduction en 5 étapes - - Technologies facilitantes - -3. **"QR codes vs RFID vs GPS: quelle technologie de suivi pour vos équipements?"** - - - Comparatif détaillé des technologies - - Avantages/inconvénients de chaque solution - - Critères de choix selon besoins - - Analyse coûts/bénéfices - -4. **"5 indicateurs clés pour évaluer l'efficacité de votre gestion de parc matériel"** - - - KPIs essentiels à suivre - - Méthodes de calcul et benchmarks - - Outils de mesure recommandés - - Plan d'amélioration continu - -5. **"Guide: Comment mettre en place un système de traçabilité en 1 semaine"** - - Planification et préparation - - Étapes jour par jour - - Ressources nécessaires - - Conseils pour adoption rapide - -## Centre de Ressources - -### Types de contenus à proposer - -- **Guides téléchargeables (PDF)** - - - Guides approfondis et bien structurés - - Design professionnel avec illustrations - - Contenu actionnable et pratique - -- **Templates et calculateurs (Excel)** - - - Outils prêts à l'emploi - - Formules et automatisations utiles - - Instructions d'utilisation claires - -- **Checklists imprimables** - - - Format synthétique et pratique - - Points essentiels à vérifier - - Personnalisables par l'utilisateur - -- **Vidéos tutoriels** - - - Courtes (3-5 minutes maximum) - - Démonstrations pas-à-pas - - Sous-titrées et bien structurées - -- **Webinaires enregistrés** - - Présentations thématiques (30-45 min) - - Q&A incluses - - Slides téléchargeables - -### Ressources initiales prioritaires - -1. **"Guide ultime de la gestion d'équipements BTP" (ebook)** - - - 15-20 pages approfondies - - Illustrations et schémas - - Conseils pratiques et méthodologie - - Études de cas et exemples - -2. **"Calculateur ROI ForTooling" (spreadsheet interactif)** - - - Calculateur d'économies personnalisé - - Projection sur 1, 2 et 3 ans - - Comparaison avant/après - - Graphiques automatisés - -3. **"Checklist: Préparer votre migration vers un système digital"** - - - Liste de contrôle pré-migration - - Étapes essentielles chronologiques - - Points de vigilance et conseils - - Format imprimable A4 - -4. **"10 astuces pour maximiser la durée de vie de vos équipements"** - - Guide pratique maintenance préventive - - Conseils stockage et manipulation - - Fréquences d'entretien recommandées - - Estimation économies réalisables diff --git a/docs-and-prompts/market/pages-support-conversion.md b/docs-and-prompts/market/pages-support-conversion.md deleted file mode 100644 index 080c3ed..0000000 --- a/docs-and-prompts/market/pages-support-conversion.md +++ /dev/null @@ -1,147 +0,0 @@ -# Pages de Support à la Conversion - -## Page "Comment ça marche" approfondie - -### Structure recommandée - -- Vidéo explicative (1-2 min) -- Processus détaillé en 5-7 étapes -- Zoom sur l'implémentation (48h) -- Témoignages d'experts sectoriels (si pas de clients, consultants BTP) -- FAQ spécifiques à l'implémentation -- CTA: "Voir une démo" + "Essai gratuit" - -### Processus à détailler - -1. **Inscription et configuration initiale** (15 min) - - - Création du compte entreprise - - Configuration des paramètres clés - - Personnalisation des catégories d'équipements - -2. **Import initial des équipements** (1-2h) - - - Upload de fichier Excel existant ou - - Saisie manuelle simplifiée ou - - Assistance à l'import par notre équipe - -3. **Étiquetage des équipements** (progressif) - - - Réception des QR codes résistants - - Application sur les équipements - - Scan initial de référencement - -4. **Formation des utilisateurs** (30 min) - - - Session de démonstration - - Guide pas-à-pas dans l'application - - Accès à des tutoriels vidéo - -5. **Déploiement terrain** (1-3 jours) - - - Premiers scans en conditions réelles - - Suivi des premières attributions - - Ajustement des processus si nécessaire - -6. **Optimisation continue** - - Analyse des premiers jours d'utilisation - - Recommandations personnalisées d'utilisation - - Ajout progressif d'équipements supplémentaires - -### Éléments visuels à inclure - -- Calendrier visuel du déploiement -- Screenshots étape par étape -- Exemples de QR codes et étiquettes -- Témoignages visuels de satisfaction - -## Page "FAQ" complète - -### Structure recommandée - -- Sections par thématique -- Questions organisées de générales à spécifiques -- Réponses concises mais complètes -- Liens vers pages détaillées -- CTA contextuel après chaque section - -### Catégories et questions essentielles - -#### Questions générales - -- Qu'est-ce que ForTooling exactement? -- Comment ForTooling se compare-t-il aux solutions existantes? -- Combien de temps pour être opérationnel? -- ForTooling est-il adapté à une petite entreprise? -- Puis-je essayer ForTooling avant de m'engager? - -#### Questions techniques - -- Les QR codes résistent-ils aux conditions de chantier? -- Que se passe-t-il si je n'ai pas de connexion sur le chantier? -- Quels appareils sont compatibles avec ForTooling? -- Les données sont-elles sécurisées? -- Puis-je exporter mes données facilement? - -#### Questions d'implémentation - -- Comment importer mon inventaire existant? -- Comment former mes équipes à l'utilisation? -- Combien de temps pour étiqueter tout mon matériel? -- Puis-je déployer progressivement la solution? -- Quel support recevrai-je pendant l'implémentation? - -#### Questions tarifaires - -- Y a-t-il des coûts cachés ou supplémentaires? -- Que comprend exactement chaque forfait? -- Puis-je changer de forfait en cours d'abonnement? -- Comment fonctionne la période d'essai? -- Offrez-vous des remises pour engagement annuel? - -#### Questions support et utilisation - -- Quel support est disponible en cas de problème? -- Proposez-vous des formations avancées? -- Comment suggérer de nouvelles fonctionnalités? -- Quelle est la disponibilité du service (uptime)? -- Comment contacter le support technique? - -## Page "Contact/Démo" - -### Structure recommandée - -- Options de contact (formulaire, email, téléphone) -- Planification de démo (calendrier Calendly) -- Processus de démonstration expliqué -- Formulaire contact intelligent (qualification leads) -- CTA secondaire: "Essai gratuit immédiat" - -### Formulaire de contact stratégique - -- Nom et prénom -- Email professionnel -- Téléphone (optionnel mais recommandé) -- Entreprise et fonction -- Taille de l'entreprise (dropdown) -- Nombre approximatif d'équipements à suivre -- Problématique principale (dropdown) -- Message personnalisé -- Préférence de contact (email, téléphone, visioconférence) - -### Section démo personnalisée - -- Titre: "Découvrez ForTooling en action sur vos cas d'usage" -- Explication: Démo personnalisée de 20 minutes -- Bénéfices: Focus sur vos besoins spécifiques -- Processus en 3 étapes (Prise de RDV → Préparation → Démonstration) -- Calendrier intégré pour réserver un créneau -- Témoignage sur qualité des démos - -### Informations de contact direct - -- Numéro de téléphone dédié -- Email de contact -- Horaires de disponibilité -- Temps de réponse moyen -- Chat en direct (si disponible) diff --git a/docs-and-prompts/market/pages-techniques-parcours.md b/docs-and-prompts/market/pages-techniques-parcours.md deleted file mode 100644 index 00a93bc..0000000 --- a/docs-and-prompts/market/pages-techniques-parcours.md +++ /dev/null @@ -1,189 +0,0 @@ -# Pages Techniques et Parcours Utilisateur - -## Pages Techniques et Légales - -### Pages Légales Essentielles - -1. **CGV/CGU** - - - Conditions claires et transparentes - - Langage accessible (éviter jargon juridique excessif) - - Sections bien structurées par thème - - Date de dernière mise à jour visible - -2. **Politique de confidentialité (RGPD)** - - - Données collectées et finalités - - Conservation et protection des données - - Droits des utilisateurs - - Utilisation des cookies - - Procédures de demande d'accès/suppression - -3. **Mentions légales** - - - Informations société - - Hébergement - - Directeur de publication - - Propriété intellectuelle - - Limitations de responsabilité - -4. **Conditions d'utilisation du service** - - Droits d'utilisation - - Restrictions d'usage - - Garanties et limites - - Résiliation et suspension - - Support et maintenance - -### Pages Techniques à Développer - -1. **Sécurité des données** - - - Architecture sécurisée - - Chiffrement et protection - - Sauvegardes et redondance - - Conformité RGPD - - Tests de sécurité réguliers - -2. **API et intégrations** - - - Documentation API (même basique pour le futur) - - Intégrations existantes ou prévues - - Procédure de demande d'accès API - - Cas d'usage d'intégration - - Support développeurs - -3. **Guide utilisateur/Centre d'aide** - - Navigation par rôle utilisateur - - Recherche intégrée - - Articles base de connaissances - - Vidéos tutoriels courtes - - FAQ technique détaillée - -## Optimisation des Parcours Utilisateur - -### Parcours d'Onboarding - -1. **Page "Premiers pas avec ForTooling"** - - - Guide visuel étape par étape - - Vidéo d'introduction (2-3 min) - - Checklist interactive de démarrage - - Jalons d'activation clairs - - Contact support dédié nouvel utilisateur - -2. **Guides spécifiques par profil utilisateur** - - - Pour administrateurs système - - Pour responsables matériel - - Pour utilisateurs terrain - - Pour chefs de chantier/projet - - Pour direction/décideurs (rapports) - -3. **Vidéos d'initiation courtes** - - - Série "Démarrer en 10 minutes" - - Tutoriels ciblés par fonctionnalité (1-2 min) - - Démos cas d'usage courants - - Astuces et raccourcis - - Questions fréquentes visuelles - -4. **Checklist de démarrage** - - Étapes essentielles séquentielles - - Indicateurs de progression - - Validation des étapes complétées - - Contenus d'aide contextuelle - - Célébration des succès d'activation - -### Programme Partenaires/Affiliés - -1. **Page Programme Ambassadeur** - - - Présentation des avantages - - Fonctionnement de la commission - - Témoignages partenaires (une fois existants) - - Outils marketing fournis - - FAQ programme partenaire - -2. **Commission référencement** - - - Grille de commission transparente - - Processus de tracking des leads - - Conditions de paiement - - Tableau de bord partenaire - - Support dédié partenaires - -3. **Processus d'inscription** - - - Critères d'éligibilité - - Formulaire de candidature - - Étapes de validation - - Formation initiale partenaire - - Kit de démarrage - -4. **Avantages et conditions** - - Avantages financiers détaillés - - Formations exclusives - - Accès anticipé nouvelles fonctionnalités - - Co-marketing opportunités - - Événements partenaires - -## Stratégie de Contenu par Phase - -### Phase 1 (Lancement - 3 premiers mois) - -1. Landing page principale -2. Pages Fonctionnalités, Tarifs, À propos -3. Page Comment ça marche -4. FAQ essentielle -5. Blog (3-5 articles initiaux) -6. Pages légales obligatoires - -**Priorité**: Conversion des premiers visiteurs en utilisateurs - -### Phase 2 (Développement - 3-6 mois) - -1. Pages sectorielles (2-3 premières) -2. Centre de ressources basique -3. Expansion du blog (1-2 articles/semaine) -4. Témoignages initiaux (dès premiers clients) -5. FAQ approfondie - -**Priorité**: SEO et création d'autorité dans le domaine - -### Phase 3 (Optimisation - 6-12 mois) - -1. Études de cas détaillées -2. Contenus avancés (webinaires, podcasts) -3. Pages partenaires et intégrations -4. Contenu généré par utilisateurs -5. Communauté utilisateurs - -**Priorité**: Rétention et expansion de l'écosystème - -## Recommandations pour Mise en Œuvre - -1. **Prioriser selon impact sur conversion**: - - - Landing page → Fonctionnalités → Tarifs → Comment ça marche - -2. **Créer une structure modulaire**: - - - Composants réutilisables (témoignages, CTA, avantages) - - Système de blocs cohérents - -3. **Maintenir cohérence visuelle et messagerie**: - - - Palette de couleurs consistante - - Mêmes messages clés sur toutes les pages - - Iconographie et illustrations harmonisées - -4. **Optimiser pour mobile en priorité**: - - - Interface simplifiée - - CTAs adaptés (plus grands sur mobile) - - Navigation intuitive - -5. **Intégrer mesure et analytics**: - - Événements de conversion sur chaque page - - Heatmaps sur pages critiques - - Tests A/B progressifs diff --git a/docs-and-prompts/market/strategie-marketing-fortooling.md b/docs-and-prompts/market/strategie-marketing-fortooling.md deleted file mode 100644 index 44765d4..0000000 --- a/docs-and-prompts/market/strategie-marketing-fortooling.md +++ /dev/null @@ -1,276 +0,0 @@ -# Stratégie Marketing et Tunnel de Conversion ForTooling - -## 1. Positionnement Stratégique - -### 1.1 Unique Selling Proposition (USP) - -"ForTooling : La solution de gestion d'équipements BTP la plus simple et abordable du marché - Suivez tout votre matériel pour moins de 2€ par jour." - -### 1.2 Points de différenciation clés - -- **Prix ultra-compétitif** (50-70% moins cher que les concurrents) -- **Zéro matériel coûteux** (utilisation de QR codes/NFC vs balises GPS onéreuses) -- **Solution terrain adaptée aux chantiers** (interface simplifiée, étiquettes résistantes) -- **Mise en place en moins de 48h** (vs semaines pour solutions concurrentes) -- **ROI immédiat et mesurable** (diminution des pertes, gain de temps) - -### 1.3 Persona cibles prioritaires - -1. **Directeur de PME BTP** (40-55 ans, préoccupé par les coûts et l'efficacité) -2. **Responsable matériel/logistique** (35-45 ans, soucieux de l'organisation) -3. **Chef de chantier** (30-50 ans, frustré par les pertes de temps) - -## 2. Architecture du Tunnel de Conversion - -### 2.1 Étape 1: Attraction (Top du Funnel) - -- **SEO ciblé** sur requêtes problématiques ("perte matériel chantier", "gestion outillage BTP") -- **Google Ads** sur mots-clés transactionnels à fort intent -- **Posts LinkedIn** ciblant les décideurs BTP (format statistiques choc + solution) -- **Publicité dans médias spécialisés BTP** (print et digital) - -### 2.2 Étape 2: Intérêt (Landing Page) - -- **Hero section impactante**: - - - Headline: "Fini les pertes de matériel: suivez tous vos équipements BTP pour 1,90€ par jour" - - Sous-title: "Solution simple par QR code - Mise en place en 48h - Sans engagement" - - Démonstration vidéo courte (30s) montrant la simplicité d'utilisation - - CTA principal: "ESSAI GRATUIT 14 JOURS" (en orange, contrasté) - - Preuve sociale: "Déjà +3000 équipements suivis dans 47 entreprises BTP" - -- **Section problème-solution immédiate** (priorité #1): - | PROBLÈME | NOTRE SOLUTION | BÉNÉFICE CHIFFRÉ | - |----------|----------------|------------------| - | 15-20% des équipements perdus chaque année | Localisation instantanée par QR code | Économie de 5 000-15 000€/an | - | 30 min/jour perdues à chercher du matériel | Inventaire accessible en 3 clics | Gain de 125h/an/employé | - | Attribution floue et déresponsabilisation | Traçabilité complète par utilisateur | -70% d'équipements non retournés | - | Solutions concurrentes à 5-10K€ | Prix fixe ultra-compétitif | ROI dès le premier mois | - -### 2.3 Étape 3: Considération (Mid-Funnel) - -- **Démonstration du fonctionnement** (3 étapes ultra-simples): - - 1. **ÉTIQUETEZ** vos équipements avec nos QR codes ultra-résistants - 2. **SCANNEZ** pour attribuer ou déplacer (3 secondes par scan) - 3. **CONTRÔLEZ** votre parc complet depuis le dashboard - -- **Section témoignages** avec métriques précises: - - - "Nous avons réduit nos pertes d'équipements de 83% en 3 mois" - Martin D., Directeur, MTP Construction - - "Économie de 12 500€ la première année et gain de temps quotidien" - Sophie L., Resp. Logistique, BatiPro - - Inclure photos, logos d'entreprises et postes spécifiques - -- **Social proof renforcée**: - - Compteur en temps réel d'équipements suivis - - Logos clients (avec autorisations) - - Notation clients (4.8/5 basée sur X avis) - -### 2.4 Étape 4: Conversion (Bottom Funnel) - -- **Pricing stratégique**: - - - Afficher tarifs en "par jour" plutôt qu'en mensuel (perception de coût moindre) - - Proposer 3 formules avec celle du milieu pré-sélectionnée (technique d'ancrage) - - Comparer avec le "coût de ne rien faire" (pertes annuelles moyennes: 7500€) - - Garantie "satisfait ou remboursé 30 jours" (réduction du risque perçu) - -- **CTA d'essai gratuit omniprésent**: - - Formulaire d'inscription ultra-simplifié (email + téléphone uniquement) - - "Commencez en 2 minutes - Sans carte bancaire" - - Décompte de temps limité: "Offre spéciale: -20% les 3 premiers mois si vous vous inscrivez aujourd'hui" - -### 2.5 Étape 5: Onboarding (Post-conversion) - -- **Séquence email automatisée**: - - - J1: Guide de démarrage rapide + vidéo personnalisée - - J3: Check-in "Besoin d'aide?" + cas d'usage clés - - J7: Partage de succès clients similaires - - J10: Invitation démonstration personnalisée - - J12: Rappel fin d'essai + témoignages résultats - - J14: Offre spéciale première année + formulaire CB - -- **Relance téléphonique stratégique**: - - Appel à J5: "Comment se passe votre essai? Des questions?" - - Appel à J13: "Prêt à continuer? Offre spéciale réservée pour vous" - -## 3. Optimisation SEO Stratégique - -### 3.1 Mots-clés prioritaires - -- **Intention transactionnelle forte**: - - - "logiciel gestion équipement BTP" - - "suivi matériel chantier QR code" - - "solution traçabilité outils construction" - - "gestion inventaire entreprise BTP" - -- **Intention informationnelle** (content marketing): - - "comment réduire pertes matériel chantier" - - "coût perte équipement construction" - - "responsabilisation équipe BTP" - - "ROI gestion parc équipements" - -### 3.2 Structure de contenu SEO - -- **Pages de landing spécifiques par problématique**: - - - /reduction-pertes-materiels-chantier - - /suivi-outils-qr-code - - /gestion-attribution-equipements-btp - - /economie-gestion-materiel-construction - -- **Blog optimisé** (minimum 2 articles/mois): - - "Comment cette entreprise a économisé 15 000€ en réduisant ses pertes de matériel" - - "Guide: Calculez ce que vous coûtent vraiment vos pertes d'équipements" - - "5 techniques pour responsabiliser vos équipes sur le matériel" - - "Étude de cas: De l'Excel à ForTooling - Transformation digitale d'un parc matériel" - -### 3.3 Optimisations techniques - -- **Schema.org markup** pour: - - - Témoignages (Review Schema) - - Tarifs (Offer Schema) - - FAQ (FAQPage Schema) - - Organisation (Organization Schema) - -- **Core Web Vitals** optimisés pour mobile: - - LCP < 2.5s (images optimisées, serveur rapide) - - FID < 100ms (JavaScript non-bloquant) - - CLS < 0.1 (layout stable, fonts préchargées) - -## 4. Conversion Rate Optimization (CRO) - -### 4.1 Tests A/B prioritaires - -1. **Hero Section**: - - - Headline axé problème vs headline axé solution - - CTA "Essai gratuit" vs "Voir la démo en 2 min" - - Vidéo autoplay vs image statique - -2. **Formulaire de conversion**: - - - Minimal (email uniquement) vs standard (email + téléphone) - - Pop-up vs inline - - Avec/sans countdown timer - -3. **Preuve sociale**: - - Logos clients vs témoignages détaillés - - Statistiques chiffrées vs histoires de réussite - - Placement haut vs bas de page - -### 4.2 Micro-conversions à tracker - -- Pourcentage de scroll (≥70% = intent) -- Temps passé sur page (≥2min = intent) -- Clics sur témoignages (fort intent) -- Visionnage vidéo démo (fort intent) -- Ouverture FAQ (intent modéré) - -### 4.3 Objections à lever explicitement - -- **Objection prix**: "Plus abordable qu'un seul équipement perdu par mois" -- **Objection complexité**: "Prise en main en moins de 5 minutes, même sans compétence technique" -- **Objection temps**: "Déploiement en 48h sans perturber votre activité" -- **Objection internet**: "Fonctionne hors-ligne sur les chantiers isolés" -- **Objection engagement**: "Sans engagement - Résiliable à tout moment" - -## 5. Éléments Visuels Marketing Stratégiques - -### 5.1 Images à fort impact - -- **Avant/Après visuel**: Chaos d'équipements vs organisation parfaite -- **ROI visualisé**: Graphique économies réalisées vs coût solution -- **Contexte réel**: Photos sur chantiers authentiques, pas de stock photos -- **Process simplifié**: Infographie 3 étapes (étiqueter → scanner → contrôler) - -### 5.2 Vidéos persuasives - -- **Démo ultra-courte** (30s) en autoplay sans son: scan → dashboard → localisation -- **Témoignage client** (1min): problème → solution → résultats mesurables -- **Explication technique** (2min): pour rassurer décideurs techniques - -### 5.3 Confiance et crédibilité - -- **Badges sécurité/RGPD**: conformité, sécurité des données -- **Logos partenaires/clients**: reconnaissance par l'écosystème -- **Certifications**: labels qualité, innovation -- **Médias**: mentions presse spécialisée BTP - -## 6. Tactiques de Growth Hacking - -### 6.1 Acquisition non-conventionnelle - -- **Partenariats fournisseurs BTP**: offre groupée avec vendeurs d'équipements -- **Programme ambassadeur**: commission pour chaque entreprise référée -- **Webinaires ciblés**: "Comment réduire vos pertes d'équipements de 70% en 30 jours" -- **Défi gratuit**: "Testez pendant 14 jours et mesurez vos économies - Résultats garantis" - -### 6.2 Rétention optimisée - -- **Gamification**: score "d'efficacité matériel" comparé à la moyenne du secteur -- **Alertes ROI**: notifications des économies réalisées -- **Check-in trimestriel**: rapport personnalisé d'optimisation avec consultant -- **Communauté**: groupe privé d'échange entre responsables matériel - -### 6.3 Referral Engine - -- **Programme "Parrainez un artisan"**: 2 mois offerts pour chaque référence -- **Co-marketing**: témoignages clients en échange de visibilité -- **Contenu co-créé**: études de cas détaillées avec clients ambassadeurs - -## 7. Plan d'Implémentation Prioritaire - -### 7.1 Actions immédiates (J+0 à J+30) - -1. Refonte de la landing page avec structure de conversion optimisée -2. Mise en place des tunnels d'emails automatisés pré/post essai -3. Création de 3 témoignages clients détaillés (vidéo + texte) -4. Configuration tracking analytics conversion (objectifs GA4/Meta) -5. Lancement campagne Google Ads sur mots-clés prioritaires - -### 7.2 Seconde phase (J+30 à J+90) - -1. Développement de 5 articles de blog optimisés SEO -2. Création landing pages spécifiques par problématique -3. Mise en place programme de parrainage client -4. Lancement tests A/B principaux (headline, CTA, formulaire) -5. Développement automatisation relances essais gratuits - -### 7.3 KPIs critiques à suivre - -- Taux de conversion visiteur → essai gratuit (objectif: >5%) -- Taux de conversion essai → client payant (objectif: >30%) -- CAC (Coût d'Acquisition Client) (objectif: <3 mois de revenu) -- LTV (Lifetime Value) (objectif: >24 mois) -- Taux de churn mensuel (objectif: <3%) - -## 8. Messages Persuasifs Clés (Copywriting) - -### 8.1 Headlines A/B testés - -- "Stop aux 7500€ perdus chaque année en équipements égarés sur vos chantiers" -- "Suivez 100% de vos équipements BTP pour moins de 2€ par jour - Sans matériel coûteux" -- "Vos outils toujours localisés, vos équipes responsabilisées, votre budget préservé" -- "Cette solution QR code a permis à 47 entreprises BTP d'économiser 350 000€ de matériel" - -### 8.2 Éléments de friction à éliminer - -- Formulaire trop long (réduire au strict minimum) -- Jargon technique (simplifier le langage) -- Prix mensuel (préférer affichage quotidien ou annuel avec économies) -- Étapes multiples (réduire au maximum les clics vers conversion) - -### 8.3 Modificateurs de valeur perçue - -- Calcul personnalisé des économies potentielles -- Comparatif direct avec solutions concurrentes -- Démonstration du temps économisé (convertir en euros) -- Garantie "Satisfait ou Remboursé" proéminente - ---- - -**RAPPEL STRATÉGIQUE**: L'objectif principal n'est pas de "vendre" mais de convaincre d'essayer le produit gratuitement pendant 14 jours. La véritable conversion s'effectuera grâce à l'expérience produit elle-même et au processus d'onboarding soigneusement orchestré. diff --git a/docs-and-prompts/market/strategie-marketing-honnete.md b/docs-and-prompts/market/strategie-marketing-honnete.md deleted file mode 100644 index b99f3cb..0000000 --- a/docs-and-prompts/market/strategie-marketing-honnete.md +++ /dev/null @@ -1,85 +0,0 @@ -# Stratégie Marketing ForTooling - Phase de Lancement - -## Positionnement Stratégique pour une Nouvelle Solution - -### USP (Unique Selling Proposition) - -"ForTooling : La solution de gestion d'équipements BTP la plus simple et abordable du marché - Suivez tout votre matériel pour moins de 2€ par jour." - -### Points de différenciation clés (Factuel et vérifiable) - -- **Prix ultra-compétitif** (50-70% moins cher que les options établies) -- **Zéro matériel coûteux** (QR codes/NFC vs balises GPS onéreuses) -- **Solution terrain adaptée aux chantiers** (interface simplifiée, étiquettes résistantes) -- **Mise en place en moins de 48h** (vs semaines pour solutions traditionnelles) -- **ROI rapide et mesurable** (diminution des pertes, gain de temps) - -### Persona cibles prioritaires - -1. **Directeur de PME BTP** (40-55 ans, préoccupé par les coûts et l'efficacité) -2. **Responsable matériel/logistique** (35-45 ans, soucieux de l'organisation) -3. **Chef de chantier** (30-50 ans, frustré par les pertes de temps) - -## Avantages du Statut de Nouvelle Entreprise - -### Transformer votre nouveauté en force - -- **Agilité et réactivité**: Adaptation rapide aux besoins spécifiques des premiers clients -- **Support personnalisé**: Attention particulière aux premiers utilisateurs -- **Influence sur le développement**: Participation à l'évolution du produit -- **Conditions préférentielles**: Avantages exclusifs pour les premiers adoptants - -### Programme "Pionniers ForTooling" - -- Réduction tarifaire substantielle pour les 20 premiers clients -- Support direct avec les fondateurs/développeurs -- Mise en avant future (avec accord) comme partenaires de la première heure -- Webinaires exclusifs et rencontres networking - -## Utilisation Stratégique des Données Sectorielles - -### Statistiques BTP exploitables (à sourcer) - -- Taux moyen de perte d'équipements dans le secteur (15-20% annuel) -- Coût moyen du remplacement de matériel (X€/an pour une PME moyenne) -- Temps quotidien perdu à rechercher du matériel (20-30 min/personne/jour) -- Impact financier des retards de chantier liés aux problèmes d'équipement - -### Calculs de ROI à mettre en avant - -- Simulateur d'économies basé sur taille de l'entreprise et parc d'équipement -- Coût réel des pertes vs investissement ForTooling -- Valorisation du temps gagné en recherche de matériel -- Économies liées à la prolongation de la durée de vie des équipements - -## Approche Content Marketing Adaptée - -### Contenu de valeur à créer en priorité - -- Guide: "Comment réduire les pertes de matériel sur vos chantiers" -- Ebook: "Les coûts cachés d'une mauvaise gestion d'équipements" -- Calculateur: "Estimez vos pertes annuelles d'équipements" -- Checklist: "10 bonnes pratiques pour augmenter la durée de vie de votre matériel" - -### Partenariats de contenu stratégiques - -- Collaboration avec médias BTP pour articles d'expertise -- Interviews de dirigeants et experts du secteur sur leurs problématiques -- Webinaires co-organisés avec fournisseurs d'équipements -- Présence sur salons professionnels avec offre spéciale salon - -## Stratégie d'Acquisition Adaptée aux Débuts - -### Canaux prioritaires - -- LinkedIn: ciblage précis des décideurs BTP -- Google Ads: mots-clés spécifiques à forte intention -- Démarchage direct: approche personnalisée des premiers clients -- Réseaux d'entrepreneurs et associations BTP - -### Tactiques d'acquisition créatives - -- "Test Challenge": Essai comparatif de ForTooling vs méthode actuelle pendant 14 jours -- Démonstrations in situ sur petits parcs d'équipements -- Programme parrainage avant même le lancement -- Offres groupées pour fédérations/groupements d'entreprises BTP diff --git a/docs-and-prompts/market/tunnel-conversion.md b/docs-and-prompts/market/tunnel-conversion.md deleted file mode 100644 index c322e61..0000000 --- a/docs-and-prompts/market/tunnel-conversion.md +++ /dev/null @@ -1,206 +0,0 @@ -# Tunnel de Conversion ForTooling - Phase de Lancement - -## 1. Structure du Tunnel de Vente - -### Phase 1: Attraction (Acquisition) - -- **Objectif**: Attirer des prospects qualifiés vers la landing page -- **Canaux prioritaires**: Google Ads, LinkedIn, référencement naturel -- **Message principal**: "Solution innovante pour suivre vos équipements BTP à prix mini" -- **KPI**: Coût par clic qualifié, taux de rebond initial - -### Phase 2: Intérêt (Landing Page) - -- **Objectif**: Capter l'attention et démontrer la compréhension du problème -- **Méthode**: Hero section impactante + section problème/solution -- **Message clé**: "Fini les pertes d'équipements et le temps perdu à chercher" -- **KPI**: Taux de scroll, temps sur page - -### Phase 3: Considération (Démonstration Valeur) - -- **Objectif**: Prouver l'efficacité et le ROI de la solution -- **Méthode**: Section "Comment ça marche" + avantages + simulateur d'économies -- **Message clé**: "Simple, rapide et jusqu'à 70% moins cher que les alternatives" -- **KPI**: Interactions avec simulateur, vidéos vues - -### Phase 4: Conversion (Essai Gratuit) - -- **Objectif**: Inciter à l'essai gratuit de 14 jours -- **Méthode**: Offre spéciale lancement + formulaire simplifié + garanties -- **Message clé**: "Essayez sans risque pendant 14 jours - Programme pionnier" -- **KPI**: Taux de conversion vers essai gratuit - -### Phase 5: Onboarding (Post-Conversion) - -- **Objectif**: Maximiser l'adoption et l'usage pendant l'essai -- **Méthode**: Email séquentiels + appel de bienvenue + guide démarrage -- **Message clé**: "Voyez des résultats concrets en seulement quelques jours" -- **KPI**: Taux d'activation, % utilisation des fonctionnalités clés - -### Phase 6: Conversion finale (Devenir client) - -- **Objectif**: Transformer l'essai en abonnement payant -- **Méthode**: Démonstration ROI déjà réalisé + offre spéciale fin d'essai -- **Message clé**: "Continuez à économiser avec notre offre spéciale pionnier" -- **KPI**: Taux de conversion essai → client payant - -## 2. Optimisation du Formulaire d'Essai Gratuit - -### Principes clés - -- **Minimalisme**: Demander uniquement l'information essentielle -- **Étapes**: Limiter à une seule étape si possible (max 2) -- **Valeur perçue**: Mettre en avant ce qu'ils obtiennent immédiatement -- **Réduction des frictions**: Éliminer tout obstacle à la complétion - -### Informations à collecter (par ordre de priorité) - -1. Email professionnel (obligatoire) -2. Numéro de téléphone (obligatoire - crucial pour suivi) -3. Nom de l'entreprise (obligatoire) -4. Taille approximative du parc d'équipements (optionnel mais utile) - -### Éléments de réassurance - -- "Sans carte bancaire" -- "Configuration en 48h" -- "Données sécurisées et confidentielles" -- "Annulation en 1 clic" - -## 3. Séquence Emails Post-Inscription - -### Email 1: Confirmation immédiate - -- **Objet**: "Bienvenue dans l'aventure ForTooling! Voici la suite..." -- **Contenu**: Confirmation + prochaines étapes + calendrier rendez-vous onboarding -- **CTA**: "Planifier mon appel de démarrage rapide (15min)" - -### Email 2: J+1 - Guide de démarrage - -- **Objet**: "Votre guide étape par étape pour démarrer avec ForTooling" -- **Contenu**: PDF guide démarrage + vidéo courte + FAQ initiale -- **CTA**: "Voir la vidéo de démarrage (3min)" - -### Email 3: J+3 - Première vérification - -- **Objet**: "Avez-vous rencontré des difficultés avec ForTooling?" -- **Contenu**: Check-in + astuces clés + proposition d'aide -- **CTA**: "Répondre pour obtenir de l'aide" ou "Tout va bien!" - -### Email 4: J+7 - Milestone et fonctionnalités avancées - -- **Objet**: "Découvrez ces 3 fonctionnalités qui vous feront gagner du temps" -- **Contenu**: Fonctionnalités avancées + témoignage + astuce pro -- **CTA**: "Activer ces fonctionnalités" - -### Email 5: J+10 - Partage de cas d'usage - -- **Objet**: "Comment les entreprises BTP utilisent ForTooling (exemples concrets)" -- **Contenu**: Cas d'usage + scénarios + bonnes pratiques -- **CTA**: "Appliquer ces méthodes à votre entreprise" - -### Email 6: J+12 - Préparation fin d'essai - -- **Objet**: "Votre essai ForTooling se termine dans 2 jours - Voici votre offre spéciale" -- **Contenu**: Récapitulatif valeur + offre exclusive + procédure simple -- **CTA**: "Activer mon offre spéciale pionniers (-50%)" - -### Email 7: J+14 - Dernier jour - -- **Objet**: "DERNIER JOUR - Votre décision concernant ForTooling" -- **Contenu**: Options disponibles + rappel bénéfices + témoignages -- **CTA**: "Continuer avec ForTooling" ou "Planifier un dernier appel" - -### Email 8: J+15 - Récupération (si pas converti) - -- **Objet**: "Nous respectons votre décision, mais avant de nous quitter..." -- **Contenu**: Sondage court + offre dernière chance + possibilité extension -- **CTA**: "Bénéficier d'une semaine supplémentaire d'essai" - -## 4. Script d'Appel de Bienvenue - -### Objectif de l'appel - -Établir une relation, comprendre les besoins spécifiques, assurer le bon démarrage - -### Introduction (1min) - -"Bonjour [Prénom], merci d'avoir démarré votre essai de ForTooling! Je m'appelle [Votre nom] et je suis là pour m'assurer que vous puissiez tirer le maximum de votre période d'essai. Avez-vous quelques minutes pour que nous parlions de vos besoins spécifiques?" - -### Questions clés (5min) - -1. "Pouvez-vous me parler brièvement des défis que vous rencontrez actuellement avec la gestion de vos équipements?" -2. "Environ combien d'équipements souhaitez-vous suivre avec ForTooling?" -3. "Avez-vous déjà utilisé une solution similaire par le passé?" -4. "Qu'est-ce qui vous a incité à essayer ForTooling spécifiquement?" - -### Présentation personnalisée (5min) - -"D'après ce que vous me dites, je pense que ces fonctionnalités spécifiques pourraient vous être particulièrement utiles..." (adapter selon réponses) - -### Plan de démarrage (3min) - -"Voici ce que je vous propose comme plan pour ces 14 jours d'essai: - -1. Aujourd'hui/demain: Configuration initiale de votre compte -2. D'ici la fin de semaine: Étiquetage de vos premiers équipements (10-20) -3. Début semaine prochaine: Formation rapide de vos équipes (15min max) -4. Milieu de semaine prochaine: Premier bilan d'utilisation avec moi - Cela vous semble-t-il réalisable?" - -### Conclusion et prochaines étapes (1min) - -"Super! Je vais vous envoyer un récapitulatif par email. N'hésitez pas à me contacter directement à ce numéro si vous avez la moindre question. Notre objectif est que vous puissiez voir des résultats concrets avant la fin de votre période d'essai." - -## 5. Stratégie de Relance Fin d'Essai - -### Principes - -- Approche consultative plutôt que pression commerciale -- Focus sur valeur déjà obtenue pendant l'essai -- Offre spéciale avec délai limité - -### Timing des relances - -- J-3: Email préparatoire -- J-1: Relance téléphonique -- J+0: Email "dernier jour" -- J+1: Appel de récupération si non converti - -### Script d'appel J-1 - -"Bonjour [Prénom], c'est [Votre nom] de ForTooling. Je vous appelle car votre période d'essai se termine demain, et je voulais faire un point avec vous: - -1. Comment s'est passée votre expérience jusqu'à présent? -2. Avez-vous pu observer des améliorations dans la gestion de vos équipements? -3. Y a-t-il des questions ou préoccupations qui pourraient vous empêcher de continuer? - -Comme vous faites partie de nos premiers utilisateurs, nous avons une offre spéciale "Pionnier": -50% sur votre abonnement première année, ce qui ramène le coût à seulement [X]€ par mois. - -Souhaitez-vous bénéficier de cette offre pour continuer avec ForTooling?" - -## 6. Tactiques de Réduction des Abandons - -### Identifiez les signes d'alerte précoces - -- Non-connexion après 3 jours -- Moins de 5 équipements enregistrés -- Absence de scans après configuration - -### Actions préventives - -- Email personnalisé: "Besoin d'aide pour démarrer?" -- Appel proactif: "Puis-je vous aider avec la mise en place?" -- Offre d'extension: "Besoin de plus de temps? Essayez 7 jours supplémentaires" - -### Incitatifs de rétention - -- Débloquer fonctionnalité premium pendant l'essai -- Offrir configuration gratuite des 20 premiers équipements -- Proposer session de formation équipe offerte - -### Feedback sur les abandons - -- Sondage court et simple -- Appel de suivi non-commercial -- Offre de retour facilitée (données conservées 30 jours) diff --git a/docs-and-prompts/stack-technique.md b/docs-and-prompts/stack-technique.md deleted file mode 100644 index 1e82217..0000000 --- a/docs-and-prompts/stack-technique.md +++ /dev/null @@ -1,150 +0,0 @@ -# Stack Technique Finale - Plateforme SaaS de Gestion d'Équipements NFC/QR - -## 1. Vue d'ensemble - -Cette plateforme SaaS de gestion d'équipements avec tracking NFC/QR combine les technologies modernes du web pour offrir une solution robuste, performante et évolutive. L'architecture est conçue pour être hautement optimisée, sécurisée et facile à maintenir. - -## 2. Frontend - -### Framework & UI - -- **Next.js 15+** - Framework React avec App Router et Server Components -- **React 19+** - Bibliothèque UI pour construire des interfaces interactives -- **Tailwind CSS 4+** - Framework CSS utility-first pour le styling -- **shadcn/ui** - Composants UI réutilisables basés sur Radix UI -- **Lucide React** - Bibliothèque d'icônes SVG -- **Framer Motion** - Animations et transitions fluides -- **Rive** - Animations complexes et interactives - -### Gestion d'état client - -- **Zustand** - Gestion d'état global légère et simple - - Utilisé pour éviter le prop drilling - - Stockage des préférences utilisateur, thèmes, filtres - - État partagé entre composants distants - -### PWA & Mobile - -- **next-pwa** - Transforme l'application en Progressive Web App -- **WebNFC API** - Accès aux fonctionnalités NFC pour les appareils compatibles -- **QR Code fallback** - Solution alternative pour les appareils sans NFC - -### Qualité & Tests - -- **TypeScript** - Typage statique pour une meilleure qualité de code -- **ESLint/Prettier** - Linting et formatage de code -- **Vitest** - Tests unitaires rapides -- **Playwright** - Tests end-to-end - -## 3. Backend & API - -### API & Validation - -- **Next.js Server Actions** - Actions serveur typées et sécurisées - - Pattern de protection centralisé (HOF withProtection) - - Isolation multi-tenant intégrée -- **Zod** - Validation de schémas pour les données d'entrée -- **Tan stack Form** - Gestion de formulaires avec validation côté client - -### Backend - -- **Pockebase** - Backend as a service - -### Sécurité API - -- **Rate limiting** - Protection contre les abus -- **CORS** - Sécurité pour les requêtes cross-origin -- **Helmet** - Sécurisation des headers HTTP - -## 4. Services & Intégrations - -### Authentification & Paiements - -- **Clerk 6+** - Authentification complète et gestion des utilisateurs -- **Stripe** - Traitement des paiements et gestion des abonnements - -### Recherche & Stockage - -- **Algolia** - Recherche rapide et pertinente -- **Cloudflare R2** - Stockage d'objets compatible S3 - -### Communication & Notifications - -- **Resend** - Service d'emails transactionnels -- **Twilio** - SMS et notifications mobiles -- **Socket.io** - Communication temps réel pour le monitoring - -### Fonctionnalités spécifiques - -- **OpenStreetMap + Leaflet.js** - Cartographie et géolocalisation -- **React-PDF** - Génération de rapports PDF -- **SheetJS** - Export de données en format Excel -- **Temporal.io** - Orchestration de workflows et tâches asynchrones - -## 5. Infrastructure & DevOps - -### Déploiement & CI/CD - -- **Coolify** - Plateforme self-hosted pour le déploiement -- **Docker** - Conteneurisation des services -- **GitHub Actions** - Automatisation CI/CD - -### Monitoring & Observabilité - -- **Prometheus + Grafana** - Collecte et visualisation de métriques -- **Loki** - Agrégation et exploration de logs -- **Glitchtip** - Suivi des erreurs (compatible avec l'API Sentry) -- **Umami** - Analytics respectueux de la vie privée - -### Sauvegarde & Restauration - -- **pgbackrest** - Solution de backup robuste pour PostgreSQL -- **pg_dump automatisé** - Sauvegardes programmées - -## 6. Architecture multi-tenant - -- Architecture à schéma unique avec discrimination par tenant_id -- Isolation des données par organisation au niveau des Server Actions -- Middleware de protection centralisé pour les vérifications d'accès -- Optimisation des requêtes grâce aux index sur tenant_id - -## 7. Intégration NFC/QR - -- Approche hybride WebNFC + QR Code -- Points de scan fixes (entrées/sorties) -- Options pour scanners Bluetooth dans les zones de forte utilisation - -## 8. Optimisations & Performance - -- **SEO** - Optimisation pour la partie publique (landing) - - Screaming Frog pour l'audit - - Lighthouse pour les bonnes pratiques -- **Web Vitals** - Suivi continu des métriques de performance -- **Unlighthouse/IBM checker** - Outils d'analyse supplémentaires - -## 9. Documentation - -- **Swagger/OpenAPI** - Documentation d'API auto-générée -- **Docusaurus** - Documentation utilisateur et technique - -## 10. Structure du projet - -``` -src/ -├── app/ # Next.js App Router -│ ├── (auth)/ # Routes authentifiées -│ ├── (marketing)/ # Routes publiques (landing) -│ └── api/ # Routes API REST si nécessaire -├── components/ # Composants React partagés -│ ├── ui/ # Composants UI de base (shadcn) -│ └── [feature]/ # Composants spécifiques aux fonctionnalités -├── lib/ # Code utilitaire partagé -├── server/ # Code serveur -│ ├── actions/ # Next.js Server Actions protégées -│ │ └── middleware.ts # Wrapper de protection HOF -│ ├── db/ # Prisma et utilitaires DB -│ └── services/ # Logique métier -├── stores/ # Stores Zustand -├── styles/ # Styles globaux Tailwind -└── types/ # Types TypeScript partagés -``` diff --git a/docs-and-prompts/technique-prompt-system.md b/docs-and-prompts/technique-prompt-system.md deleted file mode 100644 index 5d5a321..0000000 --- a/docs-and-prompts/technique-prompt-system.md +++ /dev/null @@ -1,146 +0,0 @@ -# Prompt Système pour Assistant de Développement SaaS - Plateforme de Gestion d'Équipements NFC/QR - -## 🎯 Contexte du Projet - -Tu es un assistant de développement expert spécialisé dans la création d'une plateforme SaaS de gestion d'équipements avec tracking NFC/QR. Ce système permet aux entreprises de suivre, attribuer et maintenir leur parc d'équipements via une interface moderne et des fonctionnalités avancées de scanning et de reporting. - -## 📋 Directives Générales - -- **Langue**: Toujours coder et commenter en anglais -- **Style de collaboration**: Proactif et pédagogique, explique tes choix techniques -- **Format de réponse**: Structuré, avec des sections claires et une bonne utilisation du markdown -- **Erreurs**: Identifie de manière proactive les problèmes potentiels dans mon code -- **Standards**: Respecte les meilleures pratiques pour chaque technologie utilisée -- **Optimisations**: Suggère des améliorations de performance, sécurité et maintenabilité - -## 🏗️ Stack Technique à Respecter - -### Frontend - -- **Framework**: Next.js 15+, React 19+ -- **Styling**: Tailwind CSS 4+, shadcn/ui -- !! Attention, on va utiliser Tailwind v4, et pas les versions en dessous, on évitera les morceaux de code incompatible lié à Tailwindv3 -- **État**: Zustand pour la gestion d'état globale (éviter le prop drilling) -- **Forms**: Tan Stack Form + Zod pour la validation -- **Animations**: Framer Motion, Rive pour les animations complexes -- **UI**: Composants shadcn/ui, icônes Lucide React -- **Mobile**: next-pwa, WebNFC API, QR code fallback - -### Backend - -- **API**: Next.js Server Actions avec middleware de protection centralisé -- **Validation**: Zod pour la validation des données -- **ORM**: Prisma avec PostgreSQL -- **Authentification**: Clerk 6+ -- **Paiements**: Stripe -- **Recherche**: Algolia -- **Stockage**: Cloudflare R2 -- **Emails**: Resend -- **SMS**: Twilio -- **Temps réel**: Socket.io -- **Tâches asynchrones**: Temporal.io - -### DevOps & Sécurité - -- **Déploiement**: Coolify, Docker -- **CI/CD**: GitHub Actions -- **Monitoring**: Prometheus, Grafana, Loki, Glitchtip -- **Analytics**: Umami -- **Sécurité API**: Rate limiting, CORS, Helmet - -## 11. Schéma / visualisation - -Tout les schémas et assets pour les visualisations sont dans le dossier [dev-assets](../dev-assets/images ...) pour la partie dev , et pour les éléments visuels, ils se trouveront dans le dossier public/assets/ pour la partie prod. -Si il y a besoin de schémas, il faut les les créer avec [Mermaid](https://mermaid-js.github.io/) et suivre les bonnes pratiques de ce langage. - -## 🖋️ Conventions de Code & Documentation - -### Structuration du Code - -- Architecture modulaire et maintenable -- Séparation claire des préoccupations (SoC) -- DRY (Don't Repeat Yourself) et SOLID principles -- Pattern par fonctionnalité plutôt que par type technique -- Centralisation des vérifications de sécurité et d'autorisation - -### Style de Code - -- **TypeScript**: Types stricts et exhaustifs -- **React**: Composants fonctionnels avec hooks -- **Imports**: Groupés et ordonnés (1. React/Next, 2. Libs externes, 3. Components, 4. Utils) -- **Nommage**: camelCase pour variables/fonctions, PascalCase pour composants/types -- **État**: Préférer `useState`, `useReducer` localement, Zustand globalement - -### Documentation - -- **JSDoc** pour toutes les fonctions, hooks, et types complexes: - -```typescript -/** - * Fetches equipment data based on provided filters - * @param {EquipmentFilters} filters - The filters to apply to the query - * @param {QueryOptions} options - Optional query parameters - * @returns {Promise} Array of equipment matching filters - * @throws {ApiError} When the API request fails - */ -``` - -- **Commentaires de code**: Explique le "pourquoi", pas le "quoi" -- Ajoute des logs explicatifs aux endroits clés - -### Tests - -- Tests unitaires avec Vitest -- Tests end-to-end avec Playwright -- Privilégier les tests pour la logique métier critique - -## 📐 Structure de Projet Attendue - -``` -src/ -├── app/ # Next.js App Router -│ ├── (auth)/ # Routes authentifiées -│ ├── (marketing)/ # Routes publiques (landing) -│ └── api/ # Routes API REST si nécessaire -├── components/ # Composants React partagés -│ ├── ui/ # Composants UI de base (shadcn) -│ └── [feature]/ # Composants spécifiques aux fonctionnalités -├── lib/ # Code utilitaire partagé -├── server/ # Code serveur -│ ├── actions/ # Next.js Server Actions protégées -│ │ └── middleware.ts # Wrapper de protection HOF -│ ├── db/ # Prisma et utilitaires DB -│ └── services/ # Logique métier -├── stores/ # Stores Zustand -├── styles/ # Styles globaux Tailwind -└── types/ # Types TypeScript partagés -``` - -## 🤝 Collaboration Attendue - -- **Proactivité**: Anticipe les besoins et problèmes potentiels -- **Pédagogie**: Explique les concepts complexes et les choix d'architecture -- **Adaptabilité**: Ajuste-toi à mes besoins et préférences au fur et à mesure -- **Progressivité**: Commence par les fondamentaux puis avance vers des implémentations plus complexes -- **Optimisations**: Suggère des améliorations mais priorise la lisibilité et la maintenabilité - -## 🚨 Anti-patterns à Éviter - -- Ne pas utiliser de classes React (préférer les composants fonctionnels) -- Éviter les any/unknown en TypeScript si possible -- Ne pas réinventer ce qui existe déjà dans les bibliothèques choisies -- Éviter les dépendances inutiles ou redondantes -- Ne pas mélanger les styles (préférer Tailwind) -- Éviter d'exposer des données sensibles dans le frontend -- Ne pas dupliquer la logique d'authentification et de validation -- Éviter de créer des Server Actions sans utiliser le middleware de protection - -## 🔄 Processus de Travail - -1. Comprends d'abord mon besoin ou problème -2. Propose une approche structurée avec les technologies appropriées -3. Implémente en expliquant les choix techniques -4. Suggère des améliorations ou alternatives si pertinent -5. Offre des conseils pour les tests et la maintenance - -Utilise ces directives pour m'assister de manière précise et efficace dans le développement de cette plateforme SaaS de gestion d'équipements NFC/QR. diff --git a/env.example b/env.example new file mode 100644 index 0000000..daed409 --- /dev/null +++ b/env.example @@ -0,0 +1,12 @@ +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=-- +CLERK_SECRET_KEY=-- +NEXT_PUBLIC_CLERK_SIGN_IN_URL=--/sign-in +NEXT_PUBLIC_CLERK_SIGN_UP_URL=--/sign-up +NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=--/app + +PB_TOKEN_API_ADMIN=--- +PB_API_URL=--- + +CLERK_WEBHOOK_SECRET_ORGANIZATION=-- +CLERK_WEBHOOK_SECRET_USER=-- +CLERK_WEBHOOK_SECRET_ORGANIZATION_MEMBERSHIP=-- \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d37344f..63a2de7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,54 +8,68 @@ "name": "for-tooling", "version": "0.1.0", "dependencies": { - "@clerk/nextjs": "^6.12.6", - "@eslint/js": "^9.22.0", - "@headlessui/react": "^2.2.0", - "@heroicons/react": "^2.2.0", - "@radix-ui/react-avatar": "^1.1.3", - "@radix-ui/react-dialog": "^1.1.6", - "@radix-ui/react-separator": "^1.1.2", + "@clerk/nextjs": "6.13.0", + "@eslint/js": "9.23.0", + "@headlessui/react": "2.2.0", + "@heroicons/react": "2.2.0", + "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-avatar": "1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-icons": "1.3.2", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "1.1.2", "@radix-ui/react-slot": "^1.1.2", - "@radix-ui/react-tooltip": "^1.1.8", - "autoprefixer": "10.4.20", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "dayjs": "^1.11.13", - "framer-motion": "12.4.10", - "heroicons": "^2.2.0", - "lucide-react": "^0.483.0", - "next": "15.2.2", - "postcss": "^8.5.3", - "react": "19.0.0", - "react-dom": "19.0.0", - "react-use-measure": "^2.1.7", - "tailwind-merge": "^3.0.2", - "tw-animate-css": "^1.2.4", + "@radix-ui/react-tooltip": "1.1.8", + "@tanstack/react-table": "^8.21.2", + "@types/canvas-confetti": "1.9.0", + "autoprefixer": "10.4.21", + "canvas-confetti": "1.9.3", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "crypto": "1.0.1", + "date-fns": "^4.1.0", + "dayjs": "1.11.13", + "framer-motion": "12.6.3", + "heroicons": "2.2.0", + "lucide-react": "0.487.0", + "next": "15.2.4", + "pocketbase": "0.25.2", + "postcss": "8.5.3", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-use-measure": "2.1.7", + "svix": "1.63.1", + "tailwind-merge": "3.1.0", + "tw-animate-css": "1.2.5", "zod": "3.24.2", "zustand": "5.0.3" }, "devDependencies": { - "@eslint/eslintrc": "^3.3.0", - "@next/eslint-plugin-next": "15.2.1", - "@tailwindcss/postcss": "^4.0.14", - "@types/node": "22.13.10", - "@types/react": "19.0.10", - "@types/react-dom": "19.0.4", - "@typescript-eslint/eslint-plugin": "8.26.0", - "@typescript-eslint/parser": "8.26.0", - "eslint": "^9.22.0", - "eslint-config-next": "15.2.1", + "@eslint/eslintrc": "3.3.1", + "@next/eslint-plugin-next": "15.2.4", + "@tailwindcss/postcss": "4.1.2", + "@types/node": "22.14.0", + "@types/react": "19.1.0", + "@types/react-dom": "19.1.1", + "@typescript-eslint/eslint-plugin": "8.29.0", + "@typescript-eslint/parser": "8.29.0", + "eslint": "9.23.0", + "eslint-config-next": "15.2.4", "eslint-config-prettier": "10.1.1", - "eslint-plugin-perfectionist": "4.10.0", - "eslint-plugin-prettier": "5.2.3", - "eslint-plugin-react": "7.37.4", + "eslint-plugin-perfectionist": "4.11.0", + "eslint-plugin-prettier": "5.2.6", + "eslint-plugin-react": "7.37.5", "husky": "9.1.7", "prettier": "3.5.3", "prettier-plugin-tailwindcss": "0.6.11", - "tailwindcss": "^4.0.14", - "tailwindcss-animate": "^1.0.7", + "tailwindcss": "4.1.2", + "tailwindcss-animate": "1.0.7", "typescript": "5.8.2", - "typescript-eslint": "8.26.1" + "typescript-eslint": "8.29.0" } }, "node_modules/@alloc/quick-lru": { @@ -72,29 +86,37 @@ } }, "node_modules/@clerk/backend": { - "version": "1.25.4", - "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-1.25.4.tgz", - "integrity": "sha512-rgtijAqovktwLDnuO0rP5Iln0qJKGkm5yNWFaVIGZescssvBG9VUvbTYt/TvyyzqNsAWyyT2WmnAP24WTwqBTQ==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-1.26.0.tgz", + "integrity": "sha512-ioZBMnwm4DD8IVPGDjFW3wkyn1JTMvTlsmdHGYsjdbXLtbRFVRJelAIMMGLcSmqMgzTKxnrJSOz8PxPjSMUFtw==", "license": "MIT", "dependencies": { - "@clerk/shared": "^3.1.0", - "@clerk/types": "^4.49.1", + "@clerk/shared": "^3.3.0", + "@clerk/types": "^4.50.2", "cookie": "1.0.2", "snakecase-keys": "8.0.1", "tslib": "2.8.1" }, "engines": { "node": ">=18.17.0" + }, + "peerDependencies": { + "svix": "^1.62.0" + }, + "peerDependenciesMeta": { + "svix": { + "optional": true + } } }, "node_modules/@clerk/clerk-react": { - "version": "5.25.1", - "resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.25.1.tgz", - "integrity": "sha512-tyfmCXjmGPhmoZaszf0072waJsr4rWlrxYbWkP9nxwrPGkMk6bHR/xI6EyDi5lQGCwu2ICvM+zKo4ZvL43DXmA==", + "version": "5.25.6", + "resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.25.6.tgz", + "integrity": "sha512-QXISFiW4xI96nIE8MEfqpy+ISjtfYa2wWYeS8Nyo+K34dK1aNpawpTopRKRirqUy2QRSF/yXaCY9IF/v22XlJg==", "license": "MIT", "dependencies": { - "@clerk/shared": "^3.1.0", - "@clerk/types": "^4.49.1", + "@clerk/shared": "^3.3.0", + "@clerk/types": "^4.50.2", "tslib": "2.8.1" }, "engines": { @@ -106,15 +128,15 @@ } }, "node_modules/@clerk/nextjs": { - "version": "6.12.7", - "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-6.12.7.tgz", - "integrity": "sha512-r5/V2t3kqPSGhPRsOUQTO19qDnX7q/2ITJYE4R10ifaXjsiVvwr1+UncXT9hKVFAc7W4wCWOV/7LDphnIEt4jQ==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-6.13.0.tgz", + "integrity": "sha512-xikvFU8JWBtbgh3pe76yWrlOQzIACdUNsinHP06qeRJIIg8yci8sYa93ASjd0TNPzj9cInF+owMj6mDQw7HZ5Q==", "license": "MIT", "dependencies": { - "@clerk/backend": "^1.25.4", - "@clerk/clerk-react": "^5.25.1", - "@clerk/shared": "^3.1.0", - "@clerk/types": "^4.49.1", + "@clerk/backend": "^1.26.0", + "@clerk/clerk-react": "^5.25.6", + "@clerk/shared": "^3.3.0", + "@clerk/types": "^4.50.2", "server-only": "0.0.1", "tslib": "2.8.1" }, @@ -122,24 +144,24 @@ "node": ">=18.17.0" }, "peerDependencies": { - "next": "^13.5.4 || ^14.0.3 || ^15.0.0", + "next": "^13.5.7 || ^14.2.25 || ^15.2.3", "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" } }, "node_modules/@clerk/shared": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.1.0.tgz", - "integrity": "sha512-cCQhmu4yXl/qqY84p+q8szm8rwdMVVxPDoqfLggU9+UefsLEa8rD3lbD1MSD8Yrou8L7jsvx9zmSGw3gBSVXyw==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.3.0.tgz", + "integrity": "sha512-hO1M5aRMzJVqkw6lWJ7NFVG5hWEnTZBUZGeHMYRwSPQzQNsgqsRMvpmaJO0Y2o0HNk50PpwZHaiFHcghUpfMiw==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@clerk/types": "^4.49.1", + "@clerk/types": "^4.50.2", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.5", - "std-env": "^3.7.0", - "swr": "^2.2.0" + "std-env": "^3.8.1", + "swr": "^2.3.3" }, "engines": { "node": ">=18.17.0" @@ -158,9 +180,9 @@ } }, "node_modules/@clerk/types": { - "version": "4.49.1", - "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.49.1.tgz", - "integrity": "sha512-eVxDDvf4D36lFp5fWek6P+bTeZa4c4KAAlo3sE7Ga2lIsnhot9p+p+ugqeP/Y5EgOmj3+uy1nwvpcgZ4oV93PA==", + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.50.2.tgz", + "integrity": "sha512-4m1RlV/Dl3ZGW5FAXmKfdCbhF7uTDDvaADZH1F6L3d3lRBdI6i7GppK1KqscOSgoC8OwJqGaiDVUPsg+Pp8usg==", "license": "MIT", "dependencies": { "csstype": "3.1.3" @@ -170,9 +192,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", - "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.0.tgz", + "integrity": "sha512-64WYIf4UYcdLnbKn/umDlNjQDSS8AgZrI/R9+x5ilkUVFxXcA1Ebl+gQLc/6mERA4407Xof0R7wEyEuj091CVw==", "license": "MIT", "optional": true, "dependencies": { @@ -237,9 +259,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.1.0.tgz", - "integrity": "sha512-kLrdPDJE1ckPo94kmPPf9Hfd0DU0Jw6oKYrhe+pwSC0iTUInmTa+w6fw8sGgcfkFJGNdWOUeOaDM4quW4a7OkA==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.0.tgz", + "integrity": "sha512-yJLLmLexii32mGrhW29qvU3QBVTu0GUmEf/J4XsBtVhp4JkIUFN/BjWqTF63yRvGApIDpZm5fa97LtYtINmfeQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -260,9 +282,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz", - "integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, "license": "MIT", "dependencies": { @@ -284,9 +306,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.22.0.tgz", - "integrity": "sha512-vLFajx9o8d1/oL2ZkpMYbkLv8nDB6yaIwFNt7nI4+I80U/z03SxmfOMsLbvWr3p7C+Wnoh//aOu2pQW8cS0HCQ==", + "version": "9.23.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.23.0.tgz", + "integrity": "sha512-35MJ8vCPU0ZMxo7zfev2pypqTwWTofFZO6m4KAtdoFhRpLJUpHTZZ+KB3C7Hb1d7bULYwO4lJXGCi5Se+8OMbw==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -825,15 +847,15 @@ } }, "node_modules/@next/env": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.2.tgz", - "integrity": "sha512-yWgopCfA9XDR8ZH3taB5nRKtKJ1Q5fYsTOuYkzIIoS8TJ0UAUKAGF73JnGszbjk2ufAQDj6mDdgsJAFx5CLtYQ==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz", + "integrity": "sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "15.2.1", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.2.1.tgz", - "integrity": "sha512-6ppeToFd02z38SllzWxayLxjjNfzvc7Wm07gQOKSLjyASvKcXjNStZrLXMHuaWkhjqxe+cnhb2uzfWXm1VEj/Q==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.2.4.tgz", + "integrity": "sha512-O8ScvKtnxkp8kL9TpJTTKnMqlkZnS+QxwoQnJwPGBxjBbzd6OVVPEJ5/pMNrktSyXQD/chEfzfFzYLM6JANOOQ==", "dev": true, "license": "MIT", "dependencies": { @@ -841,9 +863,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.2.tgz", - "integrity": "sha512-HNBRnz+bkZ+KfyOExpUxTMR0Ow8nkkcE6IlsdEa9W/rI7gefud19+Sn1xYKwB9pdCdxIP1lPru/ZfjfA+iT8pw==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz", + "integrity": "sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==", "cpu": [ "arm64" ], @@ -857,9 +879,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.2.tgz", - "integrity": "sha512-mJOUwp7al63tDpLpEFpKwwg5jwvtL1lhRW2fI1Aog0nYCPAhxbJsaZKdoVyPZCy8MYf/iQVNDuk/+i29iLCzIA==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.4.tgz", + "integrity": "sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==", "cpu": [ "x64" ], @@ -873,9 +895,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.2.tgz", - "integrity": "sha512-5ZZ0Zwy3SgMr7MfWtRE7cQWVssfOvxYfD9O7XHM7KM4nrf5EOeqwq67ZXDgo86LVmffgsu5tPO57EeFKRnrfSQ==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.4.tgz", + "integrity": "sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==", "cpu": [ "arm64" ], @@ -889,9 +911,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.2.tgz", - "integrity": "sha512-cgKWBuFMLlJ4TWcFHl1KOaVVUAF8vy4qEvX5KsNd0Yj5mhu989QFCq1WjuaEbv/tO1ZpsQI6h/0YR8bLwEi+nA==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.4.tgz", + "integrity": "sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==", "cpu": [ "arm64" ], @@ -905,9 +927,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.2.tgz", - "integrity": "sha512-c3kWSOSsVL8rcNBBfOq1+/j2PKs2nsMwJUV4icUxRgGBwUOfppeh7YhN5s79enBQFU+8xRgVatFkhHU1QW7yUA==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.4.tgz", + "integrity": "sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==", "cpu": [ "x64" ], @@ -921,9 +943,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.2.tgz", - "integrity": "sha512-PXTW9PLTxdNlVYgPJ0equojcq1kNu5NtwcNjRjHAB+/sdoKZ+X8FBu70fdJFadkxFIGekQTyRvPMFF+SOJaQjw==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.4.tgz", + "integrity": "sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==", "cpu": [ "x64" ], @@ -937,9 +959,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.2.tgz", - "integrity": "sha512-nG644Es5llSGEcTaXhnGWR/aThM/hIaz0jx4MDg4gWC8GfTCp8eDBWZ77CVuv2ha/uL9Ce+nPTfYkSLG67/sHg==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.4.tgz", + "integrity": "sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==", "cpu": [ "arm64" ], @@ -953,9 +975,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.2.tgz", - "integrity": "sha512-52nWy65S/R6/kejz3jpvHAjZDPKIbEQu4x9jDBzmB9jJfuOy5rspjKu4u77+fI4M/WzLXrrQd57hlFGzz1ubcQ==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz", + "integrity": "sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==", "cpu": [ "x64" ], @@ -1017,9 +1039,9 @@ } }, "node_modules/@pkgr/core": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.2.tgz", - "integrity": "sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.0.tgz", + "integrity": "sha512-vsJDAkYR6qCPu+ioGScGiMYR7LvZYIXh/dlQeviqoTWNCVfKTLYD/LkNWH4Mxsv2a5vpIRc77FN5DnmK1eBggQ==", "dev": true, "license": "MIT", "engines": { @@ -1029,12 +1051,46 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", + "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", "license": "MIT" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz", + "integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz", @@ -1084,6 +1140,62 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.4.tgz", + "integrity": "sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", + "integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", @@ -1150,6 +1262,21 @@ } } }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dismissable-layer": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz", @@ -1177,6 +1304,35 @@ } } }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.6.tgz", + "integrity": "sha512-no3X7V5fD487wab/ZYSHXq3H37u4NVeLDKI/Ks724X/eEFSSEFYZxWgsIlr1UBeEyDaM29HM5x9p1Nv8DuTYPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-menu": "2.1.6", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", @@ -1217,6 +1373,15 @@ } } }, + "node_modules/@radix-ui/react-icons": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", + "license": "MIT", + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", @@ -1235,6 +1400,106 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.2.tgz", + "integrity": "sha512-zo1uGMTaNlHehDyFQcDZXRJhUPDuukcnHz0/jnrup0JA6qL+AFpAnty+7VKa9esuU5xTblAZzTGYJKSKaBxBhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.6.tgz", + "integrity": "sha512-tBBb5CXDJW3t2mo9WlO7r6GTmWV0F0uzHZVFmlRmYpiSK1CDU5IKojP1pm7oknpBOrFZx/YgBRW9oorPO2S/Lg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz", + "integrity": "sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.2.tgz", @@ -1338,6 +1603,80 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz", + "integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.6.tgz", + "integrity": "sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-focus-guards": "1.1.1", + "@radix-ui/react-focus-scope": "1.1.2", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.2.tgz", @@ -1479,6 +1818,21 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", + "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", @@ -1655,6 +2009,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -1671,44 +2031,45 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.15.tgz", - "integrity": "sha512-IODaJjNmiasfZX3IoS+4Em3iu0fD2HS0/tgrnkYfW4hyUor01Smnr5eY3jc4rRgaTDrJlDmBTHbFO0ETTDaxWA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.2.tgz", + "integrity": "sha512-ZwFnxH+1z8Ehh8bNTMX3YFrYdzAv7JLY5X5X7XSFY+G9QGJVce/P9xb2mh+j5hKt8NceuHmdtllJvAHWKtsNrQ==", "dev": true, "license": "MIT", "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", - "tailwindcss": "4.0.15" + "lightningcss": "1.29.2", + "tailwindcss": "4.1.2" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.15.tgz", - "integrity": "sha512-e0uHrKfPu7JJGMfjwVNyt5M0u+OP8kUmhACwIRlM+JNBuReDVQ63yAD1NWe5DwJtdaHjugNBil76j+ks3zlk6g==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.2.tgz", + "integrity": "sha512-Zwz//1QKo6+KqnCKMT7lA4bspGfwEgcPAHlSthmahtgrpKDfwRGk8PKQrW8Zg/ofCDIlg6EtjSTKSxxSufC+CQ==", "dev": true, "license": "MIT", "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.0.15", - "@tailwindcss/oxide-darwin-arm64": "4.0.15", - "@tailwindcss/oxide-darwin-x64": "4.0.15", - "@tailwindcss/oxide-freebsd-x64": "4.0.15", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.15", - "@tailwindcss/oxide-linux-arm64-gnu": "4.0.15", - "@tailwindcss/oxide-linux-arm64-musl": "4.0.15", - "@tailwindcss/oxide-linux-x64-gnu": "4.0.15", - "@tailwindcss/oxide-linux-x64-musl": "4.0.15", - "@tailwindcss/oxide-win32-arm64-msvc": "4.0.15", - "@tailwindcss/oxide-win32-x64-msvc": "4.0.15" + "@tailwindcss/oxide-android-arm64": "4.1.2", + "@tailwindcss/oxide-darwin-arm64": "4.1.2", + "@tailwindcss/oxide-darwin-x64": "4.1.2", + "@tailwindcss/oxide-freebsd-x64": "4.1.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.2", + "@tailwindcss/oxide-linux-x64-musl": "4.1.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.2" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.15.tgz", - "integrity": "sha512-EBuyfSKkom7N+CB3A+7c0m4+qzKuiN0WCvzPvj5ZoRu4NlQadg/mthc1tl5k9b5ffRGsbDvP4k21azU4VwVk3Q==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.2.tgz", + "integrity": "sha512-IxkXbntHX8lwGmwURUj4xTr6nezHhLYqeiJeqa179eihGv99pRlKV1W69WByPJDQgSf4qfmwx904H6MkQqTA8w==", "cpu": [ "arm64" ], @@ -1723,9 +2084,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.15.tgz", - "integrity": "sha512-ObVAnEpLepMhV9VoO0JSit66jiN5C4YCqW3TflsE9boo2Z7FIjV80RFbgeL2opBhtxbaNEDa6D0/hq/EP03kgQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.2.tgz", + "integrity": "sha512-ZRtiHSnFYHb4jHKIdzxlFm6EDfijTCOT4qwUhJ3GWxfDoW2yT3z/y8xg0nE7e72unsmSj6dtfZ9Y5r75FIrlpA==", "cpu": [ "arm64" ], @@ -1740,9 +2101,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.15.tgz", - "integrity": "sha512-IElwoFhUinOr9MyKmGTPNi1Rwdh68JReFgYWibPWTGuevkHkLWKEflZc2jtI5lWZ5U9JjUnUfnY43I4fEXrc4g==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.2.tgz", + "integrity": "sha512-BiKUNZf1A0pBNzndBvnPnBxonCY49mgbOsPfILhcCE5RM7pQlRoOgN7QnwNhY284bDbfQSEOWnFR0zbPo6IDTw==", "cpu": [ "x64" ], @@ -1757,9 +2118,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.15.tgz", - "integrity": "sha512-6BLLqyx7SIYRBOnTZ8wgfXANLJV5TQd3PevRJZp0vn42eO58A2LykRKdvL1qyPfdpmEVtF+uVOEZ4QTMqDRAWA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.2.tgz", + "integrity": "sha512-Z30VcpUfRGkiddj4l5NRCpzbSGjhmmklVoqkVQdkEC0MOelpY+fJrVhzSaXHmWrmSvnX8yiaEqAbdDScjVujYQ==", "cpu": [ "x64" ], @@ -1774,9 +2135,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.15.tgz", - "integrity": "sha512-Zy63EVqO9241Pfg6G0IlRIWyY5vNcWrL5dd2WAKVJZRQVeolXEf1KfjkyeAAlErDj72cnyXObEZjMoPEKHpdNw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.2.tgz", + "integrity": "sha512-w3wsK1ChOLeQ3gFOiwabtWU5e8fY3P1Ss8jR3IFIn/V0va3ir//hZ8AwURveS4oK1Pu6b8i+yxesT4qWnLVUow==", "cpu": [ "arm" ], @@ -1791,9 +2152,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.15.tgz", - "integrity": "sha512-2NemGQeaTbtIp1Z2wyerbVEJZTkAWhMDOhhR5z/zJ75yMNf8yLnE+sAlyf6yGDNr+1RqvWrRhhCFt7i0CIxe4Q==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.2.tgz", + "integrity": "sha512-oY/u+xJHpndTj7B5XwtmXGk8mQ1KALMfhjWMMpE8pdVAznjJsF5KkCceJ4Fmn5lS1nHMCwZum5M3/KzdmwDMdw==", "cpu": [ "arm64" ], @@ -1808,9 +2169,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.15.tgz", - "integrity": "sha512-342GVnhH/6PkVgKtEzvNVuQ4D+Q7B7qplvuH20Cfz9qEtydG6IQczTZ5IT4JPlh931MG1NUCVxg+CIorr1WJyw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.2.tgz", + "integrity": "sha512-k7G6vcRK/D+JOWqnKzKN/yQq1q4dCkI49fMoLcfs2pVcaUAXEqCP9NmA8Jv+XahBv5DtDjSAY3HJbjosEdKczg==", "cpu": [ "arm64" ], @@ -1825,9 +2186,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.15.tgz", - "integrity": "sha512-g76GxlKH124RuGqacCEFc2nbzRl7bBrlC8qDQMiUABkiifDRHOIUjgKbLNG4RuR9hQAD/MKsqZ7A8L08zsoBrw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.2.tgz", + "integrity": "sha512-fLL+c678TkYKgkDLLNxSjPPK/SzTec7q/E5pTwvpTqrth867dftV4ezRyhPM5PaiCqX651Y8Yk0wRQMcWUGnmQ==", "cpu": [ "x64" ], @@ -1842,9 +2203,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.15.tgz", - "integrity": "sha512-Gg/Y1XrKEvKpq6WeNt2h8rMIKOBj/W3mNa5NMvkQgMC7iO0+UNLrYmt6zgZufht66HozNpn+tJMbbkZ5a3LczA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.2.tgz", + "integrity": "sha512-0tU1Vjd1WucZ2ooq6y4nI9xyTSaH2g338bhrqk+2yzkMHskBm+pMsOCfY7nEIvALkA1PKPOycR4YVdlV7Czo+A==", "cpu": [ "x64" ], @@ -1859,9 +2220,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.15.tgz", - "integrity": "sha512-7QtSSJwYZ7ZK1phVgcNZpuf7c7gaCj8Wb0xjliligT5qCGCp79OV2n3SJummVZdw4fbTNKUOYMO7m1GinppZyA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.2.tgz", + "integrity": "sha512-r8QaMo3QKiHqUcn+vXYCypCEha+R0sfYxmaZSgZshx9NfkY+CHz91aS2xwNV/E4dmUDkTPUag7sSdiCHPzFVTg==", "cpu": [ "arm64" ], @@ -1876,9 +2237,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.15.tgz", - "integrity": "sha512-JQ5H+5MLhOjpgNp6KomouE0ZuKmk3hO5h7/ClMNAQ8gZI2zkli3IH8ZqLbd2DVfXDbdxN2xvooIEeIlkIoSCqw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.2.tgz", + "integrity": "sha512-lYCdkPxh9JRHXoBsPE8Pu/mppUsC2xihYArNAESub41PKhHTnvn6++5RpmFM+GLSt3ewyS8fwCVvht7ulWm6cw==", "cpu": [ "x64" ], @@ -1893,18 +2254,37 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.0.15.tgz", - "integrity": "sha512-qyrpoDKIO7wzkRbKCvGLo7gXRjT9/Njf7ZJiJhG4njrfZkvOhjwnaHpYbpxYeDysEg+9pB1R4jcd+vQ7ZUDsmQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.2.tgz", + "integrity": "sha512-vgkMo6QRhG6uv97im6Y4ExDdq71y9v2IGZc+0wn7lauQFYJM/1KdUVhrOkexbUso8tUsMOWALxyHVkQEbsM7gw==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.0.15", - "@tailwindcss/oxide": "4.0.15", - "lightningcss": "1.29.2", + "@tailwindcss/node": "4.1.2", + "@tailwindcss/oxide": "4.1.2", "postcss": "^8.4.41", - "tailwindcss": "4.0.15" + "tailwindcss": "4.1.2" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.2.tgz", + "integrity": "sha512-11tNlEDTdIhMJba2RBH+ecJ9l1zgS2kjmexDPAraulc8jeNA4xocSNeyzextT0XJyASil4XsCYlJmf5jEWAtYg==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" } }, "node_modules/@tanstack/react-virtual": { @@ -1924,6 +2304,19 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/table-core": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz", + "integrity": "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-core": { "version": "3.13.4", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.4.tgz", @@ -1934,6 +2327,12 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -1956,19 +2355,18 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.13.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", - "integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==", - "dev": true, + "version": "22.14.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", + "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/react": { - "version": "19.0.10", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", - "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz", + "integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1976,9 +2374,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.0.4", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz", - "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==", + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w==", "devOptional": true, "license": "MIT", "peerDependencies": { @@ -1986,17 +2384,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.0.tgz", - "integrity": "sha512-cLr1J6pe56zjKYajK6SSSre6nl1Gj6xDp1TY0trpgPzjVbgDwd09v2Ws37LABxzkicmUjhEeg/fAUjPJJB1v5Q==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.0.tgz", + "integrity": "sha512-PAIpk/U7NIS6H7TEtN45SPGLQaHNgB7wSjsQV/8+KYokAb2T/gloOA/Bee2yd4/yKVhPKe5LlaUGhAZk5zmSaQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/type-utils": "8.26.0", - "@typescript-eslint/utils": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", + "@typescript-eslint/scope-manager": "8.29.0", + "@typescript-eslint/type-utils": "8.29.0", + "@typescript-eslint/utils": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -2016,16 +2414,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.26.0.tgz", - "integrity": "sha512-mNtXP9LTVBy14ZF3o7JG69gRPBK/2QWtQd0j0oH26HcY/foyJJau6pNUez7QrM5UHnSvwlQcJXKsk0I99B9pOA==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.29.0.tgz", + "integrity": "sha512-8C0+jlNJOwQso2GapCVWWfW/rzaq7Lbme+vGUFKE31djwNncIpgXD7Cd4weEsDdkoZDjH0lwwr3QDQFuyrMg9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/typescript-estree": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", + "@typescript-eslint/scope-manager": "8.29.0", + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/typescript-estree": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0", "debug": "^4.3.4" }, "engines": { @@ -2041,14 +2439,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.26.0.tgz", - "integrity": "sha512-E0ntLvsfPqnPwng8b8y4OGuzh/iIOm2z8U3S9zic2TeMLW61u5IH2Q1wu0oSTkfrSzwbDJIB/Lm8O3//8BWMPA==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.29.0.tgz", + "integrity": "sha512-aO1PVsq7Gm+tcghabUpzEnVSFMCU4/nYIgC2GOatJcllvWfnhrgW0ZEbnTxm36QsikmCN1K/6ZgM7fok2I7xNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0" + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2059,14 +2457,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.26.0.tgz", - "integrity": "sha512-ruk0RNChLKz3zKGn2LwXuVoeBcUMh+jaqzN461uMMdxy5H9epZqIBtYj7UiPXRuOpaALXGbmRuZQhmwHhaS04Q==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.29.0.tgz", + "integrity": "sha512-ahaWQ42JAOx+NKEf5++WC/ua17q5l+j1GFrbbpVKzFL/tKVc0aYY8rVSYUpUvt2hUP1YBr7mwXzx+E/DfUWI9Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.26.0", - "@typescript-eslint/utils": "8.26.0", + "@typescript-eslint/typescript-estree": "8.29.0", + "@typescript-eslint/utils": "8.29.0", "debug": "^4.3.4", "ts-api-utils": "^2.0.1" }, @@ -2083,9 +2481,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.26.0.tgz", - "integrity": "sha512-89B1eP3tnpr9A8L6PZlSjBvnJhWXtYfZhECqlBl1D9Lme9mHO6iWlsprBtVenQvY1HMhax1mWOjhtL3fh/u+pA==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.29.0.tgz", + "integrity": "sha512-wcJL/+cOXV+RE3gjCyl/V2G877+2faqvlgtso/ZRbTCnZazh0gXhe+7gbAnfubzN2bNsBtZjDvlh7ero8uIbzg==", "dev": true, "license": "MIT", "engines": { @@ -2097,14 +2495,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.0.tgz", - "integrity": "sha512-tiJ1Hvy/V/oMVRTbEOIeemA2XoylimlDQ03CgPPNaHYZbpsc78Hmngnt+WXZfJX1pjQ711V7g0H7cSJThGYfPQ==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.29.0.tgz", + "integrity": "sha512-yOfen3jE9ISZR/hHpU/bmNvTtBW1NjRbkSFdZOksL1N+ybPEE7UVGMwqvS6CP022Rp00Sb0tdiIkhSCe6NI8ow==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/visitor-keys": "8.26.0", + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/visitor-keys": "8.29.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2180,16 +2578,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.26.0.tgz", - "integrity": "sha512-2L2tU3FVwhvU14LndnQCA2frYC8JnPDVKyQtWFPf8IYFMt/ykEN1bPolNhNbCVgOmdzTlWdusCTKA/9nKrf8Ig==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.29.0.tgz", + "integrity": "sha512-gX/A0Mz9Bskm8avSWFcK0gP7cZpbY4AIo6B0hWYFCaIsz750oaiWR4Jr2CI+PQhfW1CpcQr9OlfPS+kMFegjXA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.26.0", - "@typescript-eslint/types": "8.26.0", - "@typescript-eslint/typescript-estree": "8.26.0" + "@typescript-eslint/scope-manager": "8.29.0", + "@typescript-eslint/types": "8.29.0", + "@typescript-eslint/typescript-estree": "8.29.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2204,13 +2602,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.0.tgz", - "integrity": "sha512-2z8JQJWAzPdDd51dRQ/oqIJxe99/hoLIqmf8RMCAJQtYDc535W/Jt2+RTP4bP0aKeBG1F65yjIZuczOXCmbWwg==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.29.0.tgz", + "integrity": "sha512-Sne/pVz8ryR03NFK21VpN88dZ2FdQXOlq3VIklbrTYEt8yXtRFr9tvUhqvCeKjqYk5FSim37sHbooT6vzBTZcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.26.0", + "@typescript-eslint/types": "8.29.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -2481,9 +2879,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", - "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", "funding": [ { "type": "opencollective", @@ -2500,11 +2898,11 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.23.3", - "caniuse-lite": "^1.0.30001646", + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", - "picocolors": "^1.0.1", + "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "bin": { @@ -2707,6 +3105,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-confetti": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.3.tgz", + "integrity": "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2827,6 +3235,13 @@ "node": ">= 8" } }, + "node_modules/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", + "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.", + "license": "ISC" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -2894,6 +3309,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/dayjs": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", @@ -3225,6 +3650,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3248,19 +3679,19 @@ } }, "node_modules/eslint": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.22.0.tgz", - "integrity": "sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==", + "version": "9.23.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.23.0.tgz", + "integrity": "sha512-jV7AbNoFPAY1EkFYpLq5bslU9NLNO8xnEeQXwErNibVryjk67wHVmddTBilc5srIttJDBrB0eMHKZBFbSIABCw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.19.2", - "@eslint/config-helpers": "^0.1.0", + "@eslint/config-helpers": "^0.2.0", "@eslint/core": "^0.12.0", - "@eslint/eslintrc": "^3.3.0", - "@eslint/js": "9.22.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.23.0", "@eslint/plugin-kit": "^0.2.7", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3309,13 +3740,13 @@ } }, "node_modules/eslint-config-next": { - "version": "15.2.1", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.2.1.tgz", - "integrity": "sha512-mhsprz7l0no8X+PdDnVHF4dZKu9YBJp2Rf6ztWbXBLJ4h6gxmW//owbbGJMBVUU+PibGJDAqZhW4pt8SC8HSow==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.2.4.tgz", + "integrity": "sha512-v4gYjd4eYIme8qzaJItpR5MMBXJ0/YV07u7eb50kEnlEmX7yhOjdUdzz70v4fiINYRjLf8X8TbogF0k7wlz6sA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "15.2.1", + "@next/eslint-plugin-next": "15.2.4", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", @@ -3519,14 +3950,14 @@ } }, "node_modules/eslint-plugin-perfectionist": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-4.10.0.tgz", - "integrity": "sha512-7sH4rXjIS6ekf/9YL25099Ja09aTqKM00VmN0de/JicSFU5h0GmkjpYuqm1stti0L/baDos7jcTbxt28o1pkJw==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-4.11.0.tgz", + "integrity": "sha512-5s+ehXydnLPQpLDj5mJ0CnYj2fQe6v6gKA3tS+FZVBLzwMOh8skH+l+1Gni08rG0SdEcNhJyjQp/mEkDYK8czw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "^8.26.0", - "@typescript-eslint/utils": "^8.26.0", + "@typescript-eslint/types": "^8.29.0", + "@typescript-eslint/utils": "^8.29.0", "natural-orderby": "^5.0.0" }, "engines": { @@ -3537,14 +3968,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.3.tgz", - "integrity": "sha512-qJ+y0FfCp/mQYQ/vWQ3s7eUlFEL4PyKfAJxsnYTJ4YT73nsJBWqmEpFryxV9OeUiqmsTsYJ5Y+KDNaeP31wrRw==", + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz", + "integrity": "sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==", "dev": true, "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.9.1" + "synckit": "^0.11.0" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -3555,7 +3986,7 @@ "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", - "eslint-config-prettier": "*", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "peerDependenciesMeta": { @@ -3568,9 +3999,9 @@ } }, "node_modules/eslint-plugin-react": { - "version": "7.37.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.4.tgz", - "integrity": "sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ==", + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", "dependencies": { @@ -3584,7 +4015,7 @@ "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", - "object.entries": "^1.1.8", + "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", @@ -3793,6 +4224,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -3897,13 +4334,13 @@ } }, "node_modules/framer-motion": { - "version": "12.4.10", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.4.10.tgz", - "integrity": "sha512-3Msuyjcr1Pb5hjkn4EJcRe1HumaveP0Gbv4DBMKTPKcV/1GSMkQXj+Uqgneys+9DPcZM18Hac9qY9iUEF5LZtg==", + "version": "12.6.3", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.6.3.tgz", + "integrity": "sha512-2hsqknz23aloK85bzMc9nSR2/JP+fValQ459ZTVElFQ0xgwR2YqNjYSuDZdFBPOwVCt4Q9jgyTt6hg6sVOALzw==", "license": "MIT", "dependencies": { - "motion-dom": "^12.4.10", - "motion-utils": "^12.4.10", + "motion-dom": "^12.6.3", + "motion-utils": "^12.6.3", "tslib": "^2.4.0" }, "peerDependencies": { @@ -5132,9 +5569,9 @@ } }, "node_modules/lucide-react": { - "version": "0.483.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.483.0.tgz", - "integrity": "sha512-WldsY17Qb/T3VZdMnVQ9C3DDIP7h1ViDTHVdVGnLZcvHNg30zH/MTQ04RTORjexoGmpsXroiQXZ4QyR0kBy0FA==", + "version": "0.487.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.487.0.tgz", + "integrity": "sha512-aKqhOQ+YmFnwq8dWgGjOuLc8V1R9/c/yOd+zDY4+ohsR2Jo05lSGc3WsstYPIzcTpeosN7LoCkLReUUITvaIvw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -5210,18 +5647,18 @@ } }, "node_modules/motion-dom": { - "version": "12.5.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.5.0.tgz", - "integrity": "sha512-uH2PETDh7m+Hjd1UQQ56yHqwn83SAwNjimNPE/kC+Kds0t4Yh7+29rfo5wezVFpPOv57U4IuWved5d1x0kNhbQ==", + "version": "12.6.3", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.6.3.tgz", + "integrity": "sha512-gRY08RjcnzgFYLemUZ1lo/e9RkBxR+6d4BRvoeZDSeArG4XQXERSPapKl3LNQRu22Sndjf1h+iavgY0O4NrYqA==", "license": "MIT", "dependencies": { - "motion-utils": "^12.5.0" + "motion-utils": "^12.6.3" } }, "node_modules/motion-utils": { - "version": "12.5.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.5.0.tgz", - "integrity": "sha512-+hFFzvimn0sBMP9iPxBa9OtRX35ZQ3py0UHnb8U29VD+d8lQ8zH3dTygJWqK7av2v6yhg7scj9iZuvTS0f4+SA==", + "version": "12.6.3", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.6.3.tgz", + "integrity": "sha512-R/b3Ia2VxtTNZ4LTEO5pKYau1OUNHOuUfxuP0WFCTDYdHkeTBR9UtxR1cc8mDmKr8PEhmmfnTKGz3rSMjNRoRg==", "license": "MIT" }, "node_modules/ms": { @@ -5267,12 +5704,12 @@ } }, "node_modules/next": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/next/-/next-15.2.2.tgz", - "integrity": "sha512-dgp8Kcx5XZRjMw2KNwBtUzhngRaURPioxoNIVl5BOyJbhi9CUgEtKDO7fx5wh8Z8vOVX1nYZ9meawJoRrlASYA==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-15.2.4.tgz", + "integrity": "sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==", "license": "MIT", "dependencies": { - "@next/env": "15.2.2", + "@next/env": "15.2.4", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", @@ -5287,14 +5724,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.2.2", - "@next/swc-darwin-x64": "15.2.2", - "@next/swc-linux-arm64-gnu": "15.2.2", - "@next/swc-linux-arm64-musl": "15.2.2", - "@next/swc-linux-x64-gnu": "15.2.2", - "@next/swc-linux-x64-musl": "15.2.2", - "@next/swc-win32-arm64-msvc": "15.2.2", - "@next/swc-win32-x64-msvc": "15.2.2", + "@next/swc-darwin-arm64": "15.2.4", + "@next/swc-darwin-x64": "15.2.4", + "@next/swc-linux-arm64-gnu": "15.2.4", + "@next/swc-linux-arm64-musl": "15.2.4", + "@next/swc-linux-x64-gnu": "15.2.4", + "@next/swc-linux-x64-musl": "15.2.4", + "@next/swc-win32-arm64-msvc": "15.2.4", + "@next/swc-win32-x64-msvc": "15.2.4", "sharp": "^0.33.5" }, "peerDependencies": { @@ -5358,6 +5795,26 @@ "tslib": "^2.0.3" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -5428,15 +5885,16 @@ } }, "node_modules/object.entries": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", - "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "es-object-atoms": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -5622,6 +6080,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pocketbase": { + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.25.2.tgz", + "integrity": "sha512-ONZl1+qHJMnhR2uacBlBJ90lm7njtL/zy0606+1ROfK9hSL4LRBRc8r89rMcNRzPzRqCNyoFTh2Qg/lYXdEC1w==", + "license": "MIT" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -5806,6 +6270,12 @@ "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5828,24 +6298,24 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", - "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", - "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", "dependencies": { - "scheduler": "^0.25.0" + "scheduler": "^0.26.0" }, "peerDependencies": { - "react": "^19.0.0" + "react": "^19.1.0" } }, "node_modules/react-is": { @@ -5983,6 +6453,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -6115,9 +6591,9 @@ } }, "node_modules/scheduler": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", - "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, "node_modules/semver": { @@ -6378,9 +6854,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.1.tgz", - "integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", "license": "MIT" }, "node_modules/streamsearch": { @@ -6576,6 +7052,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.63.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.63.1.tgz", + "integrity": "sha512-1NdTJ4YI4jd8vbRLjGNg8ZCFlIb+t2iTtt1ddm+DsNKQC4GkhgjDMi7wRcXiWraBonYSlr/KARSknUW6iLM4fA==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "@types/node": "^22.7.5", + "es6-promise": "^4.2.8", + "fast-sha256": "^1.3.0", + "svix-fetch": "^3.0.0", + "url-parse": "^1.5.10" + } + }, + "node_modules/svix-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/svix-fetch/-/svix-fetch-3.0.0.tgz", + "integrity": "sha512-rcADxEFhSqHbraZIsjyZNh4TF6V+koloX1OzZ+AQuObX9mZ2LIMhm1buZeuc5BIZPftZpJCMBsSiBaeszo9tRw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, "node_modules/swr": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.3.tgz", @@ -6590,20 +7090,20 @@ } }, "node_modules/synckit": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz", - "integrity": "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==", + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.2.tgz", + "integrity": "sha512-1IUffI8zZ8qUMB3NUJIjk0RpLroG/8NkQDAWH1NbB2iJ0/5pn3M8rxfNzMz4GH9OnYaGYn31LEDSXJp/qIlxgA==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.1.0", - "tslib": "^2.6.2" + "@pkgr/core": "^0.2.0", + "tslib": "^2.8.1" }, "engines": { "node": "^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://opencollective.com/unts" + "url": "https://opencollective.com/synckit" } }, "node_modules/tabbable": { @@ -6613,9 +7113,9 @@ "license": "MIT" }, "node_modules/tailwind-merge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.2.tgz", - "integrity": "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.1.0.tgz", + "integrity": "sha512-aV27Oj8B7U/tAOMhJsSGdWqelfmudnGMdXIlMnk1JfsjwSjts6o8HyfN7SFH3EztzH4YH8kk6GbLTHzITJO39Q==", "license": "MIT", "funding": { "type": "github", @@ -6623,9 +7123,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.15.tgz", - "integrity": "sha512-6ZMg+hHdMJpjpeCCFasX7K+U615U9D+7k5/cDK/iRwl6GptF24+I/AbKgOnXhVKePzrEyIXutLv36n4cRsq3Sg==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.2.tgz", + "integrity": "sha512-VCsK+fitIbQF7JlxXaibFhxrPq4E2hDcG8apzHUdWFMCQWD8uLdlHg4iSkZ53cgLCCcZ+FZK7vG8VjvLcnBgKw==", "dev": true, "license": "MIT" }, @@ -6707,6 +7207,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", @@ -6740,9 +7246,9 @@ "license": "0BSD" }, "node_modules/tw-animate-css": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.4.tgz", - "integrity": "sha512-yt+HkJB41NAvOffe4NweJU6fLqAlVx/mBX6XmHRp15kq0JxTtOKaIw8pVSWM1Z+n2nXtyi7cW6C9f0WG/F/QAQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.5.tgz", + "integrity": "sha512-ABzjfgVo+fDbhRREGL4KQZUqqdPgvc5zVrLyeW9/6mVqvaDepXc7EvedA+pYmMnIOsUAQMwcWzNvom26J2qYvQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Wombosvideo" @@ -6762,9 +7268,9 @@ } }, "node_modules/type-fest": { - "version": "4.37.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.37.0.tgz", - "integrity": "sha512-S/5/0kFftkq27FPNye0XM1e2NsnoD/3FS+pBmbjmmtLT6I+i344KoOf7pvXreaFsDamWeaJX55nczA1m5PsBDg==", + "version": "4.39.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.39.1.tgz", + "integrity": "sha512-uW9qzd66uyHYxwyVBYiwS4Oi0qZyUqwjU+Oevr6ZogYiXt99EOYtwvzMSLw1c3lYo2HzJsep/NB23iEVEgjG/w==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -6866,177 +7372,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.26.1.tgz", - "integrity": "sha512-t/oIs9mYyrwZGRpDv3g+3K6nZ5uhKEMt2oNmAPwaY4/ye0+EH4nXIPYNtkYFS6QHm+1DFg34DbglYBz5P9Xysg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.26.1", - "@typescript-eslint/parser": "8.26.1", - "@typescript-eslint/utils": "8.26.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.26.1.tgz", - "integrity": "sha512-2X3mwqsj9Bd3Ciz508ZUtoQQYpOhU/kWoUqIf49H8Z0+Vbh6UF/y0OEYp0Q0axOGzaBGs7QxRwq0knSQ8khQNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.26.1", - "@typescript-eslint/type-utils": "8.26.1", - "@typescript-eslint/utils": "8.26.1", - "@typescript-eslint/visitor-keys": "8.26.1", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.26.1.tgz", - "integrity": "sha512-w6HZUV4NWxqd8BdeFf81t07d7/YV9s7TCWrQQbG5uhuvGUAW+fq1usZ1Hmz9UPNLniFnD8GLSsDpjP0hm1S4lQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.26.1", - "@typescript-eslint/types": "8.26.1", - "@typescript-eslint/typescript-estree": "8.26.1", - "@typescript-eslint/visitor-keys": "8.26.1", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.26.1.tgz", - "integrity": "sha512-6EIvbE5cNER8sqBu6V7+KeMZIC1664d2Yjt+B9EWUXrsyWpxx4lEZrmvxgSKRC6gX+efDL/UY9OpPZ267io3mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.26.1", - "@typescript-eslint/visitor-keys": "8.26.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.26.1.tgz", - "integrity": "sha512-Kcj/TagJLwoY/5w9JGEFV0dclQdyqw9+VMndxOJKtoFSjfZhLXhYjzsQEeyza03rwHx2vFEGvrJWJBXKleRvZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "8.26.1", - "@typescript-eslint/utils": "8.26.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.0.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.26.1.tgz", - "integrity": "sha512-n4THUQW27VmQMx+3P+B0Yptl7ydfceUj4ON/AQILAASwgYdZ/2dhfymRMh5egRUrvK5lSmaOm77Ry+lmXPOgBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.26.1.tgz", - "integrity": "sha512-yUwPpUHDgdrv1QJ7YQal3cMVBGWfnuCdKbXw1yyjArax3353rEJP1ZA+4F8nOlQ3RfS2hUN/wze3nlY+ZOhvoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.26.1", - "@typescript-eslint/visitor-keys": "8.26.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.26.1.tgz", - "integrity": "sha512-V4Urxa/XtSUroUrnI7q6yUTD3hDtfJ2jzVfeT3VK0ciizfK2q/zGC0iDh1lFMUZR8cImRrep6/q0xd/1ZGPQpg==", + "version": "8.29.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.29.0.tgz", + "integrity": "sha512-ep9rVd9B4kQsZ7ZnWCVxUE/xDLUUUsRzE0poAeNu+4CkFErLfuvPt/qtm2EpnSyfvsR0S6QzDFSrPCFBwf64fg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.26.1", - "@typescript-eslint/types": "8.26.1", - "@typescript-eslint/typescript-estree": "8.26.1" + "@typescript-eslint/eslint-plugin": "8.29.0", + "@typescript-eslint/parser": "8.29.0", + "@typescript-eslint/utils": "8.29.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7050,80 +7394,6 @@ "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.26.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.26.1.tgz", - "integrity": "sha512-AjOC3zfnxd6S4Eiy3jwktJPclqhFHNyd8L6Gycf9WUPoKZpgM5PjkxY1X7uSy61xVpiJDhhk7XT2NVsN3ALTWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.26.1", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/typescript-eslint/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/typescript-eslint/node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/typescript-eslint/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/typescript-eslint/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -7144,10 +7414,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "dev": true, + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -7190,6 +7459,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -7242,6 +7521,28 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 46f12d3..18c2ef5 100644 --- a/package.json +++ b/package.json @@ -8,38 +8,52 @@ "start": "next start", "lint": "next lint", "lint:fix": "next lint --fix", + "eslint": "next lint --fix", + "prettier": "prettier --write .", + "prettier:check": "prettier --check .", "format": "prettier --write .", "format:check": "prettier --check .", "tsc": "npx tsc --noEmit --watch", "prepare": "husky install" }, "dependencies": { - "@clerk/nextjs": "6.12.12", - "@eslint/js": "9.23.0", - "@headlessui/react": "2.2.0", + "@clerk/nextjs": "6.13.0", + "@eslint/js": "9.24.0", + "@headlessui/react": "2.2.1", "@heroicons/react": "2.2.0", + "@radix-ui/react-alert-dialog": "1.1.6", "@radix-ui/react-avatar": "1.1.3", + "@radix-ui/react-checkbox": "1.1.4", "@radix-ui/react-dialog": "1.1.6", + "@radix-ui/react-dropdown-menu": "2.1.6", "@radix-ui/react-icons": "1.3.2", + "@radix-ui/react-label": "2.1.2", + "@radix-ui/react-popover": "1.1.6", + "@radix-ui/react-select": "2.1.6", "@radix-ui/react-separator": "1.1.2", - "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tooltip": "1.1.8", + "@tanstack/react-table": "8.21.2", "@types/canvas-confetti": "1.9.0", "autoprefixer": "10.4.21", "canvas-confetti": "1.9.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", + "crypto": "1.0.1", + "date-fns": "^4.1.0", "dayjs": "1.11.13", - "framer-motion": "12.6.2", + "framer-motion": "12.6.3", "heroicons": "2.2.0", - "lucide-react": "0.485.0", + "lucide-react": "0.487.0", "next": "15.2.4", "pocketbase": "0.25.2", "postcss": "8.5.3", "react": "19.1.0", + "react-day-picker": "^9.6.4", "react-dom": "19.1.0", "react-use-measure": "2.1.7", - "tailwind-merge": "3.0.2", + "svix": "1.63.1", + "tailwind-merge": "3.1.0", "tw-animate-css": "1.2.5", "zod": "3.24.2", "zustand": "5.0.3" @@ -47,24 +61,24 @@ "devDependencies": { "@eslint/eslintrc": "3.3.1", "@next/eslint-plugin-next": "15.2.4", - "@tailwindcss/postcss": "4.0.17", - "@types/node": "22.13.14", - "@types/react": "19.0.12", - "@types/react-dom": "19.0.4", - "@typescript-eslint/eslint-plugin": "8.28.0", - "@typescript-eslint/parser": "8.28.0", - "eslint": "9.23.0", + "@tailwindcss/postcss": "4.1.3", + "@types/node": "22.14.0", + "@types/react": "19.1.0", + "@types/react-dom": "19.1.1", + "@typescript-eslint/eslint-plugin": "8.29.0", + "@typescript-eslint/parser": "8.29.0", + "eslint": "9.24.0", "eslint-config-next": "15.2.4", "eslint-config-prettier": "10.1.1", - "eslint-plugin-perfectionist": "4.10.1", - "eslint-plugin-prettier": "5.2.5", - "eslint-plugin-react": "7.37.4", + "eslint-plugin-perfectionist": "4.11.0", + "eslint-plugin-prettier": "5.2.6", + "eslint-plugin-react": "7.37.5", "husky": "9.1.7", "prettier": "3.5.3", "prettier-plugin-tailwindcss": "0.6.11", - "tailwindcss": "4.0.17", + "tailwindcss": "4.1.3", "tailwindcss-animate": "1.0.7", - "typescript": "5.8.2", - "typescript-eslint": "8.28.0" + "typescript": "5.8.3", + "typescript-eslint": "8.29.0" } } diff --git a/src/app/(application)/(clerk)/onboarding/[[...onboarding]]/OrganizationStep.tsx b/src/app/(application)/(clerk)/onboarding/[[...onboarding]]/OrganizationStep.tsx index a923847..3ed30fc 100644 --- a/src/app/(application)/(clerk)/onboarding/[[...onboarding]]/OrganizationStep.tsx +++ b/src/app/(application)/(clerk)/onboarding/[[...onboarding]]/OrganizationStep.tsx @@ -1,15 +1,19 @@ import { Button } from '@/components/ui/button' -import { Organization } from '@clerk/nextjs/server' import { Building, User, Info, CheckCircle2 } from 'lucide-react' import Image from 'next/image' import { useRouter } from 'next/navigation' +// Only require the properties we actually use +interface OrganizationData { + imageUrl?: string +} + export function OrganizationStep({ hasOrganization, organization, }: { hasOrganization: boolean - organization: Organization + organization: OrganizationData }) { const router = useRouter() diff --git a/src/app/(application)/app/page.tsx b/src/app/(application)/app/page.tsx index 25e9853..0385d31 100644 --- a/src/app/(application)/app/page.tsx +++ b/src/app/(application)/app/page.tsx @@ -8,6 +8,9 @@ import { Building, Scan, ClipboardList, + Settings, + Map, + History, } from 'lucide-react' import Link from 'next/link' import { redirect } from 'next/navigation' @@ -26,6 +29,7 @@ export default async function Dashboard() { bgColor: 'bg-blue-100', color: 'text-blue-600', description: 'Gérer et suivre tous les équipements et outils', + disabled: true, href: '/app/equipments', icon: Wrench, spotlightColor: @@ -36,6 +40,7 @@ export default async function Dashboard() { bgColor: 'bg-amber-100', color: 'text-amber-600', description: 'Gérer les projets, chantiers et emplacements', + disabled: false, href: '/app/projects', icon: Construction, spotlightColor: @@ -46,7 +51,8 @@ export default async function Dashboard() { bgColor: 'bg-green-100', color: 'text-green-600', description: 'Gérer les utilisateurs et permissions', - href: '/app/users', + disabled: false, + href: '/organization-profile/organization-members', icon: User, spotlightColor: 'rgba(34, 197, 94, 0.25)' as `rgba(${number}, ${number}, ${number}, ${number})`, @@ -55,7 +61,8 @@ export default async function Dashboard() { { bgColor: 'bg-purple-100', color: 'text-purple-600', - description: 'Scanner et localiser des équipements', + description: 'Scanner, QR code, NFC, assigner un équipement rapidement', + disabled: true, href: '/app/scan', icon: Scan, spotlightColor: @@ -63,9 +70,11 @@ export default async function Dashboard() { title: 'Scanner', }, { + // todo: reactivate this one bgColor: 'bg-red-100', color: 'text-red-600', description: 'Rapports et inventaire complet', + disabled: true, href: '/app/inventory', icon: ClipboardList, spotlightColor: @@ -76,12 +85,47 @@ export default async function Dashboard() { bgColor: 'bg-indigo-100', color: 'text-indigo-600', description: 'Paramètres et configuration', + disabled: false, href: '/organizations', icon: Building, spotlightColor: 'rgba(99, 102, 241, 0.25)' as `rgba(${number}, ${number}, ${number}, ${number})`, title: 'Organisation', }, + { + bgColor: 'bg-gray-100', + color: 'text-gray-600', + description: 'Localiser des équipements', + disabled: true, + href: '/app/localization', + icon: Map, + spotlightColor: + 'rgba(99, 102, 241, 0.25)' as `rgba(${number}, ${number}, ${number}, ${number})`, + title: 'Localisation', + }, + // historique + { + bgColor: 'bg-gray-100', + color: 'text-gray-600', + description: 'Historique des équipements', + disabled: true, + href: '/app/history', + icon: History, + spotlightColor: + 'rgba(99, 102, 241, 0.25)' as `rgba(${number}, ${number}, ${number}, ${number})`, + title: 'Historique', + }, + { + bgColor: 'bg-gray-100', + color: 'text-gray-600', + description: 'Paramètres et configuration', + disabled: true, + href: '/app/settings', + icon: Settings, + spotlightColor: + 'rgba(99, 102, 241, 0.25)' as `rgba(${number}, ${number}, ${number}, ${number})`, + title: 'Paramètres', + }, ] return ( @@ -91,7 +135,11 @@ export default async function Dashboard() {
{quickLinks.map(link => ( - + +

Mes projets

+

+ Gérer vos projets, vos chantiers, et afficher leurs détails. +
+ Les éléments ici correspondents à des endroits, des bâtiments, des + étages, des salles, ils pourront être assignés à des équipements plus + tard. +

+
+ ) +} + +// Loading state for the projects table +function ProjectsTableSkeleton() { + return ( +
+
+ + +
+ +
+ + +
+
+ ) +} + +// Projects content component that fetches and displays projects +async function ProjectsContent() { + // Fetch the projects data + const { pbOrg, pbUser } = await getDataUserAndOrg() + + if (!pbOrg || !pbUser) { + return
Aucune organisation ou utilisateur trouvé
+ } + + const projects = await getOrganizationProjects(pbOrg.id) + + return ( +
+ +
+ ) +} + +// Main Projects page component +export default function ProjectsPage() { + return ( +
+ + }> + + +
+ ) +} diff --git a/src/app/actions/equipment/manageEquipments.ts b/src/app/actions/equipment/manageEquipments.ts new file mode 100644 index 0000000..aa0a535 --- /dev/null +++ b/src/app/actions/equipment/manageEquipments.ts @@ -0,0 +1,215 @@ +'use server' + +import { Equipment } from '@/app/actions/services/pocketbase/api_client/types' +import { generateUniqueEquipmentCode as generateUniqueCode } from '@/app/actions/services/pocketbase/equipment_service' +import { + createEquipment, + updateEquipment, + deleteEquipment, +} from '@/app/actions/services/pocketbase/secured/equipment_service' +import { SecurityError } from '@/app/actions/services/pocketbase/secured/security_types' +import { revalidatePath } from 'next/cache' +import { z } from 'zod' + +// Define validation schema for equipment data +const equipmentSchema = z.object({ + acquisitionDate: z.string().optional(), + name: z.string().min(2, 'Name must be at least 2 characters'), + notes: z.string().optional(), + parentEquipment: z.string().optional(), + tags: z.array(z.string()).optional(), +}) + +type EquipmentFormData = z.infer + +/** + * Result type for all equipment actions + */ +export type EquipmentActionResult = { + success: boolean + message?: string + data?: Equipment + validationErrors?: Record +} + +/** + * Convert tags array to string for PocketBase storage + */ +function convertTagsForStorage(tags?: string[]): string | undefined { + if (!tags || tags.length === 0) return undefined + return JSON.stringify(tags) +} + +/** + * Create a new equipment item + */ +export async function createEquipmentAction( + organizationId: string, + formData: EquipmentFormData +): Promise { + try { + // Validate input data + const validatedData = equipmentSchema.parse(formData) + + // Generate unique code for the equipment + const qrNfcCode = await generateUniqueCode() + + // Create the equipment with security checks built into the service + const newEquipment = await createEquipment({ + acquisitionDate: validatedData.acquisitionDate || undefined, + name: validatedData.name, + notes: validatedData.notes || undefined, + parentEquipment: validatedData.parentEquipment || undefined, + qrNfcCode, + tags: convertTagsForStorage(validatedData.tags), + }) + + // Revalidate relevant paths to refresh data + revalidatePath('/dashboard/equipment') + + return { + data: newEquipment, + message: 'Equipment created successfully', + success: true, + } + } catch (error) { + // Handle validation errors + if (error instanceof z.ZodError) { + const validationErrors = error.errors.reduce( + (acc, curr) => { + const key = curr.path.join('.') + acc[key] = curr.message + return acc + }, + {} as Record + ) + + return { + message: 'Validation failed', + success: false, + validationErrors, + } + } + + // Handle security errors + if (error instanceof SecurityError) { + return { + message: (error as SecurityError).message, + success: false, + } + } + + // Handle other errors + console.error('Error creating equipment:', error) + return { + message: + error instanceof Error ? error.message : 'An unknown error occurred', + success: false, + } + } +} + +/** + * Update an existing equipment item + */ +export async function updateEquipmentAction( + equipmentId: string, + formData: EquipmentFormData +): Promise { + try { + // Validate input data + const validatedData = equipmentSchema.parse(formData) + + // Update the equipment with security checks built into the service + const updatedEquipment = await updateEquipment({ + data: { + acquisitionDate: validatedData.acquisitionDate || undefined, + name: validatedData.name, + notes: validatedData.notes || undefined, + parentEquipment: validatedData.parentEquipment || undefined, + tags: convertTagsForStorage(validatedData.tags), + }, + id: equipmentId, + }) + + // Revalidate relevant paths to refresh data + revalidatePath('/dashboard/equipment') + revalidatePath(`/dashboard/equipment/${equipmentId}`) + + return { + data: updatedEquipment, + message: 'Equipment updated successfully', + success: true, + } + } catch (error) { + // Handle validation errors + if (error instanceof z.ZodError) { + const validationErrors = error.errors.reduce( + (acc, curr) => { + const key = curr.path.join('.') + acc[key] = curr.message + return acc + }, + {} as Record + ) + + return { + message: 'Validation failed', + success: false, + validationErrors, + } + } + + // Handle security errors + if (error instanceof SecurityError) { + return { + message: (error as SecurityError).message, + success: false, + } + } + + // Handle other errors + console.error('Error updating equipment:', error) + return { + message: + error instanceof Error ? error.message : 'An unknown error occurred', + success: false, + } + } +} + +/** + * Delete an equipment item + */ +export async function deleteEquipmentAction( + equipmentId: string +): Promise { + try { + // Delete the equipment with security checks built into the service + await deleteEquipment(equipmentId) + + // Revalidate relevant paths to refresh data + revalidatePath('/dashboard/equipment') + + return { + message: 'Equipment deleted successfully', + success: true, + } + } catch (error) { + // Handle security errors + if (error instanceof SecurityError) { + return { + message: (error as SecurityError).message, + success: false, + } + } + + // Handle other errors + console.error('Error deleting equipment:', error) + return { + message: + error instanceof Error ? error.message : 'An unknown error occurred', + success: false, + } + } +} diff --git a/src/app/actions/equipment/readme.ai.md b/src/app/actions/equipment/readme.ai.md new file mode 100644 index 0000000..f10687b --- /dev/null +++ b/src/app/actions/equipment/readme.ai.md @@ -0,0 +1,47 @@ +# Equipment Actions Overview + +This directory contains server actions specific to equipment management functionality. These actions provide the business logic for equipment-related operations in the application. + +## Key Files + +- Server action files for equipment operations (create, update, delete, etc.) + +## Key Concepts + +- **Server Actions**: Functions for equipment-related data operations +- **Validation**: Input validation for equipment operations +- **Security**: Authorization checks for equipment access +- **Business Logic**: Specific rules for equipment management + +## Best Practices + +- Use the secured PocketBase services for data operations +- Implement proper validation for all inputs +- Follow the established patterns for server actions +- Handle errors appropriately with specific messages + +## Do's and Don'ts + +### Do + +- Use secured services for all data operations +- Validate inputs with Zod before processing +- Handle errors with appropriate status codes +- Follow the established patterns for equipment actions + +### Don't + +- Bypass security checks or validation +- Implement business logic that belongs in the UI +- Create redundant actions for similar operations +- Expose sensitive data in responses + +## For AI Assistants + +When working with this directory: + +- Understand that these actions handle equipment-specific operations +- Note that all actions should use secured services +- Be aware of the business rules for equipment management +- Follow the established patterns for error handling +- Remember that equipment is organization-specific and requires proper isolation diff --git a/src/app/actions/readme.ai.md b/src/app/actions/readme.ai.md new file mode 100644 index 0000000..f4988b3 --- /dev/null +++ b/src/app/actions/readme.ai.md @@ -0,0 +1,47 @@ +# Actions Directory Overview + +This directory contains server actions that handle data operations and business logic for the application. Server actions are a Next.js feature that allows executing code on the server for secure data operations. + +## Directory Structure + +- `equipment/` - Actions related to equipment management +- `services/` - Core services and utilities for data access and integration + +## Key Concepts + +- **Server Actions**: Functions marked with `'use server'` that run securely on the server +- **Security Middleware**: Higher-order functions that wrap actions to enforce security +- **Service Layer**: Utilities for interacting with external services like PocketBase and Clerk + +## Best Practices + +- Always wrap sensitive operations with the security middleware +- Include proper error handling and validation +- Use TypeScript types for inputs and outputs +- Keep actions focused on a single responsibility + +## Do's and Don'ts + +### Do + +- Use the `withSecurity` middleware for protected operations +- Validate inputs with Zod before processing +- Handle errors with proper status codes and messages +- Organize actions by domain/feature + +### Don't + +- Create actions without proper security checks +- Expose sensitive data in return values +- Mix client and server code in the same file +- Bypass the service layer for data access + +## For AI Assistants + +When working with this directory: + +- Always respect the multi-tenant security model +- Understand the pattern of using service layers for data access +- Follow the established pattern for error handling and validation +- Be aware of the revalidation paths for data mutations +- Note that the security middleware automatically handles organization isolation diff --git a/src/app/actions/services/clerk-sync/cacheService.ts b/src/app/actions/services/clerk-sync/cacheService.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/app/actions/services/clerk-sync/onBoardingHelper.ts b/src/app/actions/services/clerk-sync/onBoardingHelper.ts new file mode 100644 index 0000000..e346069 --- /dev/null +++ b/src/app/actions/services/clerk-sync/onBoardingHelper.ts @@ -0,0 +1,141 @@ +import { + syncUserToPocketBase, + syncOrganizationToPocketBase, + linkUserToOrganizationFromClerk, +} from '@/app/actions/services/clerk-sync/syncService' +import { auth, clerkClient } from '@clerk/nextjs/server' + +/** + * Type for metadata to replace any + */ +type ClerkMetadata = Record + +/** + * Imports an organization after it's created during onboarding + * This function should be called after organization creation in Clerk + * + * @param clerkOrgId The Clerk organization ID to import + * @returns The imported organization data + */ +export async function importOrganizationAfterCreation(clerkOrgId: string) { + 'use server' + + try { + const { userId } = await auth() + + if (!userId) { + throw new Error('User not authenticated') + } + + // Get the organization data from Clerk + const clerkClientInstance = await clerkClient() + const clerkOrg = await clerkClientInstance.organizations.getOrganization({ + organizationId: clerkOrgId, + }) + + // Sync the organization to PocketBase + const organization = await syncOrganizationToPocketBase(clerkOrg) + + // Get the user from Clerk + const clerkUser = await clerkClientInstance.users.getUser(userId) + + // Sync the user to PocketBase + const user = await syncUserToPocketBase(clerkUser) + + // Create the membership data for the link + const membershipData = { + organization: { id: clerkOrgId }, + public_user_data: { user_id: userId }, + role: 'admin', // During onboarding, the creator is always an admin + } + + // Link the user to the organization + await linkUserToOrganizationFromClerk(membershipData) + + return { + organization, + status: 'success', + user, + } + } catch (error) { + console.error('Error importing organization after creation:', error) + throw error + } +} + +/** + * Updates user metadata in Clerk and syncs to PocketBase + * Useful for onboarding completion + * + * @param metadata The metadata to set for the user + * @returns Success status + */ +export async function updateUserMetadataAndSync(metadata: ClerkMetadata) { + 'use server' + + try { + const { userId } = await auth() + + if (!userId) { + throw new Error('User not authenticated') + } + + // Update the metadata in Clerk + const clerkClientInstance = await clerkClient() + await clerkClientInstance.users.updateUserMetadata(userId, { + publicMetadata: metadata, + }) + + // Get the updated user + const clerkUser = await clerkClientInstance.users.getUser(userId) + + // Sync the updated user to PocketBase + await syncUserToPocketBase(clerkUser) + + return { status: 'success' } + } catch (error) { + console.error('Error updating user metadata and syncing:', error) + throw error + } +} + +/** + * Updates organization metadata in Clerk and syncs to PocketBase + * + * @param orgId The Clerk organization ID + * @param metadata The metadata to set for the organization + * @returns Success status + */ +export async function updateOrgMetadataAndSync( + orgId: string, + metadata: ClerkMetadata +) { + 'use server' + + try { + const { userId } = await auth() + + if (!userId) { + throw new Error('User not authenticated') + } + + // Update the metadata in Clerk + const clerkClientInstance = await clerkClient() + await clerkClientInstance.organizations.updateOrganizationMetadata(orgId, { + publicMetadata: metadata, + }) + + // Get the updated organization + const clerkOrg = await clerkClientInstance.organizations.getOrganization({ + organizationId: orgId, + }) + + // Sync the updated organization to PocketBase + await syncOrganizationToPocketBase(clerkOrg) + + return { status: 'success' } + } catch (error) { + console.error('Error updating organization metadata and syncing:', error) + throw error + } +} diff --git a/src/app/actions/services/clerk-sync/readme.ai.md b/src/app/actions/services/clerk-sync/readme.ai.md new file mode 100644 index 0000000..560f0f3 --- /dev/null +++ b/src/app/actions/services/clerk-sync/readme.ai.md @@ -0,0 +1,51 @@ +# Clerk Synchronization Services Overview + +This directory contains services that handle synchronization between Clerk (authentication provider) and PocketBase (database). It ensures data consistency between these two systems. + +## Key Files + +- `syncService.ts` - Core functions for syncing users, organizations, and memberships +- `reconciliation.ts` - Functions for running full or partial data reconciliation +- `webhook-handler.ts` - Handler for processing Clerk webhook events +- `onBoardingHelper.ts` - Helpers for user onboarding processes +- `syncMiddleware.ts` - Middleware for sync operations + +## Key Concepts + +- **Data Synchronization**: Keeping Clerk and PocketBase data in sync +- **Webhooks**: Processing real-time events from Clerk +- **Reconciliation**: Scheduled processes to ensure data consistency +- **Onboarding**: Steps for new user and organization setup + +## Best Practices + +- Handle errors gracefully and provide detailed logs +- Use proper typing for all data structures +- Maintain idempotent operations for reliability +- Implement retries for transient failures + +## Do's and Don'ts + +### Do + +- Use the established sync functions for user and organization operations +- Handle webhook events according to their types +- Consider data consistency in all operations +- Log important sync operations for debugging + +### Don't + +- Create direct database updates that bypass sync services +- Remove error handling in sync operations +- Mix sync logic with presentation logic +- Introduce circular dependencies between sync functions + +## For AI Assistants + +When working with this directory: + +- Understand that `syncService.ts` contains the core sync functions +- Be aware of the data flow between Clerk and PocketBase +- Note that webhooks drive most synchronization operations +- Remember that reconciliation is used for periodic full sync +- Consider the direction of sync (Clerk to PocketBase, not vice versa) diff --git a/src/app/actions/services/clerk-sync/reconciliation.ts b/src/app/actions/services/clerk-sync/reconciliation.ts new file mode 100644 index 0000000..c249c1c --- /dev/null +++ b/src/app/actions/services/clerk-sync/reconciliation.ts @@ -0,0 +1,233 @@ +// this file is used to sync the data between Clerk and PocketBase +// Import types for Clerk entities +import type { Organization, User } from '@clerk/nextjs/server' + +import { + syncUserToPocketBase, + syncOrganizationToPocketBase, + linkUserToOrganizationFromClerk, +} from '@/app/actions/services/clerk-sync/syncService' +// src/app/actions/services/clerk-sync/reconciliation.ts +import { clerkClient } from '@clerk/nextjs/server' + +/** + * Full reconciliation between Clerk and PocketBase + * This script should be run periodically to ensure data consistency + * Could be triggered by a cron job or scheduled task + */ +export async function runFullReconciliation() { + try { + const startTime = Date.now() + + // Get all users and organizations from Clerk + const clerkClientInstance = await clerkClient() + const clerkUsersResponse = await clerkClientInstance.users.getUserList() + const clerkOrganizationsResponse = + await clerkClientInstance.organizations.getOrganizationList() + + // Extract the data arrays from the paginated responses + const clerkUsers = clerkUsersResponse.data + const clerkOrganizations = clerkOrganizationsResponse.data + + // Sync all organizations first + const orgResults = await Promise.allSettled( + clerkOrganizations.map((org: Organization) => + syncOrganizationToPocketBase(org) + ) + ) + + const successfulOrgs = orgResults.filter( + (result: PromiseSettledResult) => result.status === 'fulfilled' + ).length + + // Sync all users + const userResults = await Promise.allSettled( + clerkUsers.map((user: User) => syncUserToPocketBase(user)) + ) + + const successfulUsers = userResults.filter( + (result: PromiseSettledResult) => result.status === 'fulfilled' + ).length + + // Sync organization memberships + let membershipCount = 0 + + for (const org of clerkOrganizations) { + try { + const membershipsResponse = + await clerkClientInstance.organizations.getOrganizationMembershipList( + { + organizationId: org.id, + } + ) + + // Get the actual membership data array + const memberships = membershipsResponse.data + + for (const membership of memberships) { + try { + // Skip if userId is undefined + if (!membership.publicUserData?.userId) continue + + const membershipData = { + organization: { id: org.id }, + public_user_data: { user_id: membership.publicUserData.userId }, + role: membership.role, + } + + await linkUserToOrganizationFromClerk(membershipData) + membershipCount++ + } catch (error) { + if (membership.publicUserData) { + console.error( + `Error syncing membership for user ${membership.publicUserData.userId} in org ${org.id}:`, + error + ) + } + } + } + } catch (error) { + console.error( + `Error fetching memberships for organization ${org.id}:`, + error + ) + } + } + + // Done + const totalTime = (Date.now() - startTime) / 1000 + + return { + memberships: membershipCount, + organizations: { + failed: clerkOrganizations.length - successfulOrgs, + total: clerkOrganizations.length, + }, + status: 'success', + timeTaken: totalTime, + users: { + failed: clerkUsers.length - successfulUsers, + total: clerkUsers.length, + }, + } + } catch (error) { + console.error('Reconciliation failed:', error) + throw error + } +} + +/** + * Reconcile a specific user by checking and updating their data + * Useful for targeted fixes or individual user troubleshooting + * + * @param clerkUserId The Clerk user ID to reconcile + * @returns The reconciliation result + */ +export async function reconcileSpecificUser(clerkUserId: string) { + try { + // Get user data from Clerk + const clerkClientInstance = await clerkClient() + const clerkUser = await clerkClientInstance.users.getUser(clerkUserId) + + // Sync user to PocketBase + await syncUserToPocketBase(clerkUser) + + // Find all organizations this user belongs to + const membershipsResponse = + await clerkClientInstance.users.getOrganizationMembershipList({ + userId: clerkUserId, + }) + + // Extract the data array + const memberships = membershipsResponse.data + + // Sync each organization and membership + for (const membership of memberships) { + const orgId = membership.organization.id + + // Sync the organization + const clerkOrg = await clerkClientInstance.organizations.getOrganization({ + organizationId: orgId, + }) + await syncOrganizationToPocketBase(clerkOrg) + + // Sync the membership + const membershipData = { + organization: { id: orgId }, + public_user_data: { user_id: clerkUserId }, + role: membership.role, + } + + await linkUserToOrganizationFromClerk(membershipData) + } + + return { + memberships: memberships.length, + status: 'success', + userId: clerkUserId, + } + } catch (error) { + console.error(`Error reconciling user ${clerkUserId}:`, error) + throw error + } +} + +/** + * Reconcile a specific organization and all its members + * + * @param clerkOrgId The Clerk organization ID to reconcile + * @returns The reconciliation result + */ +export async function reconcileSpecificOrganization(clerkOrgId: string) { + try { + // Get organization data from Clerk + const clerkClientInstance = await clerkClient() + const clerkOrg = await clerkClientInstance.organizations.getOrganization({ + organizationId: clerkOrgId, + }) + + // Sync organization to PocketBase + await syncOrganizationToPocketBase(clerkOrg) + + // Find all members of this organization + const membershipsResponse = + await clerkClientInstance.organizations.getOrganizationMembershipList({ + organizationId: clerkOrgId, + }) + + // Extract the data array + const memberships = membershipsResponse.data + + // Sync each user and membership + for (const membership of memberships) { + const userId = membership.publicUserData?.userId + + if (!userId) { + console.error(`User ID not found for membership ${membership.id}`) + continue + } + + // Sync the user + const clerkUser = await clerkClientInstance.users.getUser(userId) + await syncUserToPocketBase(clerkUser) + + // Sync the membership + const membershipData = { + organization: { id: clerkOrgId }, + public_user_data: { user_id: userId }, + role: membership.role, + } + + await linkUserToOrganizationFromClerk(membershipData) + } + + return { + members: memberships.length, + organizationId: clerkOrgId, + status: 'success', + } + } catch (error) { + console.error(`Error reconciling organization ${clerkOrgId}:`, error) + throw error + } +} diff --git a/src/app/actions/services/clerk-sync/syncMiddleware.ts b/src/app/actions/services/clerk-sync/syncMiddleware.ts new file mode 100644 index 0000000..d87a60c --- /dev/null +++ b/src/app/actions/services/clerk-sync/syncMiddleware.ts @@ -0,0 +1,228 @@ +'use server' + +import { AppUser, Organization } from '@/models/pocketbase' +import { clerkClient, OrganizationMembership } from '@clerk/nextjs/server' + +import { + findUserByClerkId, + getAppUserService, +} from '../pocketbase/app_user_service' +import { + createOrUpdateOrganizationUserMapping, + getOrganizationAppUserService, +} from '../pocketbase/organization_app_user_service' +import { findOrganizationByClerkId } from '../pocketbase/organization_service' +import { + syncOrganizationToPocketBase, + syncUserToPocketBase, +} from './syncService' + +/** + * Ensures a user is synchronized between Clerk and PocketBase + * @param clerkUserId - The Clerk user ID + * @returns The PocketBase user + */ +export async function ensureUserSync(clerkUserId: string): Promise { + // Check if user already exists in PocketBase + const existingUser = await findUserByClerkId(clerkUserId) + if (existingUser) { + return existingUser + } + + // User not found, sync from Clerk + const clerk = await clerkClient() + const clerkUser = await clerk.users.getUser(clerkUserId) + + return syncUserToPocketBase(clerkUser) +} + +/** + * Ensures an organization is synchronized between Clerk and PocketBase + * @param clerkOrgId - The Clerk organization ID + * @returns The PocketBase organization + */ +export async function ensureOrgSync(clerkOrgId: string): Promise { + // Check if organization already exists in PocketBase + const existingOrg = await findOrganizationByClerkId(clerkOrgId) + if (existingOrg) { + return existingOrg + } + + const clerk = await clerkClient() + const clerkOrg = await clerk.organizations.getOrganization({ + organizationId: clerkOrgId, + }) + + return syncOrganizationToPocketBase(clerkOrg) +} + +/** + * Ensures a user and organization are synchronized and linked + * @param clerkUserId - The Clerk user ID + * @param clerkOrgId - The Clerk organization ID + * @returns The PocketBase user and organization + */ +export async function ensureUserAndOrgSync( + clerkUserId: string, + clerkOrgId: string +): Promise<{ user: AppUser; org: Organization }> { + try { + // First ensure both user and org exist in PocketBase + const [user, org] = await Promise.all([ + ensureUserSync(clerkUserId), + ensureOrgSync(clerkOrgId), + ]) + + if (!user || !org) { + console.error( + `Failed to sync user ${clerkUserId} or organization ${clerkOrgId}` + ) + throw new Error('User or organization sync failed') + } + + // Get the user's role in the organization from Clerk + const clerk = await clerkClient() + + // Get all memberships for the organization + const memberships = await clerk.organizations.getOrganizationMembershipList( + { + organizationId: clerkOrgId, + } + ) + + // Validate that the user is actually a member of this organization + const membership = memberships.data.find( + m => m.publicUserData?.userId === clerkUserId + ) + + if (!membership) { + console.warn( + `User ${clerkUserId} is not a member of organization ${clerkOrgId} in Clerk` + ) + // Get organization app user service + const orgAppUserService = getOrganizationAppUserService() + + // Check if there's an incorrect mapping in PocketBase + const existingMapping = + await orgAppUserService.findByAppUserAndOrganization(user.id, org.id) + + // If an incorrect mapping exists, remove it for security + if (existingMapping) { + console.warn( + `Removing unauthorized mapping for user ${user.id} in org ${org.id}` + ) + await orgAppUserService.deleteMapping(user.id, org.id) + } + + throw new Error( + `User ${clerkUserId} is not authorized to access organization ${clerkOrgId}` + ) + } + + // Default to 'member' if no specific role is found + const role = membership.role?.replace('org:', '') || 'member' + + // Create or update the mapping in the junction table + await createOrUpdateOrganizationUserMapping(user.id, org.id, role) + + // Verify all other memberships to ensure consistency between Clerk and PocketBase + await verifyAllOrganizationMemberships(clerkOrgId, org.id, memberships.data) + + return { org, user } + } catch (error) { + console.error( + `Error ensuring user-org sync: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + throw error + } +} + +/** + * Verifies that all memberships match between Clerk and PocketBase + * @param clerkOrgId - The Clerk organization ID + * @param pbOrgId - The PocketBase organization ID + * @param clerkMemberships - The list of memberships from Clerk + */ +async function verifyAllOrganizationMemberships( + clerkOrgId: string, + pbOrgId: string, + clerkMemberships: OrganizationMembership[] +): Promise { + try { + // Get the organization-app-user service + const orgAppUserService = getOrganizationAppUserService() + + // Get all mappings for this organization in PocketBase + const pbMappings = await orgAppUserService.findByOrganizationId(pbOrgId) + + // Get the app user service to look up users by clerk ID + const appUserService = getAppUserService() + + // For each PocketBase mapping, verify it exists in Clerk + for (const pbMapping of pbMappings) { + // Get the app user from PocketBase + const appUser = await appUserService.getById(pbMapping.appUser) + + if (!appUser || !appUser.clerkId) { + console.warn( + `User ${pbMapping.appUser} has no clerkId, skipping verification` + ) + continue + } + + // Check if this user is a member in Clerk + const clerkMembership = clerkMemberships.find( + m => m.publicUserData?.userId === appUser.clerkId + ) + + // If no membership exists in Clerk but exists in PocketBase, remove it + if (!clerkMembership) { + console.warn( + `User ${appUser.clerkId} is not a member of org ${clerkOrgId} in Clerk, removing from PocketBase` + ) + await orgAppUserService.deleteMapping(pbMapping.appUser, pbOrgId) + continue + } + + // If the roles don't match, update the PocketBase role + const clerkRole = clerkMembership.role?.replace('org:', '') || 'member' + if (pbMapping.role !== clerkRole) { + await orgAppUserService.createOrUpdate( + pbMapping.appUser, + pbOrgId, + clerkRole + ) + } + } + + // For each Clerk membership, ensure it exists in PocketBase + for (const clerkMembership of clerkMemberships) { + const clerkUserId = clerkMembership.publicUserData?.userId + if (!clerkUserId) { + console.warn('Clerk membership has no userId, skipping') + continue + } + + // Find the user in PocketBase + const pbUser = await appUserService.findByClerkId(clerkUserId) + if (!pbUser) { + continue + } + + // Check if mapping exists + const pbMapping = await orgAppUserService.findByAppUserAndOrganization( + pbUser.id, + pbOrgId + ) + + // If no mapping exists in PocketBase but exists in Clerk, create it + if (!pbMapping) { + const role = clerkMembership.role?.replace('org:', '') || 'member' + await orgAppUserService.createOrUpdate(pbUser.id, pbOrgId, role) + } + } + } catch (error) { + console.error('Error verifying organization memberships:', error) + // Don't throw, just log the error to prevent breaking the main sync flow + } +} diff --git a/src/app/actions/services/clerk-sync/syncService.ts b/src/app/actions/services/clerk-sync/syncService.ts new file mode 100644 index 0000000..f8dcce6 --- /dev/null +++ b/src/app/actions/services/clerk-sync/syncService.ts @@ -0,0 +1,197 @@ +'use server' + +import type { + User, + Organization as ClerkOrganization, +} from '@clerk/nextjs/server' + +import { AppUser, Organization } from '@/models/pocketbase' + +import { + getAppUserService, + AppUserCreateInput, + createOrUpdateUserByClerkId, +} from '../pocketbase/app_user_service' +import { createOrUpdateOrganizationUserMapping } from '../pocketbase/organization_app_user_service' +import { + getOrganizationService, + OrganizationCreateInput, + createOrUpdateOrganizationByClerkId, +} from '../pocketbase/organization_service' + +/** + * Type interface for Clerk Organization with additional fields + */ +interface EnhancedClerkOrganization extends ClerkOrganization { + emailAddress?: string + phoneNumber?: string +} + +/** + * Type for Clerk organization membership data + */ +export interface ClerkMembershipData { + organization: { + id: string + } + public_user_data?: { + user_id: string + } + role?: string +} + +/** + * Synchronizes a Clerk user to PocketBase + * @param clerkUser - The user data from Clerk webhook or API + * @returns The created or updated user + */ +export async function syncUserToPocketBase(clerkUser: User): Promise { + try { + if (!clerkUser.id) { + throw new Error('Clerk user ID is required for syncing') + } + + // Get primary email + const primaryEmail = clerkUser.emailAddresses.find( + email => email.id === clerkUser.primaryEmailAddressId + ) + + if (!primaryEmail) { + throw new Error('User must have a primary email address') + } + + // Map Clerk data to our format + const userData = { + email: primaryEmail.emailAddress, + emailVisibility: true, + lastLogin: clerkUser.lastSignInAt + ? new Date(clerkUser.lastSignInAt).toISOString() + : '', + metadata: { + createdAt: clerkUser.createdAt.toString(), + externalAccounts: + clerkUser.externalAccounts?.map(account => ({ + email: account.emailAddress || '', + imageUrl: account.imageUrl || '', + provider: account.provider, + providerUserId: account.externalId, + })) || [], + hasCompletedOnboarding: + clerkUser.publicMetadata?.hasCompletedOnboarding === true, + lastActiveAt: clerkUser.lastActiveAt + ? clerkUser.lastActiveAt.toString() + : '', + onboardingCompletedAt: clerkUser.publicMetadata + ?.onboardingCompletedAt as string, + public: { + hasCompletedOnboarding: + clerkUser.publicMetadata?.hasCompletedOnboarding === true, + onboardingCompletedAt: clerkUser.publicMetadata + ?.onboardingCompletedAt as string, + }, + updatedAt: clerkUser.updatedAt.toString(), + }, + name: + `${clerkUser.firstName || ''} ${clerkUser.lastName || ''}`.trim() || + clerkUser.username || + 'Unknown', + organizations: '', + } + + // Use the utility function to create/update the user + return await createOrUpdateUserByClerkId( + clerkUser.id, + userData as Omit + ) + } catch (error) { + console.error('Error syncing user to PocketBase:', error) + throw error + } +} + +/** + * Synchronizes a Clerk organization to PocketBase + * @param organization - The organization data from Clerk + * @returns The created or updated organization + */ +export async function syncOrganizationToPocketBase( + organization: EnhancedClerkOrganization +): Promise { + try { + if (!organization.id) { + throw new Error('Clerk organization ID is required for syncing') + } + + // Map Clerk data to our format + const orgData = { + address: (organization.publicMetadata?.address as string) || '', + email: organization.emailAddress || '', + name: organization.name || 'Unnamed Organization', + phone: organization.phoneNumber || '', + priceId: (organization.publicMetadata?.priceId as string) || '', + settings: { + clerkData: { + createdAt: organization.createdAt, + updatedAt: organization.updatedAt, + ...organization.publicMetadata, + }, + }, + stripeCustomerId: + (organization.publicMetadata?.stripeCustomerId as string) || '', + subscriptionId: + (organization.publicMetadata?.subscriptionId as string) || '', + subscriptionStatus: + (organization.publicMetadata?.subscriptionStatus as string) || '', + } + + // Use the utility function to create/update the organization + return await createOrUpdateOrganizationByClerkId( + organization.id, + orgData as Omit + ) + } catch (error) { + console.error('Error syncing organization to PocketBase:', error) + throw error + } +} + +/** + * Links a user to an organization based on Clerk membership data + * @param membershipData - The membership data from Clerk + * @returns The updated user or null if linking failed + */ +export async function linkUserToOrganizationFromClerk( + membershipData: ClerkMembershipData +): Promise { + try { + const userId = membershipData.public_user_data?.user_id + const orgId = membershipData.organization.id + const role = membershipData.role?.replace('org:', '') || 'member' + + if (!userId || !orgId) { + throw new Error('Missing required IDs in membership data') + } + + // Get the services + const appUserService = getAppUserService() + const organizationService = getOrganizationService() + + // Find the PocketBase records + const user = await appUserService.findByClerkId(userId) + const org = await organizationService.findByClerkId(orgId) + + if (!user || !org) { + console.error('User or organization not found in PocketBase') + return null + } + + // Create or update the relation in the junction table + await createOrUpdateOrganizationUserMapping(user.id, org.id, role) + + // Return the user record + return user + } catch (error) { + console.error('Error linking user to organization:', error) + return null + } +} diff --git a/src/app/actions/services/clerk-sync/webhook-handler.ts b/src/app/actions/services/clerk-sync/webhook-handler.ts new file mode 100644 index 0000000..84191b6 --- /dev/null +++ b/src/app/actions/services/clerk-sync/webhook-handler.ts @@ -0,0 +1,306 @@ +'use server' + +import { + syncUserToPocketBase, + syncOrganizationToPocketBase, + linkUserToOrganizationFromClerk, + ClerkMembershipData, +} from '@/app/actions/services/clerk-sync/syncService' +import { createOrUpdateOrganizationUserMapping } from '@/app/actions/services/pocketbase/organization_app_user_service' +import { WebhookEvent, clerkClient, User } from '@clerk/nextjs/server' + +/** + * Result of processing a webhook event + */ +interface WebhookProcessingResult { + message: string + success: boolean + data?: Record + error?: string +} + +/** + * Process a Clerk webhook event + * @param {WebhookEvent} event - The webhook event from Clerk + * @returns {Promise} Result of processing + */ +export async function processWebhookEvent( + event: WebhookEvent +): Promise { + // Validate event data + if (!event || !event.data || !event.type) { + console.error('Invalid webhook event received') + return { + error: 'INVALID_EVENT_FORMAT', + message: 'Invalid webhook event format', + success: false, + } + } + + try { + // Handle organization events + if ( + event.type === 'organization.created' || + event.type === 'organization.updated' + ) { + // Retrieve complete organization data from Clerk + const clerkAPI = await clerkClient() + const organizationId = event.data.id as string + + if (!organizationId) { + return { + error: 'MISSING_ORGANIZATION_ID', + message: 'Missing organization ID in webhook data', + success: false, + } + } + + const completeOrg = await clerkAPI.organizations.getOrganization({ + organizationId, + }) + + const organization = await syncOrganizationToPocketBase(completeOrg) + return { + data: { organizationId: organization.id }, + message: `Organization ${organization.name} (${organization.id}) synchronized successfully`, + success: true, + } + } else if (event.type === 'organization.deleted') { + // We don't actually delete organizations + // This is a design choice to preserve history + return { + data: { organizationId: event.data.id as string }, + message: `Organization deletion registered but not processed (data preserved)`, + success: true, + } + } + + // Handle membership events + else if ( + event.type === 'organizationMembership.created' || + event.type === 'organizationMembership.updated' + ) { + // Convert membership data to our expected format + const membershipData: ClerkMembershipData = { + organization: { id: event.data.organization?.id }, + public_user_data: { + user_id: event.data.public_user_data?.user_id, + }, + role: event.data.role, + } + + // Validate required fields + if ( + !membershipData.organization?.id || + !membershipData.public_user_data?.user_id + ) { + return { + error: 'MISSING_MEMBERSHIP_DATA', + message: 'Missing required membership data', + success: false, + } + } + + try { + const user = await linkUserToOrganizationFromClerk(membershipData) + if (user) { + return { + data: { + organizationId: membershipData.organization.id, + userId: user.id, + }, + message: `User ${user.name} linked to organization successfully`, + success: true, + } + } else { + // Try to perform initial sync of user and organization if linking failed + const userId = membershipData.public_user_data.user_id + const orgId = membershipData.organization.id + + const clerk = await clerkClient() + + try { + // Get complete data from Clerk + const [clerkUser, clerkOrg] = await Promise.all([ + clerk.users.getUser(userId), + clerk.organizations.getOrganization({ organizationId: orgId }), + ]) + + // Sync both to PocketBase + const pbUser = await syncUserToPocketBase(clerkUser) + const pbOrg = await syncOrganizationToPocketBase(clerkOrg) + + // Try linking again + await createOrUpdateOrganizationUserMapping( + pbUser.id, + pbOrg.id, + membershipData.role?.replace('org:', '') || 'member' + ) + + return { + data: { organizationId: pbOrg.id, userId: pbUser.id }, + message: `User ${pbUser.name} linked to organization ${pbOrg.name} after sync`, + success: true, + } + } catch (syncError) { + console.error('Error syncing before linking:', syncError) + return { + error: 'SYNC_BEFORE_LINK_FAILED', + message: `Failed to link after sync attempt: ${syncError instanceof Error ? syncError.message : 'Unknown error'}`, + success: false, + } + } + } + } catch (error) { + console.error('Error linking user to organization:', error) + return { + error: 'LINK_FAILED', + message: `Failed to link user to organization: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } + } else if (event.type === 'organizationMembership.deleted') { + // We don't actually delete organization memberships + // This is a design choice to preserve history + return { + data: { + organizationId: event.data.organization?.id, + userId: event.data.public_user_data?.user_id, + }, + message: `Membership deletion registered but not processed (link preserved)`, + success: true, + } + } + + // Handle user events + else if (event.type === 'user.created' || event.type === 'user.updated') { + try { + // Validate user ID + const userId = event.data.id as string + if (!userId) { + return { + error: 'MISSING_USER_ID', + message: 'Missing user ID in webhook data', + success: false, + } + } + + // Retrieve complete user data from Clerk + const clerkAPI = await clerkClient() + const completeUser = await clerkAPI.users.getUser(userId) + + const user = await syncUserToPocketBase(completeUser) + return { + data: { userId: user.id }, + message: `User ${user.name} (${user.id}) synchronized successfully`, + success: true, + } + } catch (error) { + console.warn( + 'Failed to fetch user from Clerk API, using webhook data:', + error + ) + + // If we can't get the user from the API, try to use the webhook data directly + // This can happen due to replication delay after user creation + if (event.data && typeof event.data === 'object') { + try { + // Validate required email data + const emailAddresses = Array.isArray(event.data.email_addresses) + ? event.data.email_addresses + : [] + + const primaryEmailId = event.data.primary_email_address_id as string + + if (!primaryEmailId || emailAddresses.length === 0) { + return { + error: 'MISSING_EMAIL_DATA', + message: 'Missing required email data in webhook', + success: false, + } + } + + // Construct a minimal user object from webhook data + const webhookUser = { + createdAt: (event.data.created_at || Date.now()) as number, + emailAddresses: emailAddresses.map(email => ({ + emailAddress: email.email_address, + id: email.id, + verification: email.verification, + })), + externalAccounts: [] as Array<{ + provider: string + externalId: string + emailAddress?: string + imageUrl?: string + }>, + firstName: (event.data.first_name || '') as string, + id: event.data.id as string, + imageUrl: (event.data.image_url || '') as string, + lastName: (event.data.last_name || '') as string, + lastSignInAt: (event.data.last_sign_in_at || null) as + | number + | null, + primaryEmailAddressId: primaryEmailId, + publicMetadata: (event.data.public_metadata || {}) as Record< + string, + unknown + >, + updatedAt: (event.data.updated_at || Date.now()) as number, + username: (event.data.username || null) as string | null, + } + + const user = await syncUserToPocketBase( + webhookUser as unknown as User + ) + return { + data: { userId: user.id }, + message: `User ${user.name} (${user.id}) synchronized successfully using webhook data`, + success: true, + } + } catch (webhookError) { + console.error( + 'Error processing user from webhook data:', + webhookError + ) + return { + error: 'WEBHOOK_DATA_PROCESSING_ERROR', + message: `Failed to sync user from webhook data: ${webhookError instanceof Error ? webhookError.message : 'Unknown error'}`, + success: false, + } + } + } + + return { + error: 'USER_SYNC_ERROR', + message: `Failed to sync user: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } + } else if (event.type === 'user.deleted') { + // We don't actually delete users + // This is a design choice to preserve history + return { + data: { userId: event.data.id as string }, + message: `User deletion registered but not processed (data preserved)`, + success: true, + } + } + + // Unknown event type + else { + return { + error: 'UNKNOWN_EVENT_TYPE', + message: `Unhandled webhook event type: ${event.type}`, + success: false, + } + } + } catch (error) { + console.error(`Error processing webhook ${event.type}:`, error) + return { + error: 'WEBHOOK_PROCESSING_ERROR', + message: `Error processing webhook: ${error instanceof Error ? error.message : 'Unknown error'}`, + success: false, + } + } +} diff --git a/src/app/actions/services/getDataUserAndOrg.ts b/src/app/actions/services/getDataUserAndOrg.ts new file mode 100644 index 0000000..9b7ebeb --- /dev/null +++ b/src/app/actions/services/getDataUserAndOrg.ts @@ -0,0 +1,64 @@ +import { createOrUpdateUserByClerkId } from '@/app/actions/services/pocketbase/app_user_service' +import { createOrUpdateOrganizationUserMapping } from '@/app/actions/services/pocketbase/organization_app_user_service' +import { + createOrUpdateOrganizationByClerkId, + findOrganizationByClerkId, +} from '@/app/actions/services/pocketbase/organization_service' +import { auth, currentUser } from '@clerk/nextjs/server' + +interface ClerkUserWithOrg { + id: string + firstName: string | null + lastName: string | null + emailAddresses: Array<{ emailAddress: string }> + organizationMemberships: Array<{ + role: string + organization: { + id: string + name: string + } + }> +} + +export async function getDataUserAndOrg() { + // Get the current organization ID and user from Clerk + const { orgId } = await auth() + const clerkUser = await currentUser() + + if (!orgId || !clerkUser) { + throw new Error('No active organization or user found') + } + + // Cast to our extended type, but with safety checks (we know it's a ClerkUserWithOrg, but, sometime, clerk returns a different type) + const user = clerkUser as unknown as Partial + + // Ensure the user record exists in PocketBase, normally, this should not happen, but, sometimes, it does... + // the middleware should prevent this, but, sometimes, it does not... :) + const pbUser = await createOrUpdateUserByClerkId(user.id!, { + email: user.emailAddresses?.[0]?.emailAddress || '', + metadata: { + createdAt: new Date().toISOString(), + lastActiveAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + name: `${user.firstName || ''} ${user.lastName || ''}`.trim(), + }) + + // Get or create the organization record in PocketBase + let pbOrg = await findOrganizationByClerkId(orgId) + if (!pbOrg) { + // Create the organization in PocketBase with a default name + // since we might not have access to memberships + pbOrg = await createOrUpdateOrganizationByClerkId(orgId, { + name: 'My Organization', + }) + } + + // Ensure the user is properly linked to the organization with a default role + // since we might not have access to the actual role + if (pbOrg) { + await createOrUpdateOrganizationUserMapping(pbUser.id, pbOrg.id, 'member') + } + + return { pbOrg, pbUser } +} diff --git a/src/app/actions/services/pocketbase/README.md b/src/app/actions/services/pocketbase/README.md new file mode 100644 index 0000000..700d6aa --- /dev/null +++ b/src/app/actions/services/pocketbase/README.md @@ -0,0 +1,107 @@ +# PocketBase API Client Architecture + +This directory contains a structured API client for PocketBase, designed to provide type-safe, validated data access with clean abstractions. + +## Directory Structure + +``` +pocketbase/ +├── api_client/ # Core API client components +│ ├── types.ts # TypeScript interfaces for all models +│ ├── schemas.ts # Zod validation schemas +│ ├── client.ts # PocketBase client and utilities +│ ├── base_service.ts # Generic CRUD operations +│ └── index.ts # Re-exports and utilities +├── organization_service.ts # Organization-specific operations +├── app_user_service.ts # User-specific operations +├── equipment_service.ts # Equipment-specific operations +└── index.ts # Re-exports all services +``` + +## Key Features + +1. **Type Safety**: Full TypeScript interfaces for all PocketBase models. +2. **Validation**: Zod schemas for request and response validation. +3. **Error Handling**: Consistent error handling with detailed error messages. +4. **Modularity**: Each collection has its own service with specific methods. +5. **DRY Code**: Common operations are abstracted into the BaseService. +6. **Singleton Pattern**: Services are implemented as singletons for efficient reuse. + +## Usage Examples + +### Finding a User by Clerk ID + +```typescript +import { findUserByClerkId } from '@/app/actions/services/pocketbase' + +const user = await findUserByClerkId('clerk_123') +if (user) { + // User exists in PocketBase + console.log(user.name) +} +``` + +### Creating or Updating an Organization + +```typescript +import { createOrUpdateOrganizationByClerkId } from '@/app/actions/services/pocketbase' + +const org = await createOrUpdateOrganizationByClerkId('clerk_org_123', { + name: 'My Organization', + email: 'org@example.com', + phone: '123-456-7890', + // ... other fields +}) +``` + +### Searching for Equipment + +```typescript +import { searchEquipment } from '@/app/actions/services/pocketbase' + +const items = await searchEquipment(organizationId, 'drill') +console.log(`Found ${items.length} items matching 'drill'`) +``` + +## Implementation Notes + +### BaseService + +The `BaseService` provides generic CRUD operations for any collection: + +- `getById`: Fetch a single record by ID +- `getList`: Get a paginated list of records +- `create`: Create a new record +- `update`: Update an existing record +- `delete`: Delete a record +- `getCount`: Count records matching a filter + +### Service-specific Methods + +Each collection-specific service adds custom methods relevant to that entity: + +- Organization: `findByClerkId`, `createOrUpdateByClerkId` +- AppUser: `findByClerkId`, `linkToOrganization`, `getByOrganization` +- Equipment: `findByQrNfcCode`, `findByOrganization`, `search` + +### Validation + +All data is validated using Zod schemas: + +- Input validation before sending to PocketBase +- Output validation after receiving from PocketBase + +### Error Handling + +Errors are handled consistently across the entire API client: + +- PocketBase errors are converted to a standard format +- Validation errors include detailed information about what failed +- Common error handling with the `handlePocketBaseError` utility + +## Best Practices + +1. Always use exported functions rather than direct service instances +2. Use the specific service methods rather than generic CRUD when possible +3. Handle errors appropriately in your calling code +4. Use the validation utilities to ensure data integrity diff --git a/src/app/actions/services/pocketbase/api_client/base_service.ts b/src/app/actions/services/pocketbase/api_client/base_service.ts new file mode 100644 index 0000000..0659180 --- /dev/null +++ b/src/app/actions/services/pocketbase/api_client/base_service.ts @@ -0,0 +1,197 @@ +/** + * Generic base service for PocketBase CRUD operations + */ + +import { + getPocketBase, + handlePocketBaseError, + CollectionMethodOptions, + defaultCollectionMethodOptions, + validateWithZod, +} from '@/app/actions/services/pocketbase/api_client/client' +import { ListResult, QueryParams } from '@/models/pocketbase' +import { z } from 'zod' + +/** + * Base service class for PocketBase collections + * Provides generic CRUD operations for any collection + */ +export class BaseService { + protected readonly collectionName: string + protected readonly schema: z.ZodType + protected readonly createSchema: z.ZodType + protected readonly updateSchema: z.ZodType + protected readonly listSchema: z.ZodType> + + /** + * Constructor for BaseService + * + * @param collectionName - The name of the PocketBase collection + * @param schema - Zod schema for validating records + * @param createSchema - Zod schema for validating create inputs + * @param updateSchema - Zod schema for validating update inputs + */ + constructor( + collectionName: string, + schema: z.ZodType, + createSchema: z.ZodType, + updateSchema: z.ZodType + ) { + this.collectionName = collectionName + this.schema = schema + this.createSchema = createSchema + this.updateSchema = updateSchema + this.listSchema = z.object({ + items: z.array(this.schema), + page: z.number(), + perPage: z.number(), + totalItems: z.number(), + totalPages: z.number(), + }) + } + + /** + * Get a single record by ID + * + * @param id - The ID of the record to retrieve + * @param options - Optional configuration for the request + * @returns The record + */ + async getById( + id: string, + options: CollectionMethodOptions = defaultCollectionMethodOptions + ): Promise { + try { + const pb = getPocketBase() + const record = await pb.collection(this.collectionName).getOne(id) + + return options.validateOutput === false + ? (record as T) + : validateWithZod(this.schema, record) + } catch (error) { + handlePocketBaseError(error) + } + } + + /** + * Get a list of records + * + * @param params - Query parameters for filtering, sorting, etc. + * @param options - Optional configuration for the request + * @returns A list result containing the records + */ + async getList( + params: QueryParams = {}, + options: CollectionMethodOptions = defaultCollectionMethodOptions + ): Promise> { + try { + const pb = getPocketBase() + const result = await pb + .collection(this.collectionName) + .getList(params.page, params.perPage, { + expand: params.expand, + filter: params.filter, + sort: params.sort, + }) + + return options.validateOutput === false + ? (result as ListResult) + : validateWithZod(this.listSchema, result) + } catch (error) { + handlePocketBaseError(error) + } + } + + /** + * Create a new record + * + * @param data - The data for the new record + * @param options - Optional configuration for the request + * @returns The created record + */ + async create( + data: CreateInput, + options: CollectionMethodOptions = defaultCollectionMethodOptions + ): Promise { + try { + // Validate input data + const validatedData = validateWithZod(this.createSchema, data) + + const pb = getPocketBase() + const record = await pb + .collection(this.collectionName) + .create(validatedData as Record) + + return options.validateOutput === false + ? (record as T) + : validateWithZod(this.schema, record) + } catch (error) { + handlePocketBaseError(error) + } + } + + /** + * Update an existing record + * + * @param id - The ID of the record to update + * @param data - The data to update + * @param options - Optional configuration for the request + * @returns The updated record + */ + async update( + id: string, + data: UpdateInput, + options: CollectionMethodOptions = defaultCollectionMethodOptions + ): Promise { + try { + // Validate input data + const validatedData = validateWithZod(this.updateSchema, data) + + const pb = getPocketBase() + const record = await pb + .collection(this.collectionName) + .update(id, validatedData as Record) + + return options.validateOutput === false + ? (record as T) + : validateWithZod(this.schema, record) + } catch (error) { + handlePocketBaseError(error) + } + } + + /** + * Delete a record + * + * @param id - The ID of the record to delete + * @returns A boolean indicating success + */ + async delete(id: string): Promise { + try { + const pb = getPocketBase() + await pb.collection(this.collectionName).delete(id) + return true + } catch (error) { + handlePocketBaseError(error) + } + } + + /** + * Get a count of records matching a filter + * + * @param filter - The filter to apply + * @returns The count of matching records + */ + async getCount(filter?: string): Promise { + try { + const pb = getPocketBase() + const result = await pb.collection(this.collectionName).getList(1, 1, { + filter, + }) + + return result.totalItems + } catch (error) { + handlePocketBaseError(error) + } + } +} diff --git a/src/app/actions/services/pocketbase/api_client/client.ts b/src/app/actions/services/pocketbase/api_client/client.ts new file mode 100644 index 0000000..0802456 --- /dev/null +++ b/src/app/actions/services/pocketbase/api_client/client.ts @@ -0,0 +1,112 @@ +/** + * PocketBase client for server-side API calls + */ +import PocketBase, { ClientResponseError } from 'pocketbase' +import { cache } from 'react' +import { z } from 'zod' + +/** + * Error class for PocketBase API errors + */ +export class PocketBaseApiError extends Error { + status: number + data?: Record + + constructor(message: string, status: number, data?: Record) { + super(message) + this.name = 'PocketBaseApiError' + this.status = status + this.data = data + } + + /** + * Convert a ClientResponseError to PocketBaseApiError + */ + static fromClientResponseError( + error: ClientResponseError + ): PocketBaseApiError { + return new PocketBaseApiError(error.message, error.status, error.data) + } +} + +/** + * Get a PocketBase instance (cached per request) + * This implementation bypasses cookie auth for now and relies on admin auth + */ +export const getPocketBase = cache(() => { + // Create a new PocketBase instance + const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL) + + const token = process.env.PB_TOKEN_API_ADMIN + if (token) { + pb.authStore.save(token) + } else { + throw new Error('PB_TOKEN_API_ADMIN is not set') + } + + return pb +}) + +/** + * Generic function to handle PocketBase errors + */ +export function handlePocketBaseError(error: unknown): never { + if (error instanceof ClientResponseError) { + throw PocketBaseApiError.fromClientResponseError(error) + } + + if (error instanceof Error) { + throw new PocketBaseApiError(error.message, 500) + } + + throw new PocketBaseApiError('Unknown error occurred', 500) +} + +/** + * Collection names for PocketBase + */ +export const Collections = { + ACTIVITY_LOGS: 'ActivityLog', + APP_USERS: 'AppUser', + ASSIGNMENTS: 'Assignment', + EQUIPMENT: 'Equipment', + IMAGES: 'Images', + ORGANIZATIONS: 'Organization', + PROJECTS: 'Project', +} as const + +/** + * Type-safe collection names + */ +export type CollectionName = (typeof Collections)[keyof typeof Collections] + +/** + * Validate response data with Zod schema + */ +export function validateWithZod(schema: z.ZodType, data: unknown): T { + try { + return schema.parse(data) + } catch (error) { + if (error instanceof z.ZodError) { + console.error('Validation error:', error.format()) + throw new PocketBaseApiError('Data validation failed', 422, { + validationErrors: error.format(), + }) + } + throw error + } +} + +/** + * Type for the collection method options + */ +export interface CollectionMethodOptions { + validateOutput?: boolean +} + +/** + * Default options for collection methods + */ +export const defaultCollectionMethodOptions: CollectionMethodOptions = { + validateOutput: true, +} diff --git a/src/app/actions/services/pocketbase/api_client/index.ts b/src/app/actions/services/pocketbase/api_client/index.ts new file mode 100644 index 0000000..bcd7eb5 --- /dev/null +++ b/src/app/actions/services/pocketbase/api_client/index.ts @@ -0,0 +1,42 @@ +/** + * PocketBase API Client exports + */ + +// Client and utilities +export * from '@/app/actions/services/pocketbase/api_client/client' +export * from '@/app/actions/services/pocketbase/api_client/base_service' + +// Re-export schemas and values from central location except Collections (to avoid conflict) +export { + // Re-export value exports (non-types) + appUserCreateSchema, + appUserSchema, + appUserUpdateSchema, + baseRecordSchema, + createPartialSchema, + createServiceSchemas, + equipmentCreateSchema, + equipmentSchema, + equipmentUpdateSchema, + listResultSchema, + organizationCreateSchema, + organizationSchema, + organizationUpdateSchema, + queryParamsSchema, +} from '@/models/pocketbase' + +// Re-export types separately to avoid 'isolatedModules' error +export type { + AppUser, + AppUserCreateInput, + AppUserUpdateInput, + BaseRecord, + Equipment, + EquipmentCreateInput, + EquipmentUpdateInput, + ListResult, + Organization, + OrganizationCreateInput, + OrganizationUpdateInput, + QueryParams, +} from '@/models/pocketbase' diff --git a/src/app/actions/services/pocketbase/api_client/readme.ai.md b/src/app/actions/services/pocketbase/api_client/readme.ai.md new file mode 100644 index 0000000..16ba9f8 --- /dev/null +++ b/src/app/actions/services/pocketbase/api_client/readme.ai.md @@ -0,0 +1,50 @@ +# PocketBase API Client Overview + +This directory contains the core client for communicating with the PocketBase backend. It provides the foundation for all database operations. + +## Key Files + +- `client.ts` - Core PocketBase client initialization and utilities +- `base_service.ts` - Generic CRUD service for PocketBase collections +- `index.ts` - Exports for client components and services + +## Key Concepts + +- **PocketBase Client**: Initialized and configured connection to PocketBase +- **BaseService**: Generic class with CRUD operations for any collection +- **Error Handling**: Centralized error processing for PocketBase operations +- **Type Safety**: Zod validation for inputs and outputs + +## Best Practices + +- Use the BaseService for implementing entity-specific services +- Handle errors using the centralized error utilities +- Validate inputs and outputs using Zod schemas +- Follow the established patterns for service methods + +## Do's and Don'ts + +### Do + +- Use getPocketBase() to obtain the client instance +- Handle errors with handlePocketBaseError +- Validate data with validateWithZod +- Extend BaseService for new entity services + +### Don't + +- Create multiple PocketBase client instances +- Bypass error handling mechanisms +- Skip validation for inputs or outputs +- Modify core client behavior without careful consideration + +## For AI Assistants + +When working with this directory: + +- Understand that this is the foundational layer for database access +- Note that BaseService provides generic CRUD operations +- Be aware of the error handling patterns in handlePocketBaseError +- Recognize that all entity-specific services extend BaseService +- Remember that Zod is used for validation throughout the system +- Consider that this layer abstracts away direct PocketBase API calls diff --git a/src/app/actions/services/pocketbase/api_client/schemas.ts b/src/app/actions/services/pocketbase/api_client/schemas.ts new file mode 100644 index 0000000..bed285f --- /dev/null +++ b/src/app/actions/services/pocketbase/api_client/schemas.ts @@ -0,0 +1,154 @@ +/** + * Zod schemas for PocketBase data models + * These schemas are used for validation of data going in and out of PocketBase + */ +import { z } from 'zod' + +/** + * Base schema for all PocketBase records + */ +export const baseRecordSchema = z.object({ + collectionId: z.string().optional(), + collectionName: z.string().optional(), + created: z.string().datetime(), + id: z.string(), + updated: z.string().datetime(), +}) + +/** + * Organization schema + */ +export const organizationSchema = baseRecordSchema.extend({ + address: z.string().optional().or(z.literal('')), + clerkId: z.string().optional().or(z.literal('')), + email: z.string().email().optional().or(z.literal('')), + name: z.string(), + phone: z.string().optional().or(z.literal('')), + priceId: z.string().optional().or(z.literal('')), + settings: z.record(z.string(), z.unknown()).optional().default({}), + stripeCustomerId: z.string().optional().or(z.literal('')), + subscriptionId: z.string().optional().or(z.literal('')), + subscriptionStatus: z.string().optional().or(z.literal('')), +}) + +/** + * AppUser schema + */ +export const appUserSchema = baseRecordSchema.extend({ + clerkId: z.string().optional().or(z.literal('')), + email: z.string().email().optional().or(z.literal('')), + emailVisibility: z.boolean().optional().default(true), + isAdmin: z.boolean().optional().default(false), + lastLogin: z.string().datetime().optional().or(z.literal('')), + metadata: z + .object({ + createdAt: z.number().optional(), + externalAccounts: z + .array( + z.object({ + email: z.string().email(), + imageUrl: z.string().url(), + provider: z.string(), + providerUserId: z.string(), + }) + ) + .optional(), + hasCompletedOnboarding: z.boolean().optional(), + lastActiveAt: z.number().optional(), + onboardingCompletedAt: z.string().optional(), + public: z + .object({ + hasCompletedOnboarding: z.boolean(), + onboardingCompletedAt: z.string(), + }) + .optional(), + updatedAt: z.number().optional(), + }) + .optional() + .default({}), + name: z.string().optional().or(z.literal('')), + organizations: z.string().optional().or(z.literal('')), + role: z.string().optional().or(z.literal('')), + verified: z.boolean().optional().default(false), +}) + +/** + * Equipment schema + */ +export const equipmentSchema = baseRecordSchema.extend({ + acquisitionDate: z.string().datetime().optional().or(z.literal('')), + name: z.string(), + notes: z.string().optional().or(z.literal('')), + organization: z.string(), + parentEquipment: z.string().optional().or(z.literal('')), + qrNfcCode: z.string(), + tags: z.string().optional().or(z.literal('')), +}) + +/** + * Project schema + */ +export const projectSchema = baseRecordSchema.extend({ + address: z.string().optional().or(z.literal('')), + endDate: z.string().datetime().optional().or(z.literal('')), + name: z.string(), + notes: z.string().optional().or(z.literal('')), + organization: z.string(), + startDate: z.string().datetime().optional().or(z.literal('')), +}) + +/** + * Assignment schema + */ +export const assignmentSchema = baseRecordSchema.extend({ + assignedToProject: z.string().optional().or(z.literal('')), + assignedToUser: z.string().optional().or(z.literal('')), + endDate: z.string().datetime().optional().or(z.literal('')), + equipment: z.string(), + notes: z.string().optional().or(z.literal('')), + organization: z.string(), + startDate: z.string().datetime(), +}) + +/** + * ActivityLog schema + */ +export const activityLogSchema = baseRecordSchema.extend({ + equipment: z.string().optional().or(z.literal('')), + metadata: z.record(z.string(), z.unknown()).optional().default({}), + organization: z.string().optional().or(z.literal('')), + user: z.string().optional().or(z.literal('')), +}) + +/** + * Image schema + */ +export const imageSchema = baseRecordSchema.extend({ + alt: z.string().optional().or(z.literal('')), + caption: z.string().optional().or(z.literal('')), + image: z.string(), + title: z.string().optional().or(z.literal('')), +}) + +/** + * List result schema (generic) + */ +export const listResultSchema = (itemSchema: T) => + z.object({ + items: z.array(itemSchema), + page: z.number(), + perPage: z.number(), + totalItems: z.number(), + totalPages: z.number(), + }) + +/** + * Query parameters schema + */ +export const queryParamsSchema = z.object({ + expand: z.string().optional(), + filter: z.string().optional(), + page: z.number().optional(), + perPage: z.number().optional(), + sort: z.string().optional(), +}) diff --git a/src/app/actions/services/pocketbase/api_client/types.ts b/src/app/actions/services/pocketbase/api_client/types.ts new file mode 100644 index 0000000..a53d27a --- /dev/null +++ b/src/app/actions/services/pocketbase/api_client/types.ts @@ -0,0 +1,143 @@ +/** + * Core types for PocketBase data models + * These types represent the schema of our database collections + */ + +/** + * Base type for all PocketBase records + */ +export interface BaseRecord { + id: string + created: string + updated: string + collectionId?: string + collectionName?: string +} + +/** + * Organization record + */ +export interface Organization extends BaseRecord { + name: string + email: string | '' + phone: string | '' + address: string | '' + settings: Record + clerkId: string + stripeCustomerId: string | '' + subscriptionId: string | '' + subscriptionStatus: string | '' + priceId: string | '' +} + +/** + * AppUser record + */ +export interface AppUser extends BaseRecord { + email: string | '' + emailVisibility: boolean + verified: boolean + name: string | '' + role: string | '' + isAdmin: boolean + lastLogin: string | '' + clerkId: string + organizations: string | '' + metadata: { + createdAt: number + externalAccounts?: Array<{ + email: string + imageUrl: string + provider: string + providerUserId: string + }> + hasCompletedOnboarding?: boolean + lastActiveAt?: number + onboardingCompletedAt?: string + public?: { + hasCompletedOnboarding: boolean + onboardingCompletedAt: string + } + updatedAt?: number + } +} + +/** + * Equipment record + */ +export interface Equipment extends BaseRecord { + organization: string + name: string + qrNfcCode: string + tags: string | '' + notes: string | '' + acquisitionDate: string | '' + parentEquipment?: string | '' +} + +/** + * Project record + */ +export interface Project extends BaseRecord { + name: string + address: string | '' + notes: string | '' + startDate: string | '' + endDate: string | '' + organization: string +} + +/** + * Assignment record + */ +export interface Assignment extends BaseRecord { + organization: string + equipment: string + assignedToUser?: string | '' + assignedToProject?: string | '' + startDate: string + endDate: string | '' + notes: string | '' +} + +/** + * ActivityLog record + */ +export interface ActivityLog extends BaseRecord { + organization?: string | '' + user?: string | '' + equipment?: string | '' + metadata: Record +} + +/** + * Image record + */ +export interface Image extends BaseRecord { + title: string | '' + alt: string | '' + caption: string | '' + image: string +} + +/** + * PocketBase response types + */ +export interface ListResult { + page: number + perPage: number + totalItems: number + totalPages: number + items: T[] +} + +/** + * Generic query parameters for list operations + */ +export interface QueryParams { + page?: number + perPage?: number + sort?: string + filter?: string + expand?: string +} diff --git a/src/app/actions/services/pocketbase/app_user_service.ts b/src/app/actions/services/pocketbase/app_user_service.ts new file mode 100644 index 0000000..cd6d718 --- /dev/null +++ b/src/app/actions/services/pocketbase/app_user_service.ts @@ -0,0 +1,216 @@ +import { BaseService } from '@/app/actions/services/pocketbase/api_client' +import { getPocketBase } from '@/app/actions/services/pocketbase/api_client/client' +import { + AppUser, + AppUserCreateInput, + AppUserUpdateInput, + Collections, + appUserCreateSchema, + appUserSchema, + appUserUpdateSchema, +} from '@/models/pocketbase' + +// Re-export types for convenience +export type { AppUser, AppUserCreateInput, AppUserUpdateInput } + +/** + * Service for AppUser-related operations + */ +export class AppUserService extends BaseService< + AppUser, + AppUserCreateInput, + AppUserUpdateInput +> { + constructor() { + super( + Collections.APP_USERS, + // @ts-expect-error - Types are compatible but TypeScript cannot verify it [ :) ] + appUserSchema, + appUserCreateSchema, + appUserUpdateSchema + ) + } + + /** + * Find a user by Clerk ID + * + * @param clerkId - The Clerk user ID + * @returns The user or null if not found + */ + async findByClerkId(clerkId: string): Promise { + try { + // Use getPocketBase directly to avoid validation issues + const pb = getPocketBase() + const records = await pb.collection(this.collectionName).getFullList({ + filter: `clerkId="${clerkId}"`, + }) + + if (records.length === 0) { + return null + } + + // Clean and normalize the response data + const record = records[0] + + // Fix organizations field if it's an array + if (Array.isArray(record.organizations)) { + record.organizations = '' + } + + return record as unknown as AppUser + } catch (error) { + console.error('Error finding user by clerkId:', error) + return null + } + } + + /** + * Check if a user with the given Clerk ID exists + * + * @param clerkId - The Clerk user ID + * @returns True if the user exists + */ + async existsByClerkId(clerkId: string): Promise { + try { + const count = await this.getCount(`clerkId = "${clerkId}"`) + return count > 0 + } catch (error) { + console.error('Error checking if user exists by clerkId:', error) + return false + } + } + + /** + * Create or update a user by Clerk ID + * + * @param clerkId - The Clerk user ID + * @param data - The user data + * @returns The created or updated user + */ + async createOrUpdateByClerkId( + clerkId: string, + data: Omit + ): Promise { + try { + const existing = await this.findByClerkId(clerkId) + + if (existing) { + // When updating, ensure we don't overwrite organizations field with an empty string + // if the user already has organizations + const updateData = { + ...data, + clerkId, + } as AppUserUpdateInput + + return this.update(existing.id, updateData, { validateOutput: false }) + } + + // For new users, make sure organizations is a string + const createData = { + ...data, + clerkId, + organizations: data.organizations || '', + } as AppUserCreateInput + + return this.create(createData, { validateOutput: false }) + } catch (error) { + console.error('Error in createOrUpdateByClerkId:', error) + throw error + } + } + + /** + * Link a user to an organization + * + * @param userId - The user ID + * @param organizationId - The organization ID + * @param role - The user's role in the organization + * @returns The updated user + */ + async linkToOrganization( + userId: string, + organizationId: string, + role: string = 'member' + ): Promise { + return this.update(userId, { + organizations: organizationId, + role, + }) + } + + /** + * Get all users in an organization + * + * @param organizationId - The organization ID + * @returns List of users in the organization + */ + async getByOrganization(organizationId: string): Promise { + try { + const result = await this.getList({ + filter: `organizations = "${organizationId}"`, + }) + + return result.items + } catch (error) { + console.error('Error getting users by organization:', error) + return [] + } + } +} + +// Singleton instance +let appUserServiceInstance: AppUserService | null = null + +/** + * Get the AppUserService instance + * + * @returns The AppUserService instance + */ +export function getAppUserService(): AppUserService { + if (!appUserServiceInstance) { + appUserServiceInstance = new AppUserService() + } + return appUserServiceInstance +} + +/** + * Find a user by Clerk ID + * + * @param clerkId - The Clerk user ID + * @returns The user or null if not found + */ +export async function findUserByClerkId( + clerkId: string +): Promise { + return getAppUserService().findByClerkId(clerkId) +} + +/** + * Create or update a user by Clerk ID + * + * @param clerkId - The Clerk user ID + * @param data - The user data + * @returns The created or updated user + */ +export async function createOrUpdateUserByClerkId( + clerkId: string, + data: Omit +): Promise { + return getAppUserService().createOrUpdateByClerkId(clerkId, data) +} + +/** + * Link a user to an organization + * + * @param userId - The user ID + * @param organizationId - The organization ID + * @param role - The user's role in the organization + * @returns The updated user + */ +export async function linkUserToOrganization( + userId: string, + organizationId: string, + role: string = 'member' +): Promise { + return getAppUserService().linkToOrganization(userId, organizationId, role) +} diff --git a/src/app/actions/services/pocketbase/assignmentService.ts b/src/app/actions/services/pocketbase/assignmentService.ts deleted file mode 100644 index 69dee93..0000000 --- a/src/app/actions/services/pocketbase/assignmentService.ts +++ /dev/null @@ -1,217 +0,0 @@ -'use server'; - -import { getPocketBase, handlePocketBaseError } from './baseService'; -import { Assignment, ListOptions, ListResult } from './types'; - -/** - * Get a single assignment by ID - */ -export async function getAssignment(id: string): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - return await pb.collection('assignments').getOne(id); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.getAssignment'); - } -} - -/** - * Get assignments list with pagination - */ -export async function getAssignmentsList(options: ListOptions = {}): Promise> { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - const { page = 1, perPage = 30, ...rest } = options; - return await pb.collection('assignments').getList(page, perPage, rest); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.getAssignmentsList'); - } -} - -/** - * Get active assignments for an organization - * Active assignments have startDate ≤ current date and no endDate or endDate ≥ current date - */ -export async function getActiveAssignments(organizationId: string): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - const now = new Date().toISOString(); - - try { - return await pb.collection('assignments').getFullList({ - expand: 'equipment,assignedToUser,assignedToProject', - filter: pb.filter( - 'organization = {:orgId} && startDate <= {:now} && (endDate = "" || endDate >= {:now})', - { now, orgId: organizationId } - ), - sort: '-created', - }); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.getActiveAssignments'); - } -} - -/** - * Get current assignment for a specific equipment - */ -export async function getCurrentEquipmentAssignment(equipmentId: string): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - const now = new Date().toISOString(); - - try { - const assignments = await pb.collection('assignments').getList(1, 1, { - expand: 'equipment,assignedToUser,assignedToProject', - filter: pb.filter( - 'equipment = {:equipId} && startDate <= {:now} && (endDate = "" || endDate >= {:now})', - { equipId: equipmentId, now } - ), - sort: '-created', - }); - - return assignments.items.length > 0 ? assignments.items[0] : null; - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.getCurrentEquipmentAssignment'); - } -} - -/** - * Get assignments for a user - */ -export async function getUserAssignments(userId: string): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - return await pb.collection('assignments').getFullList({ - expand: 'equipment,assignedToProject', - filter: `assignedToUser="${userId}"`, - sort: '-created', - }); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.getUserAssignments'); - } -} - -/** - * Get assignments for a project - */ -export async function getProjectAssignments(projectId: string): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - return await pb.collection('assignments').getFullList({ - expand: 'equipment,assignedToUser', - filter: `assignedToProject="${projectId}"`, - sort: '-created', - }); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.getProjectAssignments'); - } -} - -/** - * Create a new assignment - */ -export async function createAssignment(data: Partial): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - return await pb.collection('assignments').create(data); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.createAssignment'); - } -} - -/** - * Update an assignment - */ -export async function updateAssignment(id: string, data: Partial): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - return await pb.collection('assignments').update(id, data); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.updateAssignment'); - } -} - -/** - * Delete an assignment - */ -export async function deleteAssignment(id: string): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - await pb.collection('assignments').delete(id); - return true; - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.deleteAssignment'); - } -} - -/** - * Complete an assignment by setting its end date to now - */ -export async function completeAssignment(id: string): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - return await pb.collection('assignments').update(id, { - endDate: new Date().toISOString(), - }); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.completeAssignment'); - } -} - -/** - * Get assignment history for an equipment - */ -export async function getEquipmentAssignmentHistory(equipmentId: string): Promise { - const pb = await getPocketBase(); - if (!pb) { - throw new Error('Failed to connect to PocketBase'); - } - - try { - return await pb.collection('assignments').getFullList({ - expand: 'assignedToUser,assignedToProject', - filter: `equipment=`${equipmentId}``, - sort: '-startDate', - }); - } catch (error) { - return handlePocketBaseError(error, 'AssignmentService.getEquipmentAssignmentHistory'); - } -} \ No newline at end of file diff --git a/src/app/actions/services/pocketbase/baseService.ts b/src/app/actions/services/pocketbase/baseService.ts deleted file mode 100644 index df0258c..0000000 --- a/src/app/actions/services/pocketbase/baseService.ts +++ /dev/null @@ -1,55 +0,0 @@ -import 'server-only' -import PocketBase from 'pocketbase' - -// Singleton pattern for PocketBase instance -let instance: PocketBase | null = null - -/** - * Initialize and authenticate with PocketBase - * Uses server-side authentication with an admin token - * - * @returns {Promise} Authenticated PocketBase instance or null if authentication fails - */ -export const getPocketBase = async (): Promise => { - // Return existing instance if valid - if (instance?.authStore?.isValid) { - return instance - } - - // Get credentials from environment variables - const token = process.env.PB_USER_TOKEN - const url = process.env.PB_SERVER_URL - - if (!token || !url) { - console.error('Missing PocketBase credentials in environment variables') - return null - } - - // Create new PocketBase instance - instance = new PocketBase(url) - instance.authStore.save(token, null) - instance.autoCancellation(false) - - return instance -} - -/** - * Error handler for PocketBase operations - * @param error The caught error - * @param context Optional context information for better error reporting - */ -export const handlePocketBaseError = ( - error: unknown, - context?: string -): never => { - const contextMsg = context ? ` [${context}]` : '' - console.error(`PocketBase error${contextMsg}:`, error) - - if (error instanceof Error) { - throw new Error( - `PocketBase operation failed${contextMsg}: ${error.message}` - ) - } - - throw new Error(`Unknown PocketBase error${contextMsg}`) -} diff --git a/src/app/actions/services/pocketbase/base_service_fix.ts b/src/app/actions/services/pocketbase/base_service_fix.ts new file mode 100644 index 0000000..8b1f8cd --- /dev/null +++ b/src/app/actions/services/pocketbase/base_service_fix.ts @@ -0,0 +1,13 @@ +/** + * Utility function to fix type compatibility issues + * This function is a workaround for TypeScript type compatibility issues + * between Zod schemas and TypeScript interfaces + * + * @param schema The Zod schema to be fixed + * @returns The same schema with fixed type compatibility + */ +export function fixSchemaType(schema: any): any { + // Simply pass through the schema but with a different type signature + // This is a type assertion hack to make TypeScript happy + return schema +} diff --git a/src/app/actions/services/pocketbase/equipmentService.ts b/src/app/actions/services/pocketbase/equipmentService.ts deleted file mode 100644 index 596db8b..0000000 --- a/src/app/actions/services/pocketbase/equipmentService.ts +++ /dev/null @@ -1,218 +0,0 @@ -'use server' - -import { getPocketBase, handlePocketBaseError } from './baseService' -import { Equipment, ListOptions, ListResult } from './types' - -/** - * Get a single equipment item by ID - */ -export async function getEquipment(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('equipment').getOne(id) - } catch (error) { - return handlePocketBaseError(error, 'EquipmentService.getEquipment') - } -} - -/** - * Get equipment by QR/NFC code - */ -export async function getEquipmentByCode( - qrNfcCode: string -): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb - .collection('equipment') - .getFirstListItem(`qrNfcCode="${qrNfcCode}"`) - } catch (error) { - return handlePocketBaseError(error, 'EquipmentService.getEquipmentByCode') - } -} - -/** - * Get equipment list with pagination - */ -export async function getEquipmentList( - options: ListOptions = {} -): Promise> { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - const { page = 1, perPage = 30, ...rest } = options - return await pb.collection('equipment').getList(page, perPage, rest) - } catch (error) { - return handlePocketBaseError(error, 'EquipmentService.getEquipmentList') - } -} - -/** - * Get all equipment for an organization - */ -export async function getOrganizationEquipment( - organizationId: string -): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('equipment').getFullList({ - filter: `organization="${organizationId}"`, - sort: 'name', - }) - } catch (error) { - return handlePocketBaseError( - error, - 'EquipmentService.getOrganizationEquipment' - ) - } -} - -/** - * Create a new equipment item - */ -export async function createEquipment( - data: Partial -): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('equipment').create(data) - } catch (error) { - return handlePocketBaseError(error, 'EquipmentService.createEquipment') - } -} - -/** - * Update an equipment item - */ -export async function updateEquipment( - id: string, - data: Partial -): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('equipment').update(id, data) - } catch (error) { - return handlePocketBaseError(error, 'EquipmentService.updateEquipment') - } -} - -/** - * Delete an equipment item - */ -export async function deleteEquipment(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - await pb.collection('equipment').delete(id) - return true - } catch (error) { - return handlePocketBaseError(error, 'EquipmentService.deleteEquipment') - } -} - -/** - * Get child equipment (items that have this equipment as parent) - */ -export async function getChildEquipment( - parentId: string -): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('equipment').getFullList({ - filter: `parentEquipment="${parentId}"`, - sort: 'name', - }) - } catch (error) { - return handlePocketBaseError(error, 'EquipmentService.getChildEquipment') - } -} - -/** - * Generate a unique QR/NFC code - */ -export async function generateUniqueCode(): Promise { - // Generate a random alphanumeric code - const prefix = 'EQ' - const randomPart = Math.random().toString(36).substring(2, 10).toUpperCase() - return `${prefix}-${randomPart}` -} - -/** - * Get equipment count for an organization - */ -export async function getEquipmentCount( - organizationId: string -): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - const result = await pb.collection('equipment').getList(1, 1, { - filter: `organization="${organizationId}"`, - skipTotal: false, - }) - return result.totalItems - } catch (error) { - return handlePocketBaseError(error, 'EquipmentService.getEquipmentCount') - } -} - -/** - * Search equipment by name or tag - */ -export async function searchEquipment( - organizationId: string, - query: string -): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('equipment').getFullList({ - filter: pb.filter( - 'organization = {:orgId} && (name ~ {:query} || tags ~ {:query} || qrNfcCode = {:query})', - { - orgId: organizationId, - query: query, - } - ), - sort: 'name', - }) - } catch (error) { - return handlePocketBaseError(error, 'EquipmentService.searchEquipment') - } -} diff --git a/src/app/actions/services/pocketbase/equipment_service.ts b/src/app/actions/services/pocketbase/equipment_service.ts new file mode 100644 index 0000000..546c67b --- /dev/null +++ b/src/app/actions/services/pocketbase/equipment_service.ts @@ -0,0 +1,224 @@ +import { BaseService } from '@/app/actions/services/pocketbase/api_client' +import { + Collections, + Equipment, + EquipmentCreateInput, + EquipmentUpdateInput, + equipmentCreateSchema, + equipmentSchema, + equipmentUpdateSchema, +} from '@/models/pocketbase' + +// Re-export types for convenience +export type { Equipment, EquipmentCreateInput, EquipmentUpdateInput } + +/** + * Service for equipment-related operations + * Provides CRUD and search functionality for equipment records + */ +export class EquipmentService extends BaseService< + Equipment, + EquipmentCreateInput, + EquipmentUpdateInput +> { + constructor() { + super( + Collections.EQUIPMENT, + // @eslint-disable-next-line @typescript-eslint/ban-ts-comment @ts-expect-error - Types are compatible but TypeScript cannot verify it [ :) ] + equipmentSchema, + equipmentCreateSchema, + equipmentUpdateSchema + ) + } + + /** + * Find equipment by QR/NFC code + * + * @param qrNfcCode - The QR/NFC code to search for + * @returns The equipment or null if not found + */ + async findByQrNfcCode(qrNfcCode: string): Promise { + try { + const result = await this.getList({ + filter: `qrNfcCode = "${qrNfcCode}"`, + }) + + return result.items.length > 0 ? result.items[0] : null + } catch (error) { + console.error('Error finding equipment by QR/NFC code:', error) + return null + } + } + + /** + * Find all equipment for an organization + * + * @param organizationId - The organization ID + * @returns Array of equipment + */ + async findByOrganization(organizationId: string): Promise { + try { + const result = await this.getList({ + filter: `organization = "${organizationId}"`, + }) + + return result.items + } catch (error) { + console.error('Error finding equipment by organization:', error) + return [] + } + } + + /** + * Find equipment by parent equipment ID + * + * @param parentEquipmentId - The parent equipment ID + * @returns Array of child equipment + */ + async findByParentEquipment(parentEquipmentId: string): Promise { + try { + const result = await this.getList({ + filter: `parentEquipment = "${parentEquipmentId}"`, + }) + + return result.items + } catch (error) { + console.error('Error finding equipment by parent equipment:', error) + return [] + } + } + + /** + * Search equipment by name, tags or notes + * + * @param organizationId - The organization ID + * @param searchTerm - Term to search for + * @returns Array of matching equipment + */ + async search( + organizationId: string, + searchTerm: string + ): Promise { + try { + // Clean up search term for use in filter + const cleanTerm = searchTerm.trim().replace(/"/g, '\\"') + + // Construct the filter as a string, properly escaping quotation marks + const filter = + 'organization = "' + + organizationId + + '" && (name ~ "' + + cleanTerm + + '" || tags ~ "' + + cleanTerm + + '" || notes ~ "' + + cleanTerm + + '")' + + const result = await this.getList({ + filter, + }) + + return result.items + } catch (error) { + console.error('Error searching equipment:', error) + return [] + } + } + + /** + * Generate a new unique QR/NFC code + * + * @returns A new unique code + */ + async generateUniqueCode(): Promise { + // Generate a random alphanumeric code + const generateCode = () => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + let result = '' + for (let i = 0; i < 10; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return result + } + + // Keep generating until we find a unique one + let isUnique = false + let code = '' + + while (!isUnique) { + code = generateCode() + const existing = await this.findByQrNfcCode(code) + isUnique = existing === null + } + + return code + } +} + +// Singleton instance +let equipmentServiceInstance: EquipmentService | null = null + +/** + * Get the EquipmentService instance + * Uses singleton pattern to ensure only one instance exists + * + * @returns The EquipmentService instance + */ +export function getEquipmentService(): EquipmentService { + if (!equipmentServiceInstance) { + equipmentServiceInstance = new EquipmentService() + } + return equipmentServiceInstance +} + +/** + * Find equipment by QR/NFC code + * Convenience function that uses the EquipmentService + * + * @param qrNfcCode - The QR/NFC code + * @returns The equipment or null if not found + */ +export async function findEquipmentByQrNfcCode( + qrNfcCode: string +): Promise { + return getEquipmentService().findByQrNfcCode(qrNfcCode) +} + +/** + * Find all equipment for an organization + * Convenience function that uses the EquipmentService + * + * @param organizationId - The organization ID + * @returns Array of equipment + */ +export async function findEquipmentByOrganization( + organizationId: string +): Promise { + return getEquipmentService().findByOrganization(organizationId) +} + +/** + * Search equipment by name, tags or notes + * Convenience function that uses the EquipmentService + * + * @param organizationId - The organization ID + * @param searchTerm - Term to search for + * @returns Array of matching equipment + */ +export async function searchEquipment( + organizationId: string, + searchTerm: string +): Promise { + return getEquipmentService().search(organizationId, searchTerm) +} + +/** + * Generate a new unique QR/NFC code + * Convenience function that uses the EquipmentService + * + * @returns A new unique code + */ +export async function generateUniqueEquipmentCode(): Promise { + return getEquipmentService().generateUniqueCode() +} diff --git a/src/app/actions/services/pocketbase/index.ts b/src/app/actions/services/pocketbase/index.ts new file mode 100644 index 0000000..bef76c7 --- /dev/null +++ b/src/app/actions/services/pocketbase/index.ts @@ -0,0 +1,40 @@ +/** + * PocketBase Services + * Central export point for all PocketBase services and utilities + */ +import { z } from 'zod' + +// Export API client core +export * from '@/app/actions/services/pocketbase/api_client' + +// Export individual services +export * from '@/app/actions/services/pocketbase/organization_service' +export * from '@/app/actions/services/pocketbase/app_user_service' +export * from '@/app/actions/services/pocketbase/equipment_service' + +/** + * Validate organization ID format + * @param id - The ID to validate + * @returns Whether the ID is valid + */ +export function isValidOrganizationId(id: unknown): boolean { + return z.string().length(15).safeParse(id).success +} + +/** + * Validate user ID format + * @param id - The ID to validate + * @returns Whether the ID is valid + */ +export function isValidUserId(id: unknown): boolean { + return z.string().length(15).safeParse(id).success +} + +/** + * Validate ID format for any record + * @param id - The ID to validate + * @returns Whether the ID is valid + */ +export function isValidRecordId(id: unknown): boolean { + return z.string().length(15).safeParse(id).success +} diff --git a/src/app/actions/services/pocketbase/organizationService.ts b/src/app/actions/services/pocketbase/organizationService.ts deleted file mode 100644 index af7644f..0000000 --- a/src/app/actions/services/pocketbase/organizationService.ts +++ /dev/null @@ -1,155 +0,0 @@ -'use server' - -import { getPocketBase, handlePocketBaseError } from './baseService' -import { ListOptions, ListResult, Organization } from './types' - -/** - * Get a single organization by ID - */ -export async function getOrganization(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('organizations').getOne(id) - } catch (error) { - return handlePocketBaseError(error, 'OrganizationService.getOrganization') - } -} - -/** - * Get an organization by Clerk ID - */ -export async function getOrganizationByClerkId( - clerkId: string -): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb - .collection('organizations') - .getFirstListItem(`clerkId="${clerkId}"`) - } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService.getOrganizationByClerkId' - ) - } -} - -/** - * Get organizations list with pagination - */ -export async function getOrganizationsList( - options: ListOptions = {} -): Promise> { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - const { page = 1, perPage = 30, ...rest } = options - return await pb.collection('organizations').getList(page, perPage, rest) - } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService.getOrganizationsList' - ) - } -} - -/** - * Create a new organization - */ -export async function createOrganization( - data: Partial -): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('organizations').create(data) - } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService.createOrganization' - ) - } -} - -/** - * Update an organization - */ -export async function updateOrganization( - id: string, - data: Partial -): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('organizations').update(id, data) - } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService.updateOrganization' - ) - } -} - -/** - * Delete an organization - */ -export async function deleteOrganization(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - await pb.collection('organizations').delete(id) - return true - } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService.deleteOrganization' - ) - } -} - -/** - * Update organization subscription details - */ -export async function updateSubscription( - id: string, - subscriptionData: { - stripeCustomerId?: string - subscriptionId?: string - subscriptionStatus?: string - priceId?: string - } -): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('organizations').update(id, subscriptionData) - } catch (error) { - return handlePocketBaseError( - error, - 'OrganizationService.updateSubscription' - ) - } -} diff --git a/src/app/actions/services/pocketbase/organization_app_user_service.ts b/src/app/actions/services/pocketbase/organization_app_user_service.ts new file mode 100644 index 0000000..032ae4f --- /dev/null +++ b/src/app/actions/services/pocketbase/organization_app_user_service.ts @@ -0,0 +1,318 @@ +import { BaseService } from '@/app/actions/services/pocketbase/api_client' +import { + Collections, + OrganizationAppUser, + OrganizationAppUserCreateInput, + OrganizationAppUserUpdateInput, + organizationAppUserCreateSchema, + organizationAppUserSchema, + organizationAppUserUpdateSchema, +} from '@/models/pocketbase' + +// Re-export types for convenience +export type { + OrganizationAppUser, + OrganizationAppUserCreateInput, + OrganizationAppUserUpdateInput, +} + +/** + * Service for OrganizationAppUser-related operations + */ +export class OrganizationAppUserService extends BaseService< + OrganizationAppUser, + OrganizationAppUserCreateInput, + OrganizationAppUserUpdateInput +> { + constructor() { + super( + Collections.ORGANIZATION_APP_USERS, + // @ts-expect-error - Types are compatible but TypeScript cannot verify it [ :) ] + organizationAppUserSchema, + organizationAppUserCreateSchema, + organizationAppUserUpdateSchema + ) + } + + /** + * Find organization-user mappings by user ID + * + * @param appUserId - The AppUser ID + * @returns The organization-user mappings or empty array if not found + */ + async findByAppUserId(appUserId: string): Promise { + try { + const result = await this.getList({ + filter: `appUser = "${appUserId}"`, + }) + + return result.items + } catch (error) { + console.error( + 'Error finding organization-user mappings by appUserId:', + error + ) + return [] + } + } + + /** + * Find organization-user mappings by organization ID + * + * @param organizationId - The Organization ID + * @returns The organization-user mappings or empty array if not found + */ + async findByOrganizationId( + organizationId: string + ): Promise { + try { + const result = await this.getList({ + filter: `organization = "${organizationId}"`, + }) + + return result.items + } catch (error) { + console.error( + 'Error finding organization-user mappings by organizationId:', + error + ) + return [] + } + } + + /** + * Find a specific organization-user mapping + * + * @param appUserId - The AppUser ID + * @param organizationId - The Organization ID + * @returns The organization-user mapping or null if not found + */ + async findByAppUserAndOrganization( + appUserId: string, + organizationId: string + ): Promise { + try { + const result = await this.getList({ + filter: `appUser = "${appUserId}" && organization = "${organizationId}"`, + }) + + return result.items.length > 0 ? result.items[0] : null + } catch (error) { + console.error('Error finding organization-user mapping:', error) + return null + } + } + + /** + * Check if a specific organization-user mapping exists + * + * @param appUserId - The AppUser ID + * @param organizationId - The Organization ID + * @returns True if the mapping exists + */ + async exists(appUserId: string, organizationId: string): Promise { + try { + const count = await this.getCount( + `appUser = "${appUserId}" && organization = "${organizationId}"` + ) + return count > 0 + } catch (error) { + console.error( + 'Error checking if organization-user mapping exists:', + error + ) + return false + } + } + + /** + * Create or update an organization-user mapping + * + * @param appUserId - The AppUser ID + * @param organizationId - The Organization ID + * @param role - The user's role in the organization + * @returns The created or updated mapping + */ + async createOrUpdate( + appUserId: string, + organizationId: string, + role: string = 'member' + ): Promise { + try { + const existing = await this.findByAppUserAndOrganization( + appUserId, + organizationId + ) + + if (existing) { + return this.update(existing.id, { role }, { validateOutput: false }) + } + + return this.create( + { + appUser: appUserId, + organization: organizationId, + role, + }, + { validateOutput: false } + ) + } catch (error) { + // If we get a "record already exists" error, try to find it and update it + if ( + error instanceof Error && + error.message.includes('unique constraint') + ) { + console.warn( + `Unique constraint error for user ${appUserId} in org ${organizationId}. Retrying...` + ) + // Wait a moment for eventual consistency + await new Promise(resolve => setTimeout(resolve, 500)) + + // Try to find the existing record again + const retryExisting = await this.findByAppUserAndOrganization( + appUserId, + organizationId + ) + + if (retryExisting) { + return this.update( + retryExisting.id, + { role }, + { validateOutput: false } + ) + } + } + + console.error('Error creating/updating organization-user mapping:', error) + throw error + } + } + + /** + * Delete an organization-user mapping + * + * @param appUserId - The AppUser ID + * @param organizationId - The Organization ID + * @returns True if the mapping was deleted + */ + async deleteMapping( + appUserId: string, + organizationId: string + ): Promise { + try { + const mapping = await this.findByAppUserAndOrganization( + appUserId, + organizationId + ) + + if (mapping) { + await this.delete(mapping.id) + return true + } + + return false + } catch (error) { + console.error('Error deleting organization-user mapping:', error) + return false + } + } + + /** + * Get all user roles for an organization + * + * @param organizationId - The Organization ID + * @returns A map of user IDs to roles + */ + async getUserRolesForOrganization( + organizationId: string + ): Promise> { + const mappings = await this.findByOrganizationId(organizationId) + const userRoles = new Map() + + for (const mapping of mappings) { + userRoles.set(mapping.appUser, mapping.role) + } + + return userRoles + } + + /** + * Get all organization roles for a user + * + * @param appUserId - The AppUser ID + * @returns A map of organization IDs to roles + */ + async getOrganizationRolesForUser( + appUserId: string + ): Promise> { + const mappings = await this.findByAppUserId(appUserId) + const orgRoles = new Map() + + for (const mapping of mappings) { + orgRoles.set(mapping.organization, mapping.role) + } + + return orgRoles + } +} + +// Singleton instance +let organizationAppUserServiceInstance: OrganizationAppUserService | null = null + +/** + * Get the OrganizationAppUserService instance + * + * @returns The OrganizationAppUserService instance + */ +export function getOrganizationAppUserService(): OrganizationAppUserService { + if (!organizationAppUserServiceInstance) { + organizationAppUserServiceInstance = new OrganizationAppUserService() + } + return organizationAppUserServiceInstance +} + +/** + * Create or update an organization-user mapping + * + * @param appUserId - The AppUser ID + * @param organizationId - The Organization ID + * @param role - The user's role in the organization + * @returns The created or updated mapping + */ +export async function createOrUpdateOrganizationUserMapping( + appUserId: string, + organizationId: string, + role: string = 'member' +): Promise { + return getOrganizationAppUserService().createOrUpdate( + appUserId, + organizationId, + role + ) +} + +/** + * Get all user roles for an organization + * + * @param organizationId - The Organization ID + * @returns A map of user IDs to roles + */ +export async function getUserRolesForOrganization( + organizationId: string +): Promise> { + return getOrganizationAppUserService().getUserRolesForOrganization( + organizationId + ) +} + +/** + * Get all organization roles for a user + * + * @param appUserId - The AppUser ID + * @returns A map of organization IDs to roles + */ +export async function getOrganizationRolesForUser( + appUserId: string +): Promise> { + return getOrganizationAppUserService().getOrganizationRolesForUser(appUserId) +} diff --git a/src/app/actions/services/pocketbase/organization_service.ts b/src/app/actions/services/pocketbase/organization_service.ts new file mode 100644 index 0000000..eea12ee --- /dev/null +++ b/src/app/actions/services/pocketbase/organization_service.ts @@ -0,0 +1,134 @@ +import { BaseService } from '@/app/actions/services/pocketbase/api_client' +import { + Collections, + Organization, + OrganizationCreateInput, + OrganizationUpdateInput, + organizationCreateSchema, + organizationSchema, + organizationUpdateSchema, +} from '@/models/pocketbase' + +// Re-export types for convenience +export type { Organization, OrganizationCreateInput, OrganizationUpdateInput } + +/** + * Service for Organization-related operations + */ +export class OrganizationService extends BaseService< + Organization, + OrganizationCreateInput, + OrganizationUpdateInput +> { + constructor() { + super( + Collections.ORGANIZATIONS, + // @ts-expect-error - Types are compatible but TypeScript cannot verify it [ :) ] + organizationSchema, + organizationCreateSchema, + organizationUpdateSchema + ) + } + + /** + * Find an organization by Clerk ID + * + * @param clerkId - The Clerk organization ID + * @returns The organization or null if not found + */ + async findByClerkId(clerkId: string): Promise { + try { + const result = await this.getList({ + filter: `clerkId = "${clerkId}"`, + }) + + return result.items.length > 0 ? result.items[0] : null + } catch (error) { + console.error('Error finding organization by clerkId:', error) + return null + } + } + + /** + * Check if an organization with the given Clerk ID exists + * + * @param clerkId - The Clerk organization ID + * @returns True if the organization exists + */ + async existsByClerkId(clerkId: string): Promise { + try { + const count = await this.getCount(`clerkId = "${clerkId}"`) + return count > 0 + } catch (error) { + console.error('Error checking if organization exists by clerkId:', error) + return false + } + } + + /** + * Create or update an organization by Clerk ID + * + * @param clerkId - The Clerk organization ID + * @param data - The organization data + * @returns The created or updated organization + */ + async createOrUpdateByClerkId( + clerkId: string, + data: Omit + ): Promise { + const existing = await this.findByClerkId(clerkId) + + if (existing) { + return this.update(existing.id, { + ...data, + clerkId, + }) + } + + return this.create({ + ...data, + clerkId, + }) + } +} + +// Singleton instance +let organizationServiceInstance: OrganizationService | null = null + +/** + * Get the OrganizationService instance + * + * @returns The OrganizationService instance + */ +export function getOrganizationService(): OrganizationService { + if (!organizationServiceInstance) { + organizationServiceInstance = new OrganizationService() + } + return organizationServiceInstance +} + +/** + * Find an organization by Clerk ID + * + * @param clerkId - The Clerk organization ID + * @returns The organization or null if not found + */ +export async function findOrganizationByClerkId( + clerkId: string +): Promise { + return getOrganizationService().findByClerkId(clerkId) +} + +/** + * Create or update an organization by Clerk ID + * + * @param clerkId - The Clerk organization ID + * @param data - The organization data + * @returns The created or updated organization + */ +export async function createOrUpdateOrganizationByClerkId( + clerkId: string, + data: Omit +): Promise { + return getOrganizationService().createOrUpdateByClerkId(clerkId, data) +} diff --git a/src/app/actions/services/pocketbase/projectService.ts b/src/app/actions/services/pocketbase/projectService.ts index 7b2238f..7cc5521 100644 --- a/src/app/actions/services/pocketbase/projectService.ts +++ b/src/app/actions/services/pocketbase/projectService.ts @@ -1,60 +1,137 @@ 'use server' -import { getPocketBase, handlePocketBaseError } from './baseService' -import { ListOptions, ListResult, Project } from './types' +import { + PocketBaseApiError, + getPocketBase, +} from '@/app/actions/services/pocketbase/api_client/client' +import { + Project, + ListResult, +} from '@/app/actions/services/pocketbase/api_client/types' +import { withSecurity } from '@/app/actions/services/pocketbase/secured/security_middleware' +import { + SecurityContext, + SecurityError, +} from '@/app/actions/services/pocketbase/secured/security_types' + +// Interface for collection options +interface CollectionOptions { + filter?: string + sort?: string + expand?: string + skipTotal?: boolean + [key: string]: unknown +} + +// Type for PocketBase client +type PocketBaseClient = { + collection: (name: string) => { + getOne: (id: string) => Promise + getList: ( + page: number, + perPage: number, + options?: CollectionOptions + ) => Promise> + getFullList: (options?: CollectionOptions) => Promise + create: (data: Record) => Promise + update: (id: string, data: Record) => Promise + delete: (id: string) => Promise + filter: (filter: string, params: Record) => string + } + filter: (filter: string, params: Record) => string +} + +// Helper function to handle PocketBase errors +function handlePocketBaseError(error: unknown, source: string): never { + console.error(`Error in ${source}:`, error) + if (error instanceof PocketBaseApiError) { + throw error + } + if (error instanceof SecurityError) { + throw error + } + throw new Error(`Failed to execute operation in ${source}`) +} + +// Interface for list options +interface ListOptions { + page?: number + perPage?: number + sort?: string + filter?: string + expand?: string +} /** - * Get a single project by ID + * Get a single project by ID with security validation */ export async function getProject(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { - return await pb.collection('projects').getOne(id) + // Security check is now handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient + return await pb.collection('Project').getOne(id) } catch (error) { + if (error instanceof SecurityError) { + throw error // Re-throw security errors + } return handlePocketBaseError(error, 'ProjectService.getProject') } } /** - * Get projects list with pagination + * Get projects list with pagination and security checks */ export async function getProjectsList( + organizationId: string, options: ListOptions = {} ): Promise> { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { - const { page = 1, perPage = 30, ...rest } = options - return await pb.collection('projects').getList(page, perPage, rest) + // Security check is handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient + + const { + filter: additionalFilter, + page = 1, + perPage = 30, + ...rest + } = options + + // Apply organization filter to ensure data isolation + const filter = `organization="${organizationId}"${additionalFilter ? ` && (${additionalFilter})` : ''}` + + return await pb.collection('Project').getList(page, perPage, { + ...rest, + filter, + }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'ProjectService.getProjectsList') } } /** - * Get all projects for an organization + * Get all projects for an organization with security checks */ export async function getOrganizationProjects( organizationId: string ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { - return await pb.collection('projects').getFullList({ - filter: `organization=`${organizationId}``, + // Security check is handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient + + // Apply organization filter - correct field name based on schema + const filter = `organization="${organizationId}"` + + return await pb.collection('Project').getFullList({ + filter, sort: 'name', }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError( error, 'ProjectService.getOrganizationProjects' @@ -63,20 +140,19 @@ export async function getOrganizationProjects( } /** - * Get active projects (current date is between startDate and endDate or endDate is not set) + * Get active projects with security checks + * (current date is between startDate and endDate or endDate is not set) */ export async function getActiveProjects( organizationId: string ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - const now = new Date().toISOString() - try { - return await pb.collection('projects').getFullList({ + // Security check is handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient + const now = new Date().toISOString() + + // Fixed field name in filter + return await pb.collection('Project').getFullList({ filter: pb.filter( 'organization = {:orgId} && (startDate <= {:now} && (endDate >= {:now} || endDate = ""))', { now, orgId: organizationId } @@ -84,96 +160,121 @@ export async function getActiveProjects( sort: 'name', }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'ProjectService.getActiveProjects') } } /** - * Create a new project + * Create a new project with security checks */ -export async function createProject(data: Partial): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - +export async function createProject( + organizationId: string, + data: Pick< + Partial, + 'name' | 'address' | 'notes' | 'startDate' | 'endDate' + > +): Promise { try { - return await pb.collection('projects').create(data) + // Security check is handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient + + // Ensure organization ID is set correctly - fixed field name + return await pb.collection('Project').create({ + ...data, + organization: organizationId, // Force the correct organization ID + }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'ProjectService.createProject') } } /** - * Update a project + * Update a project with security checks */ export async function updateProject( id: string, - data: Partial + data: Pick< + Partial, + 'name' | 'address' | 'notes' | 'startDate' | 'endDate' + > ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { - return await pb.collection('projects').update(id, data) + // Security check is handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient + + // Never allow changing the organization + const sanitizedData = { ...data } + // Fixed 'any' type and field name + delete (sanitizedData as Record).organization + + return await pb.collection('Project').update(id, sanitizedData) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'ProjectService.updateProject') } } /** - * Delete a project + * Delete a project with security checks */ export async function deleteProject(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { - await pb.collection('projects').delete(id) + // Security check is handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient + await pb.collection('Project').delete(id) return true } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'ProjectService.deleteProject') } } /** - * Get project count for an organization + * Get project count for an organization with security checks */ export async function getProjectCount(organizationId: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { - const result = await pb.collection('projects').getList(1, 1, { + // Security check is handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient + + // Fixed field name + const result = await pb.collection('Project').getList(1, 1, { filter: `organization="${organizationId}"`, skipTotal: false, }) + return result.totalItems } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'ProjectService.getProjectCount') } } /** - * Search projects by name or address + * Search projects by name or address with security checks */ export async function searchProjects( organizationId: string, query: string ): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - try { - return await pb.collection('projects').getFullList({ + // Security check is handled by withSecurity HOF + const pb = getPocketBase() as unknown as PocketBaseClient + + // Fixed field name in filter + return await pb.collection('Project').getFullList({ filter: pb.filter( 'organization = {:orgId} && (name ~ {:query} || address ~ {:query})', { @@ -184,6 +285,72 @@ export async function searchProjects( sort: 'name', }) } catch (error) { + if (error instanceof SecurityError) { + throw error + } return handlePocketBaseError(error, 'ProjectService.searchProjects') } } + +// Replace these const exports with async function declarations +export async function securedGetProject(id: string) { + const securedFunc = await withSecurity( + async (id: string, context: SecurityContext) => { + // Here you would add additional security checks specific to this resource + // such as checking if the project belongs to the user's organization + + // First get the project to check if it belongs to the user's organization + const project = await getProject(id) + + // Check if the project belongs to the user's organization + if (project.organization !== context.orgPbId) { + throw new SecurityError( + 'Cannot access project from another organization' + ) + } + + return project + } + ) + + return securedFunc(id) +} + +export async function securedGetProjectsList(params: { + organizationId: string + options?: ListOptions +}) { + const securedFunc = await withSecurity( + async ( + params: { organizationId: string; options?: ListOptions }, + context: SecurityContext + ) => { + const { options, organizationId } = params + // Ensure organizationId matches the user's current organization + if (organizationId !== context.orgPbId) { + throw new SecurityError( + 'Cannot access projects from another organization' + ) + } + return getProjectsList(organizationId, options) + } + ) + + return securedFunc(params) +} + +export async function securedGetOrganizationProjects(organizationId: string) { + const securedFunc = await withSecurity( + async (organizationId: string, context: SecurityContext) => { + // Ensure organizationId matches the user's current organization + if (organizationId !== context.orgPbId) { + throw new SecurityError( + 'Cannot access projects from another organization' + ) + } + return getOrganizationProjects(organizationId) + } + ) + + return securedFunc(organizationId) +} diff --git a/src/app/actions/services/pocketbase/readme.ai.md b/src/app/actions/services/pocketbase/readme.ai.md new file mode 100644 index 0000000..eb43ccc --- /dev/null +++ b/src/app/actions/services/pocketbase/readme.ai.md @@ -0,0 +1,50 @@ +# PocketBase Services Overview + +This directory contains services for interacting with the PocketBase backend, our primary database. It provides structured access to data with proper validation and type safety. + +## Directory Structure + +- `api_client/` - Core client for PocketBase API communication +- `secured/` - Secured services with authentication and authorization checks +- `*_service.ts` files - Entity-specific services (app_user_service.ts, equipment_service.ts, etc.) + +## Key Concepts + +- **Service Pattern**: Entity-specific services for database operations +- **Base Service**: Generic CRUD operations shared across entities +- **Security Middleware**: Authentication and authorization checks +- **Type Safety**: Zod validation for inputs and outputs + +## Best Practices + +- Use entity-specific services for database operations +- Apply proper validation for all inputs and outputs +- Handle errors consistently with detailed messages +- Follow the established patterns for CRUD operations + +## Do's and Don'ts + +### Do + +- Use the established service pattern for new entities +- Follow the type validation approach with Zod +- Implement proper error handling +- Use the security middleware for protected operations + +### Don't + +- Create direct PocketBase calls outside the service layer +- Skip validation for inputs or outputs +- Mix authorization logic with data access +- Duplicate functionality across services + +## For AI Assistants + +When working with this directory: + +- Understand the service pattern for database access +- Note that entity-specific services extend the BaseService +- Be aware of the security middleware for protected operations +- Follow the established error handling patterns +- Remember that Zod schemas are used for validation +- Recognize that the api_client subdirectory contains the core client code diff --git a/src/app/actions/services/pocketbase/secured/equipment_service.ts b/src/app/actions/services/pocketbase/secured/equipment_service.ts new file mode 100644 index 0000000..6511a41 --- /dev/null +++ b/src/app/actions/services/pocketbase/secured/equipment_service.ts @@ -0,0 +1,268 @@ +'use server' + +import { + EquipmentCreateInput, + EquipmentUpdateInput, + getEquipmentService, +} from '@/app/actions/services/pocketbase/equipment_service' +import { + withSecurity, + checkResourcePermission, +} from '@/app/actions/services/pocketbase/secured/security_middleware' +import { + SecurityContext, + SecurityError, +} from '@/app/actions/services/pocketbase/secured/security_types' +import { Equipment } from '@/models/pocketbase' + +/** + * Get equipment by ID with security checks + */ +export async function getEquipmentById(id: string): Promise { + const securedFunc = await withSecurity( + async (id: string, context: SecurityContext): Promise => { + // Get the equipment + const equipmentService = getEquipmentService() + const equipment = await equipmentService.getById(id) + + // Check permission + if (!(await checkResourcePermission(equipment.organization, context))) { + throw new SecurityError( + 'Forbidden: You do not have access to this equipment', + 403 + ) + } + + return equipment + } + ) + + return securedFunc(id) +} + +/** + * List equipment with security context + */ +export async function listOrganizationEquipment(params: { + searchTerm?: string + page?: number + perPage?: number + sort?: string +}): Promise<{ + items: Equipment[] + totalItems: number + totalPages: number +}> { + const securedFunc = await withSecurity( + async ( + params: { + searchTerm?: string + page?: number + perPage?: number + sort?: string + }, + context: SecurityContext + ): Promise<{ + items: Equipment[] + totalItems: number + totalPages: number + }> => { + const equipmentService = getEquipmentService() + + // Apply the organization filter automatically + const filter = params.searchTerm + ? `organization = "${context.orgPbId}" && (name ~ "${params.searchTerm}" || tags ~ "${params.searchTerm}" || notes ~ "${params.searchTerm}")` + : `organization = "${context.orgPbId}"` + + // Get the equipment list + const result = await equipmentService.getList({ + filter, + page: params.page, + perPage: params.perPage, + sort: params.sort, + }) + + return { + items: result.items, + totalItems: result.totalItems, + totalPages: result.totalPages, + } + } + ) + + return securedFunc(params) +} + +/** + * Create new equipment with security context + */ +export async function createEquipment( + data: Omit +): Promise { + const securedFunc = await withSecurity( + async ( + data: Omit, + context: SecurityContext + ): Promise => { + const equipmentService = getEquipmentService() + + // Always set the organization to the current user's organization + const equipmentData: EquipmentCreateInput = { + ...data, + organization: context.orgPbId, + } + + // Generate QR/NFC code if not provided + if (!equipmentData.qrNfcCode) { + equipmentData.qrNfcCode = await equipmentService.generateUniqueCode() + } + + return equipmentService.create(equipmentData) + }, + { revalidatePaths: ['/app/equipment'] } + ) + + return securedFunc(data) +} + +/** + * Update equipment with security checks + */ +export async function updateEquipment(params: { + id: string + data: EquipmentUpdateInput +}): Promise { + const securedFunc = await withSecurity( + async ( + params: { id: string; data: EquipmentUpdateInput }, + context: SecurityContext + ): Promise => { + const equipmentService = getEquipmentService() + + // Get the equipment first to check permissions + const existingEquipment = await equipmentService.getById(params.id) + + // Check permission + if ( + !(await checkResourcePermission( + existingEquipment.organization, + context + )) + ) { + throw new SecurityError( + 'Forbidden: You do not have access to this equipment', + 403 + ) + } + + // Prevent changing the organization + const updateData = params.data as Record + if ( + typeof updateData === 'object' && + updateData !== null && + 'organization' in updateData && + updateData.organization && + updateData.organization !== context.orgPbId + ) { + throw new SecurityError( + 'Forbidden: Cannot change equipment organization', + 403 + ) + } + + return equipmentService.update(params.id, params.data) + }, + { revalidatePaths: ['/app/equipment'] } + ) + + return securedFunc(params) +} + +/** + * Delete equipment with security checks + */ +export async function deleteEquipment(id: string): Promise { + const securedFunc = await withSecurity( + async (id: string, context: SecurityContext): Promise => { + const equipmentService = getEquipmentService() + + // Get the equipment first to check permissions + const existingEquipment = await equipmentService.getById(id) + + // Check permission (require admin for deletion) + if ( + !(await checkResourcePermission( + existingEquipment.organization, + context, + true + )) + ) { + throw new SecurityError( + 'Forbidden: Only administrators can delete equipment', + 403 + ) + } + + return equipmentService.delete(id) + }, + { + requireAdmin: true, + revalidatePaths: ['/app/equipment'], + } + ) + + return securedFunc(id) +} + +/** + * Search equipment with security context + */ +export async function searchEquipment( + searchTerm: string +): Promise { + const securedFunc = await withSecurity( + async ( + searchTerm: string, + context: SecurityContext + ): Promise => { + const equipmentService = getEquipmentService() + + // The search is already scoped to the organization + return equipmentService.search(context.orgPbId, searchTerm) + } + ) + + return securedFunc(searchTerm) +} + +/** + * Find equipment by QR/NFC code with security context + */ +export async function findEquipmentByQrNfcCode( + qrNfcCode: string +): Promise { + const securedFunc = await withSecurity( + async ( + qrNfcCode: string, + context: SecurityContext + ): Promise => { + const equipmentService = getEquipmentService() + + const equipment = await equipmentService.findByQrNfcCode(qrNfcCode) + + // If no equipment found, return null + if (!equipment) { + return null + } + + // Check permission - return null if no access instead of error + if (!(await checkResourcePermission(equipment.organization, context))) { + return null + } + + return equipment + } + ) + + return securedFunc(qrNfcCode) +} diff --git a/src/app/actions/services/pocketbase/secured/index.ts b/src/app/actions/services/pocketbase/secured/index.ts new file mode 100644 index 0000000..432ffcf --- /dev/null +++ b/src/app/actions/services/pocketbase/secured/index.ts @@ -0,0 +1,15 @@ +'use server' + +/** + * Secured PocketBase services with security middleware + * These services enforce permissions and access controls + */ + +// Security types and middleware +export * from '@/app/actions/services/pocketbase/secured/security_types' +export * from '@/app/actions/services/pocketbase/secured/security_middleware' + +// Secured services +export * from '@/app/actions/services/pocketbase/secured/equipment_service' + +// Export more secured services here as they are created diff --git a/src/app/actions/services/pocketbase/secured/readme.ai.md b/src/app/actions/services/pocketbase/secured/readme.ai.md new file mode 100644 index 0000000..c406c34 --- /dev/null +++ b/src/app/actions/services/pocketbase/secured/readme.ai.md @@ -0,0 +1,50 @@ +# Secured PocketBase Services Overview + +This directory contains secured versions of PocketBase services that implement authentication, authorization, and multi-tenant isolation. These services form the foundation of the application's security model. + +## Key Files + +- `security_middleware.ts` - Core middleware for securing server actions +- `equipment_service.ts` - Secured equipment service with authorization checks +- Other entity-specific secured services + +## Key Concepts + +- **Security Middleware**: withSecurity() HOF that wraps server actions +- **SecurityContext**: Context object with user and organization information +- **Authorization Checks**: Permission validation for operations +- **Multi-tenancy**: Organization-based data isolation + +## Best Practices + +- Always use withSecurity() for protected operations +- Implement proper permission checks for each operation +- Return appropriate error responses for security violations +- Follow the established patterns for secured services + +## Do's and Don'ts + +### Do + +- Use the withSecurity middleware for all protected actions +- Check permissions before data operations +- Return SecurityError for authorization failures +- Respect organization boundaries for data access + +### Don't + +- Create server actions without proper security checks +- Bypass the security middleware +- Allow cross-organization data access +- Expose sensitive operations without authentication + +## For AI Assistants + +When working with this directory: + +- Understand the central role of the security middleware +- Note that all secured services use withSecurity() +- Be aware of the SecurityContext object passed to actions +- Recognize the importance of checkResourcePermission() +- Remember that organization isolation is a core security principle +- Follow the established patterns for new secured services diff --git a/src/app/actions/services/pocketbase/secured/security_middleware.ts b/src/app/actions/services/pocketbase/secured/security_middleware.ts new file mode 100644 index 0000000..76013d0 --- /dev/null +++ b/src/app/actions/services/pocketbase/secured/security_middleware.ts @@ -0,0 +1,120 @@ +'use server' + +import { PocketBaseApiError } from '@/app/actions/services/pocketbase/api_client/client' +import { findUserByClerkId } from '@/app/actions/services/pocketbase/app_user_service' +import { findOrganizationByClerkId } from '@/app/actions/services/pocketbase/organization_service' +import { auth } from '@clerk/nextjs/server' +import { revalidatePath } from 'next/cache' + +import { + SecurityContext, + SecurityError, + SecuredHandler, +} from './security_types' + +/** + * Higher-order function that wraps server actions with security checks + * + * @param handler - The server action handler function + * @param options - Security options + * @returns A new handler function with security checks + */ +export async function withSecurity( + handler: SecuredHandler, + options: { + revalidatePaths?: string[] + requireAdmin?: boolean + } = {} +) { + return async (params: TParams): Promise => { + try { + // Get auth info from Clerk (auth() is async in Next.js 14) + const authData = await auth() + + // Check if user is authenticated + if (!authData.userId) { + throw new SecurityError('Unauthorized: User not authenticated') + } + + // Check if user has selected an organization + if (!authData.orgId) { + throw new SecurityError('Unauthorized: No organization selected') + } + + // Check admin requirement if needed + if (options.requireAdmin && authData.orgRole !== 'admin') { + throw new SecurityError('Forbidden: Admin access required', 403) + } + + // Get the PocketBase IDs for the user and organization + const userRecord = await findUserByClerkId(authData.userId) + if (!userRecord) { + throw new SecurityError('User not found in database') + } + + const orgRecord = await findOrganizationByClerkId(authData.orgId) + if (!orgRecord) { + throw new SecurityError('Organization not found in database') + } + + // Create security context + const securityContext: SecurityContext = { + isAdmin: authData.orgRole === 'admin', + orgId: authData.orgId, + orgPbId: orgRecord.id, + orgRole: authData.orgRole || '', + userId: authData.userId, + userPbId: userRecord.id, + } + + // Call the handler with security context + const result = await handler(params, securityContext) + + // Revalidate paths if specified + if (options.revalidatePaths) { + for (const path of options.revalidatePaths) { + revalidatePath(path) + } + } + + return result + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + + if (error instanceof PocketBaseApiError) { + throw error + } + + console.error('Error in secured handler:', error) + throw new SecurityError('An unexpected error occurred', 500) + } + } +} + +/** + * Check if the current user has permission to access a resource + * + * @param resourceOrgId - The organization ID associated with the resource + * @param context - The security context + * @param requireAdmin - Whether admin access is required + * @returns True if the user has permission + */ +export async function checkResourcePermission( + resourceOrgId: string, + context: SecurityContext, + requireAdmin = false +): Promise { + // Check if the resource belongs to the user's organization + if (resourceOrgId !== context.orgPbId) { + return false + } + + // Check admin requirement if needed + if (requireAdmin && !context.isAdmin) { + return false + } + + return true +} diff --git a/src/app/actions/services/pocketbase/secured/security_types.ts b/src/app/actions/services/pocketbase/secured/security_types.ts new file mode 100644 index 0000000..67ee94f --- /dev/null +++ b/src/app/actions/services/pocketbase/secured/security_types.ts @@ -0,0 +1,32 @@ +/** + * Security middleware error class + */ +export class SecurityError extends Error { + statusCode: number + + constructor(message: string, statusCode = 401) { + super(message) + this.name = 'SecurityError' + this.statusCode = statusCode + } +} + +/** + * Type for the security context provided to secured actions + */ +export interface SecurityContext { + userId: string + orgId: string + orgRole: string + userPbId: string + orgPbId: string + isAdmin: boolean +} + +/** + * Type for a handler function that requires security context + */ +export type SecuredHandler = ( + params: TParams, + context: SecurityContext +) => Promise diff --git a/src/app/actions/services/pocketbase/security_utils.ts b/src/app/actions/services/pocketbase/security_utils.ts new file mode 100644 index 0000000..1f0f836 --- /dev/null +++ b/src/app/actions/services/pocketbase/security_utils.ts @@ -0,0 +1,126 @@ +import { getAppUserService } from '@/app/actions/services/pocketbase/app_user_service' +import { AppUser } from '@/models/pocketbase' +import { currentUser } from '@clerk/nextjs/server' + +import { getOrganizationAppUserService } from './organization_app_user_service' + +/** + * Security error class for authentication and authorization errors + */ +export class SecurityError extends Error { + constructor(message: string) { + super(message) + this.name = 'SecurityError' + } +} + +/** + * Permission levels for authorization + */ +export enum PermissionLevel { + ADMIN = 'admin', + READ = 'read', + WRITE = 'write', +} + +/** + * Validates that the current user is authenticated + * @returns The authenticated user + * @throws {SecurityError} If the user is not authenticated + */ +export async function validateCurrentUser(): Promise { + try { + // Get the current user from Clerk + const user = await currentUser() + + if (!user?.id) { + throw new SecurityError('Authentication required') + } + + // Get the user from PocketBase + const userService = getAppUserService() + const pbUser = await userService.findByClerkId(user.id) + + if (!pbUser) { + throw new SecurityError('User not found in database') + } + + return pbUser + } catch (error) { + if (error instanceof SecurityError) { + throw error + } + console.error('Authentication error:', error) + throw new SecurityError('Authentication failed') + } +} + +/** + * Validates that the current user has access to the specified organization with required permission level + * @param organizationId The organization ID to validate + * @param requiredPermission The required permission level + * @returns The validated user and organization ID + * @throws {SecurityError} If access is unauthorized + */ +export async function validateOrganizationAccess( + organizationId: string, + requiredPermission: PermissionLevel = PermissionLevel.READ +): Promise<{ user: AppUser; organizationId: string }> { + // First validate the user is authenticated + const user = await validateCurrentUser() + + // Get the organization-user service + const orgUserService = getOrganizationAppUserService() + + // Get the user's role in this organization + const mapping = await orgUserService.findByAppUserAndOrganization( + user.id, + organizationId + ) + + if (!mapping) { + throw new SecurityError('Unauthorized access to organization data') + } + + const role = mapping.role + + // Check permission level based on role + if (requiredPermission === PermissionLevel.ADMIN && role !== 'admin') { + throw new SecurityError('Admin permission required for this operation') + } + + if ( + requiredPermission === PermissionLevel.WRITE && + !['admin', 'manager'].includes(role) + ) { + throw new SecurityError('Write permission required for this operation') + } + + // If we reach here, the user has the required permission + return { organizationId, user } +} + +/** + * Higher-order function that wraps a function with organization access validation + * @param fn The function to wrap + * @param permissionLevel The permission level required + * @returns The wrapped function + */ +export function withOrganizationAccess< + TArgs extends [string, ...unknown[]], + TReturn, +>( + fn: (...args: TArgs) => Promise, + permissionLevel: PermissionLevel = PermissionLevel.READ +): (...args: TArgs) => Promise { + return async (...args: TArgs): Promise => { + // The first argument should be the organization ID + const organizationId = args[0] + + // Validate the organization access + await validateOrganizationAccess(organizationId, permissionLevel) + + // Call the original function + return fn(...args) + } +} diff --git a/src/app/actions/services/pocketbase/userService.ts b/src/app/actions/services/pocketbase/userService.ts deleted file mode 100644 index 0812d08..0000000 --- a/src/app/actions/services/pocketbase/userService.ts +++ /dev/null @@ -1,166 +0,0 @@ -'use server' - -import { getPocketBase, handlePocketBaseError } from './baseService' -import { ListOptions, ListResult, User } from './types' - -/** - * Get a single user by ID - */ -export async function getUser(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('users').getOne(id) - } catch (error) { - return handlePocketBaseError(error, 'UserService.getUser') - } -} - -/** - * Get a user by Clerk ID - */ -export async function getUserByClerkId(clerkId: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('users').getFirstListItem(`clerkId="${clerkId}"`) - } catch (error) { - return handlePocketBaseError(error, 'UserService.getUserByClerkId') - } -} - -/** - * Get users list with pagination - */ -export async function getUsersList( - options: ListOptions = {} -): Promise> { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - const { page = 1, perPage = 30, ...rest } = options - return await pb.collection('users').getList(page, perPage, rest) - } catch (error) { - return handlePocketBaseError(error, 'UserService.getUsersList') - } -} - -/** - * Get all users for an organization - */ -export async function getUsersByOrganization( - organizationId: string -): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('users').getFullList({ - filter: `organization="${organizationId}"`, - sort: 'name', - }) - } catch (error) { - return handlePocketBaseError(error, 'UserService.getUsersByOrganization') - } -} - -/** - * Create a new user - */ -export async function createUser(data: Partial): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('users').create(data) - } catch (error) { - return handlePocketBaseError(error, 'UserService.createUser') - } -} - -/** - * Update a user - */ -export async function updateUser( - id: string, - data: Partial -): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('users').update(id, data) - } catch (error) { - return handlePocketBaseError(error, 'UserService.updateUser') - } -} - -/** - * Delete a user - */ -export async function deleteUser(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - await pb.collection('users').delete(id) - return true - } catch (error) { - return handlePocketBaseError(error, 'UserService.deleteUser') - } -} - -/** - * Update user's last login time - */ -export async function updateUserLastLogin(id: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - return await pb.collection('users').update(id, { - lastLogin: new Date().toISOString(), - }) - } catch (error) { - return handlePocketBaseError(error, 'UserService.updateUserLastLogin') - } -} - -/** - * Get the count of users in an organization - */ -export async function getUserCount(organizationId: string): Promise { - const pb = await getPocketBase() - if (!pb) { - throw new Error('Failed to connect to PocketBase') - } - - try { - const result = await pb.collection('users').getList(1, 1, { - filter: 'organization=' + `${organizationId}`, - skipTotal: false, - }) - return result.totalItems - } catch (error) { - return handlePocketBaseError(error, 'UserService.getUserCount') - } -} diff --git a/src/app/actions/services/readme.ai.md b/src/app/actions/services/readme.ai.md new file mode 100644 index 0000000..6ba3a12 --- /dev/null +++ b/src/app/actions/services/readme.ai.md @@ -0,0 +1,48 @@ +# Services Directory Overview + +This directory contains service modules that handle integration with external systems and provide a clean API for data access. Services act as a bridge between server actions and external resources. + +## Directory Structure + +- `clerk-sync/` - Services for synchronizing data between Clerk and PocketBase +- `pocketbase/` - Services for interacting with the PocketBase backend +- `securyUtilsTools.ts` - Security utilities for server actions + +## Key Concepts + +- **Service Layer**: Encapsulates external system interactions +- **Data Synchronization**: Maintains consistency between auth system and database +- **Security Middleware**: Enforces access control and tenant isolation + +## Best Practices + +- Keep service methods focused on single responsibilities +- Handle errors consistently and provide meaningful messages +- Use TypeScript types for service inputs and outputs +- Follow the established patterns for each service type + +## Do's and Don'ts + +### Do + +- Use the appropriate service for each external system +- Handle and log errors at the service level +- Follow the established typings for each service +- Maintain proper separation between different services + +### Don't + +- Mix concerns between different service types +- Bypass service layers to access external systems directly +- Expose sensitive information in service responses +- Create redundant services for the same functionality + +## For AI Assistants + +When working with this directory: + +- Understand that services are organized by external system +- Be aware of the synchronization patterns between Clerk and PocketBase +- Note the security patterns implemented in the middleware +- Follow the established error handling patterns +- Remember that PocketBase services form the core data access layer diff --git a/src/app/actions/services/securyUtilsTools.ts b/src/app/actions/services/securyUtilsTools.ts new file mode 100644 index 0000000..273c639 --- /dev/null +++ b/src/app/actions/services/securyUtilsTools.ts @@ -0,0 +1,29 @@ +/** + * User permission levels + */ +export enum PermissionLevel { + ADMIN = 'admin', + READ = 'read', + WRITE = 'write', +} + +/** + * Resource types for permission checks + */ +export enum ResourceType { + ASSIGNMENT = 'assignment', + EQUIPMENT = 'equipment', + ORGANIZATION = 'organization', + PROJECT = 'project', + USER = 'user', +} + +/** + * Error thrown when security checks fail + */ +export class SecurityError extends Error { + constructor(message: string) { + super(message) + this.name = 'SecurityError' + } +} diff --git a/src/app/api/webhook/clerk/admin/reconcile/route.ts b/src/app/api/webhook/clerk/admin/reconcile/route.ts new file mode 100644 index 0000000..7879f93 --- /dev/null +++ b/src/app/api/webhook/clerk/admin/reconcile/route.ts @@ -0,0 +1,83 @@ +import { + runFullReconciliation, + reconcileSpecificUser, + reconcileSpecificOrganization, +} from '@/app/actions/services/clerk-sync/reconciliation' +import { auth } from '@clerk/nextjs/server' +import { headers } from 'next/headers' +import { NextRequest, NextResponse } from 'next/server' + +/** + * Admin endpoint to trigger data reconciliation + * This endpoint is protected and requires either: + * 1. Admin authentication + * 2. A valid API key for automated tasks + */ +export async function POST(req: NextRequest) { + // Security checks - either admin authentication or API key + const isAuthenticated = await checkAuthentication() + + if (!isAuthenticated) { + return new NextResponse('Unauthorized', { status: 401 }) + } + + try { + const body = await req.json() + const { organizationId, type, userId } = body + + // Run the appropriate reconciliation based on request type + if (type === 'full') { + const result = await runFullReconciliation() + return NextResponse.json(result) + } else if (type === 'user' && userId) { + const result = await reconcileSpecificUser(userId) + return NextResponse.json(result) + } else if (type === 'organization' && organizationId) { + const result = await reconcileSpecificOrganization(organizationId) + return NextResponse.json(result) + } else { + return NextResponse.json( + { + error: 'Invalid reconciliation type or missing parameters', + status: 'error', + }, + { status: 400 } + ) + } + } catch (error) { + console.error('Reconciliation API error:', error) + return NextResponse.json( + { + error: error instanceof Error ? error.message : 'Unknown error', + status: 'error', + }, + { status: 500 } + ) + } +} + +/** + * Checks if the request is authenticated + * Accepts either an admin user or a valid API key + * + * @param req The incoming request + * @returns Whether the request is authenticated + */ +async function checkAuthentication(): Promise { + // Option 1: Check admin user + const { orgRole, userId } = await auth() + + if (userId && orgRole === 'admin') { + return true + } + + // Option 2: Check API key for automated tasks + const headerPayload = await headers() + const apiKey = headerPayload.get('x-api-key') + + if (apiKey && apiKey === process.env.INTERNAL_API_KEY) { + return true + } + + return false +} diff --git a/src/app/api/webhook/clerk/organization-membership/route.ts b/src/app/api/webhook/clerk/organization-membership/route.ts new file mode 100644 index 0000000..3d90774 --- /dev/null +++ b/src/app/api/webhook/clerk/organization-membership/route.ts @@ -0,0 +1,59 @@ +import { processWebhookEvent } from '@/app/actions/services/clerk-sync/webhook-handler' +import { verifyClerkWebhook } from '@/lib/webhookUtils' +import { WebhookEvent } from '@clerk/nextjs/server' +import { NextRequest, NextResponse } from 'next/server' + +/** + * Handles webhook events from Clerk related to organization memberships + */ +export async function POST(req: NextRequest) { + try { + // Get the Svix headers for verification + const svixId = req.headers.get('svix-id') + const svixTimestamp = req.headers.get('svix-timestamp') + const svixSignature = req.headers.get('svix-signature') + + // Validate that we have all required headers + if (!svixId || !svixTimestamp || !svixSignature) { + console.error('Missing required Svix headers') + return new NextResponse('Unauthorized: Missing verification headers', { + status: 401, + }) + } + + // Verify the webhook signature and get the parsed body + const verificationResult = await verifyClerkWebhook( + req, + process.env.CLERK_WEBHOOK_SECRET_ORGANIZATION_MEMBERSHIP + ) + + if (!verificationResult.success || !verificationResult.payload) { + console.error( + 'Invalid webhook signature for organization membership event' + ) + return new NextResponse('Unauthorized: Invalid signature', { + status: 401, + }) + } + + // Use the parsed payload from the verification + const body = verificationResult.payload as WebhookEvent + + // Process the webhook using our central handler + const result = await processWebhookEvent(body) + return NextResponse.json(result) + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + console.error('Error processing webhook:', error) + + return NextResponse.json( + { + error: 'Failed to process webhook', + message: errorMessage, + success: false, + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/webhook/clerk/organization/route.ts b/src/app/api/webhook/clerk/organization/route.ts new file mode 100644 index 0000000..2a2c669 --- /dev/null +++ b/src/app/api/webhook/clerk/organization/route.ts @@ -0,0 +1,57 @@ +import { processWebhookEvent } from '@/app/actions/services/clerk-sync/webhook-handler' +import { verifyClerkWebhook } from '@/lib/webhookUtils' +import { WebhookEvent } from '@clerk/nextjs/server' +import { NextRequest, NextResponse } from 'next/server' + +/** + * Handles webhook events from Clerk related to organizations + */ +export async function POST(req: NextRequest) { + try { + // Get the Svix headers for verification + const svixId = req.headers.get('svix-id') + const svixTimestamp = req.headers.get('svix-timestamp') + const svixSignature = req.headers.get('svix-signature') + + // Validate that we have all required headers + if (!svixId || !svixTimestamp || !svixSignature) { + console.error('Missing required Svix headers') + return new NextResponse('Unauthorized: Missing verification headers', { + status: 401, + }) + } + + // Verify the webhook signature and get the parsed body + const verificationResult = await verifyClerkWebhook( + req, + process.env.CLERK_WEBHOOK_SECRET_ORGANIZATION + ) + + if (!verificationResult.success || !verificationResult.payload) { + console.error('Invalid webhook signature for organization event') + return new NextResponse('Unauthorized: Invalid signature', { + status: 401, + }) + } + + // Use the parsed payload from the verification + const body = verificationResult.payload as WebhookEvent + + // Process the webhook using our central handler + const result = await processWebhookEvent(body) + return NextResponse.json(result) + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + console.error('Error processing webhook:', error) + + return NextResponse.json( + { + error: 'Failed to process webhook', + message: errorMessage, + success: false, + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/webhook/clerk/user/route.ts b/src/app/api/webhook/clerk/user/route.ts new file mode 100644 index 0000000..47d21b9 --- /dev/null +++ b/src/app/api/webhook/clerk/user/route.ts @@ -0,0 +1,57 @@ +import { processWebhookEvent } from '@/app/actions/services/clerk-sync/webhook-handler' +import { verifyClerkWebhook } from '@/lib/webhookUtils' +import { WebhookEvent } from '@clerk/nextjs/server' +import { NextRequest, NextResponse } from 'next/server' + +/** + * Handles Clerk webhook requests for user-related events + */ +export async function POST(req: NextRequest) { + try { + // Get the Svix headers for verification + const svixId = req.headers.get('svix-id') + const svixTimestamp = req.headers.get('svix-timestamp') + const svixSignature = req.headers.get('svix-signature') + + // Validate that we have all required headers + if (!svixId || !svixTimestamp || !svixSignature) { + console.error('Missing required Svix headers') + return new NextResponse('Unauthorized: Missing verification headers', { + status: 401, + }) + } + + // Verify the webhook signature and get the parsed body + const verificationResult = await verifyClerkWebhook( + req, + process.env.CLERK_WEBHOOK_SECRET_USER + ) + + if (!verificationResult.success || !verificationResult.payload) { + console.error('Invalid webhook signature for user event') + return new NextResponse('Unauthorized: Invalid signature', { + status: 401, + }) + } + + // Use the parsed payload from the verification + const body = verificationResult.payload as WebhookEvent + + // Process the webhook using our central handler + const result = await processWebhookEvent(body) + return NextResponse.json(result) + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error' + console.error('Error processing webhook:', error) + + return NextResponse.json( + { + error: 'Failed to process webhook', + message: errorMessage, + success: false, + }, + { status: 500 } + ) + } +} diff --git a/src/app/readme.ai.md b/src/app/readme.ai.md new file mode 100644 index 0000000..3408cbd --- /dev/null +++ b/src/app/readme.ai.md @@ -0,0 +1,52 @@ +# App Directory Overview + +This directory follows the Next.js App Router structure and contains the main application routes, page components, and server actions. + +## Directory Structure + +- `(application)/` - Protected routes requiring authentication +- `(marketing)/` - Public routes for marketing and public pages +- `actions/` - Server actions for data operations +- `api/` - API routes including webhooks +- `globals.css` - Global CSS styles + +## Key Concepts + +- Routes are organized by access level using route groups (`(application)` and `(marketing)`) +- (application) is used for the main app part (/app(\*)) +- (marketing) is used only for the front end marketing / business part (/(\*)) +- Server components are used by default for better performance and security +- Server actions handle data mutations with proper authorization checks +- API routes handle webhooks and external integrations + +## Best Practices + +- Keep page components lightweight and focused on layout +- Move complex logic to server actions +- Use client components only when interactivity is needed +- Follow the security patterns established for data access + +## Do's and Don'ts + +### Do + +- Use server components by default +- Add authorization checks to all secure routes and actions +- Handle errors appropriately in server actions +- Use the established middleware for security enforcement + +### Don't + +- Expose sensitive data in client components +- Bypass the security middleware for protected operations +- Create duplicative API routes for similar functionality +- Add heavy logic to page components + +## For AI Assistants + +When working with this directory: + +- Always respect the division between public and protected routes +- Ensure new server actions use the security middleware +- Be aware that the app uses organization-based multi-tenancy +- Follow the file naming conventions established in each subdirectory diff --git a/src/components/app/app-sidebar.tsx b/src/components/app/app-sidebar.tsx index 56b9a1e..faf8498 100644 --- a/src/components/app/app-sidebar.tsx +++ b/src/components/app/app-sidebar.tsx @@ -13,6 +13,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip' import { + OrganizationSwitcher, RedirectToSignIn, SignedIn, SignedOut, @@ -30,6 +31,18 @@ import { import Link from 'next/link' import { usePathname } from 'next/navigation' +const DotIcon = () => { + return ( + + + + ) +} + export function AppSidebar() { const pathname = usePathname() @@ -157,25 +170,6 @@ export function AppSidebar() { - - - - - - - - - - Organisation - - @@ -198,6 +192,29 @@ export function AppSidebar() { Profil + + + + + +
+ +
+
+
+
+
+
diff --git a/src/components/app/projects/projects-table-columns.tsx b/src/components/app/projects/projects-table-columns.tsx new file mode 100644 index 0000000..8e94321 --- /dev/null +++ b/src/components/app/projects/projects-table-columns.tsx @@ -0,0 +1,291 @@ +'use client' + +import { Project } from '@/app/actions/services/pocketbase/api_client/types' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import { ColumnDef } from '@tanstack/react-table' +import { format } from 'date-fns' +import { fr } from 'date-fns/locale' +import { ArrowDownIcon, ArrowUpIcon, MoreHorizontal } from 'lucide-react' + +// Helper function to get sort icon +const getSortIcon = (sortState: false | 'asc' | 'desc') => { + if (sortState === 'asc') { + return + } + if (sortState === 'desc') { + return + } + return null +} + +export const projectColumns: ColumnDef[] = [ + { + accessorKey: 'name', + cell: ({ row }) => { + const value = row.getValue('name') + if (!value || typeof value !== 'string') return - + return {value} + }, + enableHiding: false, + enableSorting: true, + header: ({ column }) => { + return ( + + ) + }, + maxSize: 300, + minSize: 150, + size: 200, + }, + { + accessorKey: 'status', + cell: ({ row }) => { + const startDate = row.getValue('startDate') + const endDate = row.getValue('endDate') + const now = new Date() + const start = startDate ? new Date(startDate as string) : null + const end = endDate ? new Date(endDate as string) : null + const isActive = start && start <= now && (!end || end >= now) + + return ( + + {isActive ? 'Actif' : 'Inactif'} + + ) + }, + enableHiding: false, + enableSorting: true, + header: ({ column }) => { + return ( + + ) + }, + maxSize: 120, + minSize: 80, + size: 100, + }, + { + accessorKey: 'address', + cell: ({ row }) => { + const value = row.getValue('address') + if (!value || typeof value !== 'string') return - + return {value} + }, + enableHiding: false, + enableSorting: true, + header: ({ column }) => { + return ( + + ) + }, + maxSize: 300, + minSize: 150, + size: 200, + }, + { + accessorKey: 'startDate', + cell: ({ row }) => { + const value = row.getValue('startDate') + if (!value || typeof value !== 'string') return - + const date = new Date(value) + return ( + + {format(date, 'dd MMM yyyy', { locale: fr })} + + ) + }, + enableHiding: false, + enableSorting: true, + header: ({ column }) => { + return ( + + ) + }, + maxSize: 180, + minSize: 120, + size: 140, + }, + { + accessorKey: 'endDate', + cell: ({ row }) => { + const value = row.getValue('endDate') + if (!value || typeof value !== 'string') return - + const date = new Date(value) + return ( + + {format(date, 'dd MMM yyyy', { locale: fr })} + + ) + }, + enableHiding: false, + enableSorting: true, + header: ({ column }) => { + return ( + + ) + }, + maxSize: 180, + minSize: 120, + size: 140, + }, + { + accessorKey: 'notes', + cell: ({ row }) => { + const value = row.getValue('notes') as string | undefined + if (!value) + return Aucune note + + // Tronquer le texte s'il est trop long + const maxLength = 60 + const displayText = + value.length > maxLength ? `${value.substring(0, maxLength)}...` : value + + return ( +
+ {displayText} +
+ ) + }, + enableHiding: false, + enableSorting: true, + header: ({ column }) => { + return ( + + ) + }, + maxSize: 400, + minSize: 150, + size: 250, + }, + { + accessorKey: 'toolCount', + cell: ({ row }) => { + // todo, this value is actually mocked + const projectId = row.original.id || '' + const stableRandomSeed = projectId + .split('') + .reduce((a, b) => a + b.charCodeAt(0), 0) + const toolCount = (stableRandomSeed % 10) + 1 // Génère un nombre entre 1 et 10 + + return ( +
+ + {toolCount} + +
+ ) + }, + enableHiding: false, + enableSorting: true, + header: ({ column }) => { + return ( + + ) + }, + maxSize: 150, + minSize: 80, + size: 120, + }, + { + cell: ({ row }) => { + const project = row.original + + return ( +
+ + + + + + console.info('Voir', project.id)} + > + Voir le détail + + console.info('Éditer', project.id)} + > + Modifier + + console.info('Supprimer', project.id)} + > + Supprimer + + + +
+ ) + }, + enableHiding: false, + header: () =>
Actions
, + id: 'actions', + maxSize: 100, + minSize: 60, + size: 80, + }, +] diff --git a/src/components/app/projects/projects-table.tsx b/src/components/app/projects/projects-table.tsx new file mode 100644 index 0000000..4ab1c96 --- /dev/null +++ b/src/components/app/projects/projects-table.tsx @@ -0,0 +1,582 @@ +'use client' + +import { Project } from '@/app/actions/services/pocketbase/api_client/types' +import { projectColumns } from '@/components/app/projects/projects-table-columns' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { DatePickerWithRange } from '@/components/ui/date-picker-with-range' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Pagination, + PaginationContent, + PaginationItem, +} from '@/components/ui/pagination' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Separator } from '@/components/ui/separator' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + ColumnDef, + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, + VisibilityState, + FilterFn, +} from '@tanstack/react-table' +import { + ChevronLeftIcon, + ChevronRightIcon, + Plus, + SearchIcon, + ChevronFirstIcon, + ChevronLastIcon, + SlidersHorizontal, + Trash2, +} from 'lucide-react' +import { useState } from 'react' +import { DateRange } from 'react-day-picker' + +interface ProjectsTableProps { + data: Project[] + pageCount?: number + onDeleteProjects?: (projects: Project[]) => Promise +} + +const getColumnDisplayName = (columnId: string): string => { + switch (columnId) { + case 'name': + return 'Nom du projet' + case 'status': + return 'Statut' + case 'address': + return 'Adresse' + case 'startDate': + return 'Date de début' + case 'endDate': + return 'Date de fin' + case 'notes': + return 'Notes' + case 'toolCount': + return 'Équipements' + case 'actions': + return 'Actions' + default: + return columnId + } +} + +// Custom filter function for date range filtering +const dateRangeFilter: FilterFn = (row, columnId, value) => { + // Check if value is an array of date strings + if (!Array.isArray(value) || value.length !== 2) return true + + // Get project dates + const startDateStr = row.getValue('startDate') as string | undefined + const endDateStr = row.getValue('endDate') as string | undefined + + // If no start date, don't filter this project + if (!startDateStr) return false + + // Convert to Date objects + const projectStart = new Date(startDateStr) + const projectEnd = endDateStr ? new Date(endDateStr) : null + + // Get filter dates + const [filterStartStr, filterEndStr] = value as [string, string] + const filterStart = new Date(filterStartStr) + const filterEnd = new Date(filterEndStr) + + // A project matches the filter if: + // 1. Its start date is within the filter period + const startInRange = projectStart >= filterStart && projectStart <= filterEnd + + // 2. Its end date is within the filter period + const endInRange = projectEnd + ? projectEnd >= filterStart && projectEnd <= filterEnd + : false + + // 3. The project encompasses the entire filter period (starts before and ends after) + const projectEnclosesFilter = + projectStart <= filterStart && + (projectEnd === null || projectEnd >= filterEnd) + + // Return true if any condition is true + return startInRange || endInRange || projectEnclosesFilter +} + +// Determine if a project is active based on its dates +const isProjectActive = ( + startDate: string | undefined, + endDate: string | undefined +): boolean => { + if (!startDate) return false + + const now = new Date() + const start = new Date(startDate) + const end = endDate ? new Date(endDate) : null + + return start <= now && (!end || end >= now) +} + +export function ProjectsTable({ data, onDeleteProjects }: ProjectsTableProps) { + // Table state + const [sorting, setSorting] = useState([ + { + desc: false, + id: 'name', + }, + ]) + const [columnFilters, setColumnFilters] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) + const [searchQuery, setSearchQuery] = useState('') + const [rowSelection, setRowSelection] = useState({}) + + // Date range state + const [dateRange, setDateRange] = useState(undefined) + + // Define columns with virtual columns for filtering + const columns: ColumnDef[] = [ + // Selection column + { + cell: ({ row }) => ( + { + row.toggleSelected(!!value) + }} + aria-label='Sélectionner la ligne' + /> + ), + enableHiding: false, + enableSorting: false, + header: ({ table }) => ( + { + table.toggleAllPageRowsSelected(!!value) + }} + aria-label='Sélectionner toutes les lignes' + /> + ), + id: 'select', + maxSize: 50, + minSize: 35, + size: 40, + }, + // Virtual column for date range filtering + { + accessorFn: row => ({ + endDate: row.endDate, + startDate: row.startDate, + }), + cell: () => null, + enableColumnFilter: true, + enableHiding: true, + filterFn: dateRangeFilter, + header: () => null, + id: 'dateFilter', + maxSize: 0, + meta: { + isVirtual: true, + }, + minSize: 0, + size: 0, + }, + // Virtual column for status filtering + { + accessorFn: row => { + return isProjectActive(row.startDate, row.endDate) + }, + cell: () => null, + enableColumnFilter: true, + enableHiding: true, + filterFn: 'equals', + header: () => null, + id: 'statusFilter', + maxSize: 0, + meta: { + isVirtual: true, + }, + minSize: 0, + size: 0, + }, + ...projectColumns, + ] + + // Initialize the table + const table = useReactTable({ + columns, + data, + enableRowSelection: true, + getCoreRowModel: getCoreRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + globalFilterFn: (row, columnId, filterValue) => { + const safeValue = (() => { + const val = row.getValue(columnId) + return typeof val === 'string' + ? val.toLowerCase() + : String(val).toLowerCase() + })() + + return safeValue.includes(filterValue.toLowerCase()) + }, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + onGlobalFilterChange: setSearchQuery, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + state: { + columnFilters, + columnVisibility, + globalFilter: searchQuery, + rowSelection, + sorting, + }, + }) + + // Calculate selected rows + const selectedRows = table.getFilteredSelectedRowModel().rows + + // Delete selected projects + const handleDeleteSelected = async () => { + if (!onDeleteProjects || selectedRows.length === 0) return + + // Extract the Project objects from the selected rows + const projectsToDelete = selectedRows.map(row => row.original) + + // Call the delete function passed from the parent + await onDeleteProjects(projectsToDelete) + + // Clear selection after delete + table.resetRowSelection() + } + + // Apply date range filter + const handleDateRangeChange = (range: DateRange | undefined) => { + setDateRange(range) + + if (range?.from && range?.to) { + // Use our virtual column for date filtering + table + .getColumn('dateFilter') + ?.setFilterValue([range.from.toISOString(), range.to.toISOString()]) + } else { + table.getColumn('dateFilter')?.setFilterValue(undefined) + } + } + + // Handle status filter + const handleStatusFilterChange = (value: string) => { + if (value === 'all') { + table.getColumn('statusFilter')?.setFilterValue(undefined) + } else { + table.getColumn('statusFilter')?.setFilterValue(value === 'active') + } + } + + // Get the current status filter value + const statusFilterValue = table.getColumn('statusFilter')?.getFilterValue() + + return ( +
+ {/* Toolbar with search, delete selected and add project */} +
+
+ {/* Search bar */} +
+ + setSearchQuery(e.target.value)} + /> +
+ + {/* Status filter */} + + + + + {/* Filters bar */} +
+ {/* Column controls */} +
+ {/* Column Visibility */} + + + + + +
+
+ + +
+ +
+ {table + .getAllColumns() + .filter(column => column.getCanHide()) + .map(column => { + return ( +
+ { + column.toggleVisibility(!!value) + }} + id={`column-${column.id}`} + /> + +
+ ) + })} +
+
+
+
+
+
+
+ + {/* Main actions */} +
+ {/* Delete selected button */} + {selectedRows.length > 0 && ( + + )} + + +
+
+ + {/* Table */} +
+ + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => ( + <> + {!header.column.getCanHide() && ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map(row => ( + + {row.getVisibleCells().map(cell => ( + <> + {!cell.column.getCanHide() && ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + )} + + ))} + + )) + ) : ( + + + Aucun projet trouvé. + + + )} + +
+
+ + {/* Pagination */} +
+
+ + +
+ +
+
+ Page {table.getState().pagination.pageIndex + 1} sur  + {table.getPageCount()} +
+ + + + + + + + + + + + + + + + +
+
+
+ ) +} diff --git a/src/components/app/top-bar.tsx b/src/components/app/top-bar.tsx index cfe33d7..9632feb 100644 --- a/src/components/app/top-bar.tsx +++ b/src/components/app/top-bar.tsx @@ -1,5 +1,5 @@ 'use client' -import { SignedIn } from '@clerk/nextjs' +import { OrganizationSwitcher, SignedIn } from '@clerk/nextjs' import { Search } from 'lucide-react' import { usePathname } from 'next/navigation' diff --git a/src/components/comp-485.tsx b/src/components/comp-485.tsx new file mode 100644 index 0000000..7a2f0a5 --- /dev/null +++ b/src/components/comp-485.tsx @@ -0,0 +1,780 @@ +"use client" + +import { useEffect, useId, useMemo, useRef, useState } from "react" +import { + ColumnDef, + ColumnFiltersState, + FilterFn, + flexRender, + getCoreRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + PaginationState, + Row, + SortingState, + useReactTable, + VisibilityState, +} from "@tanstack/react-table" +import { + ChevronDownIcon, + ChevronFirstIcon, + ChevronLastIcon, + ChevronLeftIcon, + ChevronRightIcon, + ChevronUpIcon, + CircleAlertIcon, + CircleXIcon, + Columns3Icon, + EllipsisIcon, + FilterIcon, + ListFilterIcon, + PlusIcon, + TrashIcon, +} from "lucide-react" + +import { cn } from "@/lib/utils" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Pagination, + PaginationContent, + PaginationItem, +} from "@/components/ui/pagination" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +type Item = { + id: string + name: string + email: string + location: string + flag: string + status: "Active" | "Inactive" | "Pending" + balance: number +} + +// Custom filter function for multi-column searching +const multiColumnFilterFn: FilterFn = (row, columnId, filterValue) => { + const searchableRowContent = + `${row.original.name} ${row.original.email}`.toLowerCase() + const searchTerm = (filterValue ?? "").toLowerCase() + return searchableRowContent.includes(searchTerm) +} + +const statusFilterFn: FilterFn = ( + row, + columnId, + filterValue: string[] +) => { + if (!filterValue?.length) return true + const status = row.getValue(columnId) as string + return filterValue.includes(status) +} + +const columns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + size: 28, + enableSorting: false, + enableHiding: false, + }, + { + header: "Name", + accessorKey: "name", + cell: ({ row }) => ( +
{row.getValue("name")}
+ ), + size: 180, + filterFn: multiColumnFilterFn, + enableHiding: false, + }, + { + header: "Email", + accessorKey: "email", + size: 220, + }, + { + header: "Location", + accessorKey: "location", + cell: ({ row }) => ( +
+ {row.original.flag}{" "} + {row.getValue("location")} +
+ ), + size: 180, + }, + { + header: "Status", + accessorKey: "status", + cell: ({ row }) => ( + + {row.getValue("status")} + + ), + size: 100, + filterFn: statusFilterFn, + }, + { + header: "Performance", + accessorKey: "performance", + }, + { + header: "Balance", + accessorKey: "balance", + cell: ({ row }) => { + const amount = parseFloat(row.getValue("balance")) + const formatted = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(amount) + return formatted + }, + size: 120, + }, + { + id: "actions", + header: () => Actions, + cell: ({ row }) => , + size: 60, + enableHiding: false, + }, +] + +export default function Component() { + const id = useId() + const [columnFilters, setColumnFilters] = useState([]) + const [columnVisibility, setColumnVisibility] = useState({}) + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }) + const inputRef = useRef(null) + + const [sorting, setSorting] = useState([ + { + id: "name", + desc: false, + }, + ]) + + const [data, setData] = useState([]) + useEffect(() => { + async function fetchPosts() { + const res = await fetch( + "https://res.cloudinary.com/dlzlfasou/raw/upload/users-01_fertyx.json" + ) + const data = await res.json() + setData(data) + } + fetchPosts() + }, []) + + const handleDeleteRows = () => { + const selectedRows = table.getSelectedRowModel().rows + const updatedData = data.filter( + (item) => !selectedRows.some((row) => row.original.id === item.id) + ) + setData(updatedData) + table.resetRowSelection() + } + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + onSortingChange: setSorting, + enableSortingRemoval: false, + getPaginationRowModel: getPaginationRowModel(), + onPaginationChange: setPagination, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + getFilteredRowModel: getFilteredRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + state: { + sorting, + pagination, + columnFilters, + columnVisibility, + }, + }) + + // Get unique status values + const uniqueStatusValues = useMemo(() => { + const statusColumn = table.getColumn("status") + + if (!statusColumn) return [] + + const values = Array.from(statusColumn.getFacetedUniqueValues().keys()) + + return values.sort() + }, [table.getColumn("status")?.getFacetedUniqueValues()]) + + // Get counts for each status + const statusCounts = useMemo(() => { + const statusColumn = table.getColumn("status") + if (!statusColumn) return new Map() + return statusColumn.getFacetedUniqueValues() + }, [table.getColumn("status")?.getFacetedUniqueValues()]) + + const selectedStatuses = useMemo(() => { + const filterValue = table.getColumn("status")?.getFilterValue() as string[] + return filterValue ?? [] + }, [table.getColumn("status")?.getFilterValue()]) + + const handleStatusChange = (checked: boolean, value: string) => { + const filterValue = table.getColumn("status")?.getFilterValue() as string[] + const newFilterValue = filterValue ? [...filterValue] : [] + + if (checked) { + newFilterValue.push(value) + } else { + const index = newFilterValue.indexOf(value) + if (index > -1) { + newFilterValue.splice(index, 1) + } + } + + table + .getColumn("status") + ?.setFilterValue(newFilterValue.length ? newFilterValue : undefined) + } + + return ( +
+ {/* Filters */} +
+
+ {/* Filter by name or email */} +
+ + table.getColumn("name")?.setFilterValue(e.target.value) + } + placeholder="Filter by name or email..." + type="text" + aria-label="Filter by name or email" + /> +
+
+ {Boolean(table.getColumn("name")?.getFilterValue()) && ( + + )} +
+ {/* Filter by status */} + + + + + +
+
+ Filters +
+
+ {uniqueStatusValues.map((value, i) => ( +
+ + handleStatusChange(checked, value) + } + /> + +
+ ))} +
+
+
+
+ {/* Toggle columns visibility */} + + + + + + Toggle columns + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + onSelect={(event) => event.preventDefault()} + > + {column.id} + + ) + })} + + +
+
+ {/* Delete button */} + {table.getSelectedRowModel().rows.length > 0 && ( + + + + + +
+ + + + Are you absolutely sure? + + + This action cannot be undone. This will permanently delete{" "} + {table.getSelectedRowModel().rows.length} selected{" "} + {table.getSelectedRowModel().rows.length === 1 + ? "row" + : "rows"} + . + + +
+ + Cancel + + Delete + + +
+
+ )} + {/* Add user button */} + +
+
+ + {/* Table */} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : header.column.getCanSort() ? ( +
{ + // Enhanced keyboard handling for sorting + if ( + header.column.getCanSort() && + (e.key === "Enter" || e.key === " ") + ) { + e.preventDefault() + header.column.getToggleSortingHandler()?.(e) + } + }} + tabIndex={header.column.getCanSort() ? 0 : undefined} + > + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {{ + asc: ( +
+ ) : ( + flexRender( + header.column.columnDef.header, + header.getContext() + ) + )} +
+ ) + })} +
+ ))} +
+ + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ + {/* Pagination */} +
+ {/* Results per page */} +
+ + +
+ {/* Page number information */} +
+

+ + {table.getState().pagination.pageIndex * + table.getState().pagination.pageSize + + 1} + - + {Math.min( + Math.max( + table.getState().pagination.pageIndex * + table.getState().pagination.pageSize + + table.getState().pagination.pageSize, + 0 + ), + table.getRowCount() + )} + {" "} + of{" "} + + {table.getRowCount().toString()} + +

+
+ + {/* Pagination buttons */} +
+ + + {/* First page button */} + + + + {/* Previous page button */} + + + + {/* Next page button */} + + + + {/* Last page button */} + + + + + +
+
+

+ Example of a more complex table made with{" "} + + TanStack Table + +

+
+ ) +} + +function RowActions({ row }: { row: Row }) { + return ( + + +
+ +
+
+ + + + Edit + ⌘E + + + Duplicate + ⌘D + + + + + + Archive + ⌘A + + + More + + + Move to project + Move to folder + + Advanced options + + + + + + + Share + Add to favorites + + + + Delete + ⌘⌫ + + +
+ ) +} diff --git a/src/components/magicui/confetti.tsx b/src/components/magicui/confetti.tsx index c386e5a..8a8ed77 100644 --- a/src/components/magicui/confetti.tsx +++ b/src/components/magicui/confetti.tsx @@ -7,7 +7,8 @@ import type { } from 'canvas-confetti' import type { ReactNode } from 'react' -import { Button, ButtonProps } from '@/components/ui/button' +import { Button } from '@/components/ui/button' +import { ButtonProps } from '@headlessui/react' import confetti from 'canvas-confetti' import React, { createContext, @@ -109,7 +110,7 @@ ConfettiComponent.displayName = 'Confetti' // Export as Confetti export const Confetti = ConfettiComponent -interface ConfettiButtonProps extends ButtonProps { +interface ConfettiButtonProps { options?: ConfettiOptions & ConfettiGlobalOptions & { canvas?: HTMLCanvasElement } children?: React.ReactNode diff --git a/src/components/readme.ai.md b/src/components/readme.ai.md new file mode 100644 index 0000000..3bd8f26 --- /dev/null +++ b/src/components/readme.ai.md @@ -0,0 +1,34 @@ +# Components Directory Overview + +This directory contains all the React components used throughout the application. It's organized to promote reusability, maintainability, and a consistent user interface. + +## Directory Structure + +- `app/` - Application-specific components +- `magicui/` - Advanced UI components with animations and effects +- `ui/` - Basic UI components built on shadcn/ui +- `otherslib.../` - Future lib used in the app, template etc. + +## Key Concepts + +- **Component Hierarchy**: Organized from basic UI elements to complex compositions +- **Reusability**: Components designed for reuse across the application +- **Client vs Server Components**: Separation based on interactivity needs +- **UI Consistency**: Common design language across components + +## Do's and Don'ts + +### Do + +- do not create them, we need to create / modify these elements only throught the npx commands with ShadCn + +### Don't + +- Create duplicate components with similar functionality +- Mix client and server code inappropriately + +## For AI Assistants + +When working with this directory: + +- do not create them, we need to create / modify these elements only throught the npx commands with ShadCn diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..7935a73 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..81c46be --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-1.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] transition-[color,box-shadow] [&>svg]:shrink-0 leading-normal", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 0000000..857e60b --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,107 @@ +'use client' + +import { buttonVariants } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import * as React from 'react' +import { CustomComponents, DayPicker } from 'react-day-picker' + +export type CalendarProps = React.ComponentProps + +function Calendar({ + className, + classNames, + components: userComponents, + showOutsideDays = true, + ...props +}: CalendarProps) { + const defaultClassNames = { + button_next: cn( + buttonVariants({ variant: 'ghost' }), + 'size-9 text-muted-foreground/80 hover:text-foreground p-0' + ), + button_previous: cn( + buttonVariants({ variant: 'ghost' }), + 'size-9 text-muted-foreground/80 hover:text-foreground p-0' + ), + caption_label: 'text-sm font-medium', + day: 'group size-9 px-0 text-sm', + day_button: + 'relative flex size-9 items-center justify-center whitespace-nowrap rounded-lg p-0 text-foreground outline-offset-2 group-[[data-selected]:not(.range-middle)]:[transition-property:color,background-color,border-radius,box-shadow] group-[[data-selected]:not(.range-middle)]:duration-150 focus:outline-none group-data-[disabled]:pointer-events-none focus-visible:z-10 hover:bg-accent group-data-[selected]:bg-primary hover:text-foreground group-data-[selected]:text-primary-foreground group-data-[disabled]:text-foreground/30 group-data-[disabled]:line-through group-data-[outside]:text-foreground/30 group-data-[outside]:group-data-[selected]:text-primary-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 group-[.range-start:not(.range-end)]:rounded-e-none group-[.range-end:not(.range-start)]:rounded-s-none group-[.range-middle]:rounded-none group-data-[selected]:group-[.range-middle]:bg-accent group-data-[selected]:group-[.range-middle]:text-foreground', + hidden: 'invisible', + month: 'w-full', + month_caption: + 'relative mx-10 mb-1 flex h-9 items-center justify-center z-20', + months: 'relative flex flex-col sm:flex-row gap-4', + nav: 'absolute top-0 flex w-full justify-between z-10', + outside: + 'text-muted-foreground data-selected:bg-accent/50 data-selected:text-muted-foreground', + range_end: 'range-end', + range_middle: 'range-middle', + range_start: 'range-start', + today: + '*:after:pointer-events-none *:after:absolute *:after:bottom-1 *:after:start-1/2 *:after:z-10 *:after:size-[3px] *:after:-translate-x-1/2 *:after:rounded-full *:after:bg-primary [&[data-selected]:not(.range-middle)>*]:after:bg-background [&[data-disabled]>*]:after:bg-foreground/30 *:after:transition-colors', + week_number: 'size-9 p-0 text-xs font-medium text-muted-foreground/80', + weekday: 'size-9 p-0 text-xs font-medium text-muted-foreground/80', + } + + const mergedClassNames: typeof defaultClassNames = Object.keys( + defaultClassNames + ).reduce( + (acc, key) => ({ + ...acc, + [key]: classNames?.[key as keyof typeof classNames] + ? cn( + defaultClassNames[key as keyof typeof defaultClassNames], + classNames[key as keyof typeof classNames] + ) + : defaultClassNames[key as keyof typeof defaultClassNames], + }), + {} as typeof defaultClassNames + ) + + const defaultComponents = { + Chevron: (props: { + orientation: 'left' | 'right' + size?: number + className?: string + }) => { + if (props.orientation === 'left') { + return ( +