diff --git a/.gitignore b/.gitignore index 4a70fa51a1..09f569ab22 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ output/playwright/ screenshots/ !**/public/screenshots/ !**/assets/screenshots/ + +# local graphify knowledge-graph output (never commit) +**/graphify-out/ diff --git a/packages/twenty-apps/internal/twenty-partners/.gitignore b/packages/twenty-apps/internal/twenty-partners/.gitignore index ff704a82b0..60aa5f23e9 100644 --- a/packages/twenty-apps/internal/twenty-partners/.gitignore +++ b/packages/twenty-apps/internal/twenty-partners/.gitignore @@ -41,3 +41,6 @@ yarn-error.log* # typescript *.tsbuildinfo *.d.ts + +# local graphify index (never commit — run `graphify update .` locally) +graphify-out/ diff --git a/packages/twenty-apps/internal/twenty-partners/package.json b/packages/twenty-apps/internal/twenty-partners/package.json index 0694725d0d..1b303577c6 100644 --- a/packages/twenty-apps/internal/twenty-partners/package.json +++ b/packages/twenty-apps/internal/twenty-partners/package.json @@ -1,6 +1,6 @@ { "name": "twenty-partners", - "version": "1.2.10", + "version": "1.3.0", "license": "MIT", "engines": { "node": "^24.5.0", @@ -25,6 +25,7 @@ "rls:configure:prod": "ENV_FILE=.env.prod tsx src/scripts/configure-partner-rls.ts" }, "dependencies": { + "react-markdown": "^10.1.0", "twenty-client-sdk": "2.21.0", "twenty-sdk": "2.21.0", "zod": "^4.1.11" @@ -32,6 +33,7 @@ "devDependencies": { "@types/node": "^24.7.2", "@types/react": "^19.0.0", + "@types/react-dom": "^19", "dotenv": "^16.0.0", "oxlint": "^0.16.0", "react": "^19.0.0", diff --git a/packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-content.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-content.integration-test.ts new file mode 100644 index 0000000000..55f7d7fa76 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-content.integration-test.ts @@ -0,0 +1,197 @@ +// resolvePartnerFromRequest only base64-decodes the bearer token (no signature +// check), so a fake unsigned JWT is enough to drive the handler against a real +// workspace member — same trick as resolve-partner-from-request.test.ts. +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { handler } from 'src/logic-functions/save-my-partner-content.logic-function'; + +const makeToken = (payload: Record): string => + `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`; + +const requireId = (id: string | undefined, what: string): string => { + if (id === undefined) throw new Error(`${what} did not return an id`); + return id; +}; + +describe('save-my-partner-content handler', () => { + let client: CoreApiClient; + let partnerId: string; + let otherPartnerId: string; + let userWorkspaceId: string; + let keepId: string; + let dropId: string; + let otherContentId: string; + const originalToken = process.env.TWENTY_APP_ACCESS_TOKEN; + + beforeAll(async () => { + client = new CoreApiClient(); + + const memberResult = await client.query({ + workspaceMembers: { + __args: { filter: { userId: { is: 'NOT_NULL' } }, first: 1 }, + edges: { node: { id: true, userId: true } }, + }, + }); + const member = memberResult.workspaceMembers?.edges?.[0]?.node; + if (!member?.userId) { + throw new Error('No workspace member with a linked userId found to drive this test.'); + } + userWorkspaceId = 'integration-test-workspace'; + process.env.TWENTY_APP_ACCESS_TOKEN = makeToken({ + userId: member.userId, + userWorkspaceId, + }); + + const partnerCreated = await client.mutation({ + createPartner: { + __args: { + data: { name: 'Content Integration Test Partner', partnerUserId: member.id }, + }, + id: true, + }, + }); + partnerId = requireId(partnerCreated.createPartner?.id, 'createPartner'); + + const otherPartnerCreated = await client.mutation({ + createPartner: { + __args: { data: { name: 'Other Partner (content test)' } }, + id: true, + }, + }); + otherPartnerId = requireId(otherPartnerCreated.createPartner?.id, 'createPartner'); + + // Seeded APPROVED — proves an edit through this route never resets status. + const keep = await client.mutation({ + createPartnerContent: { + __args: { + data: { + partnerId, + name: 'Keep me (edited)', + contentType: ['CASE_STUDY'], + status: 'APPROVED', + clientName: 'Acme Corp', + headline: 'Original headline', + body: { markdown: 'Original body' }, + }, + }, + id: true, + }, + }); + keepId = requireId(keep.createPartnerContent?.id, 'createPartnerContent'); + + const drop = await client.mutation({ + createPartnerContent: { + __args: { + data: { + partnerId, + name: 'Drop me', + contentType: ['CASE_STUDY'], + status: 'WIP', + }, + }, + id: true, + }, + }); + dropId = requireId(drop.createPartnerContent?.id, 'createPartnerContent'); + + const otherContent = await client.mutation({ + createPartnerContent: { + __args: { + data: { + partnerId: otherPartnerId, + name: 'Belongs to someone else', + contentType: ['CASE_STUDY'], + status: 'WIP', + }, + }, + id: true, + }, + }); + otherContentId = requireId(otherContent.createPartnerContent?.id, 'createPartnerContent'); + }); + + afterAll(async () => { + if (originalToken === undefined) delete process.env.TWENTY_APP_ACCESS_TOKEN; + else process.env.TWENTY_APP_ACCESS_TOKEN = originalToken; + + await client.mutation({ destroyPartnerContent: { __args: { id: keepId }, id: true } }); + await client.mutation({ destroyPartnerContent: { __args: { id: otherContentId }, id: true } }); + // dropId is deleted by the reconcile happy-path; clean it up in case that test bailed early + // (tolerate not-found — it is expected to be gone after a successful run). + if (dropId) { + await client + .mutation({ destroyPartnerContent: { __args: { id: dropId }, id: true } }) + .catch(() => {}); + } + await client.mutation({ destroyPartner: { __args: { id: partnerId }, id: true } }); + await client.mutation({ destroyPartner: { __args: { id: otherPartnerId }, id: true } }); + }); + + it('keeps+edits one (status untouched), creates one (WIP/CASE_STUDY), drops the omitted one', async () => { + const result = await handler({ + headers: {}, + queryStringParameters: {}, + pathParameters: {}, + body: { + caseStudies: [ + { + id: keepId, + name: 'Keep me (edited)', + clientName: 'Acme Corp', + headline: 'Updated headline', + bodyMarkdown: 'Updated body', + caseStudyLink: 'https://example.com/acme-updated', + }, + { name: 'Brand new case study', clientName: 'New Co', headline: 'Fresh win' }, + ], + }, + isBase64Encoded: false, + requestContext: { http: { method: 'POST', path: '/save-my-partner-content' } }, + userWorkspaceId, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.caseStudies).toHaveLength(2); + + const kept = result.caseStudies.find((row) => row.id === keepId); + expect(kept).toMatchObject({ headline: 'Updated headline', bodyMarkdown: 'Updated body' }); + // The seeded row was APPROVED — an edit through this route must never reset it. + expect(kept?.status).toBe('APPROVED'); + + expect(result.caseStudies.some((row) => row.id === dropId)).toBe(false); + + const created = result.caseStudies.find((row) => row.name === 'Brand new case study'); + expect(created).toBeDefined(); + expect(created?.status).toBe('WIP'); + + // contentType is stamped by the async trigger, which needs the creator's workspaceMemberId — + // absent under vitest (buildAppClient runs as the API key), so it can't fire for route-created + // rows here. Verify the draft persisted; trigger stamping is exercised where a real member exists. + const fetched = await client.query({ + partnerContents: { + __args: { filter: { id: { eq: created?.id ?? '' } }, first: 1 }, + edges: { node: { status: true } }, + }, + }); + expect(fetched.partnerContents?.edges?.[0]?.node?.status).toBe('WIP'); + }); + + it('refuses a case study id owned by another partner', async () => { + const result = await handler({ + headers: {}, + queryStringParameters: {}, + pathParameters: {}, + body: { + caseStudies: [{ id: otherContentId, name: 'Hijacked' }], + }, + isBase64Encoded: false, + requestContext: { http: { method: 'POST', path: '/save-my-partner-content' } }, + userWorkspaceId, + }); + + expect(result).toEqual({ ok: false, reason: 'FORBIDDEN' }); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-links.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-links.integration-test.ts new file mode 100644 index 0000000000..8cf4fda5e7 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-links.integration-test.ts @@ -0,0 +1,162 @@ +// Deferred: written for the batch pass, not run as part of this task. +// resolvePartnerFromRequest only base64-decodes the bearer token (no signature +// check), so a fake unsigned JWT is enough to drive the handler against a real +// workspace member — same trick as resolve-partner-from-request.test.ts. +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { handler } from 'src/logic-functions/save-my-partner-links.logic-function'; + +const makeToken = (payload: Record): string => + `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`; + +const requireId = (id: string | undefined, what: string): string => { + if (id === undefined) throw new Error(`${what} did not return an id`); + return id; +}; + +describe('save-my-partner-links handler', () => { + let client: CoreApiClient; + let partnerId: string; + let otherPartnerId: string; + let userWorkspaceId: string; + let keepId: string; + let dropId: string; + let otherLinkId: string; + const originalToken = process.env.TWENTY_APP_ACCESS_TOKEN; + + beforeAll(async () => { + client = new CoreApiClient(); + + const memberResult = await client.query({ + workspaceMembers: { + __args: { filter: { userId: { is: 'NOT_NULL' } }, first: 1 }, + edges: { node: { id: true, userId: true } }, + }, + }); + const member = memberResult.workspaceMembers?.edges?.[0]?.node; + if (!member?.userId) { + throw new Error('No workspace member with a linked userId found to drive this test.'); + } + userWorkspaceId = 'integration-test-workspace'; + process.env.TWENTY_APP_ACCESS_TOKEN = makeToken({ + userId: member.userId, + userWorkspaceId, + }); + + const partnerCreated = await client.mutation({ + createPartner: { + __args: { + data: { name: 'Links Integration Test Partner', partnerUserId: member.id }, + }, + id: true, + }, + }); + partnerId = requireId(partnerCreated.createPartner?.id, 'createPartner'); + + const otherPartnerCreated = await client.mutation({ + createPartner: { + __args: { data: { name: 'Other Partner (links test)' } }, + id: true, + }, + }); + otherPartnerId = requireId(otherPartnerCreated.createPartner?.id, 'createPartner'); + + const keep = await client.mutation({ + createPartnerLink: { + __args: { + data: { + partnerId, + name: 'Keep me (edited)', + url: { primaryLinkUrl: 'https://example.com/keep' }, + sortOrder: 0, + }, + }, + id: true, + }, + }); + keepId = requireId(keep.createPartnerLink?.id, 'createPartnerLink'); + + const drop = await client.mutation({ + createPartnerLink: { + __args: { + data: { + partnerId, + name: 'Drop me', + url: { primaryLinkUrl: 'https://example.com/drop' }, + sortOrder: 1, + }, + }, + id: true, + }, + }); + dropId = requireId(drop.createPartnerLink?.id, 'createPartnerLink'); + + const otherLink = await client.mutation({ + createPartnerLink: { + __args: { + data: { + partnerId: otherPartnerId, + name: 'Belongs to someone else', + url: { primaryLinkUrl: 'https://example.com/other' }, + sortOrder: 0, + }, + }, + id: true, + }, + }); + otherLinkId = requireId(otherLink.createPartnerLink?.id, 'createPartnerLink'); + }); + + afterAll(async () => { + if (originalToken === undefined) delete process.env.TWENTY_APP_ACCESS_TOKEN; + else process.env.TWENTY_APP_ACCESS_TOKEN = originalToken; + + await client.mutation({ destroyPartnerLink: { __args: { id: keepId }, id: true } }); + await client.mutation({ destroyPartnerLink: { __args: { id: otherLinkId }, id: true } }); + await client.mutation({ destroyPartner: { __args: { id: partnerId }, id: true } }); + await client.mutation({ destroyPartner: { __args: { id: otherPartnerId }, id: true } }); + }); + + it('keeps+edits one, creates one, and drops the omitted one', async () => { + const result = await handler({ + headers: {}, + queryStringParameters: {}, + pathParameters: {}, + body: { + links: [ + { id: keepId, name: 'Keep me (edited)', url: 'https://example.com/keep-2', sortOrder: 0 }, + { name: 'Brand new link', url: 'https://example.com/new', sortOrder: 1 }, + ], + }, + isBase64Encoded: false, + requestContext: { http: { method: 'POST', path: '/save-my-partner-links' } }, + userWorkspaceId, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.links).toHaveLength(2); + const kept = result.links.find((link) => link.id === keepId); + expect(kept).toMatchObject({ name: 'Keep me (edited)', url: 'https://example.com/keep-2' }); + expect(result.links.some((link) => link.id === dropId)).toBe(false); + expect(result.links.some((link) => link.name === 'Brand new link')).toBe(true); + }); + + it('refuses a link id owned by another partner', async () => { + const result = await handler({ + headers: {}, + queryStringParameters: {}, + pathParameters: {}, + body: { + links: [{ id: otherLinkId, name: 'Hijacked', url: 'https://example.com/hijack' }], + }, + isBase64Encoded: false, + requestContext: { http: { method: 'POST', path: '/save-my-partner-links' } }, + userWorkspaceId, + }); + + expect(result).toEqual({ ok: false, reason: 'FORBIDDEN' }); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-services.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-services.integration-test.ts new file mode 100644 index 0000000000..feed89150e --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/__tests__/save-my-partner-services.integration-test.ts @@ -0,0 +1,169 @@ +// Deferred: written for the batch pass, not run as part of this task. +// resolvePartnerFromRequest only base64-decodes the bearer token (no signature +// check), so a fake unsigned JWT is enough to drive the handler against a real +// workspace member — same trick as resolve-partner-from-request.test.ts. +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { handler } from 'src/logic-functions/save-my-partner-services.logic-function'; + +const makeToken = (payload: Record): string => + `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`; + +const requireId = (id: string | undefined, what: string): string => { + if (id === undefined) throw new Error(`${what} did not return an id`); + return id; +}; + +describe('save-my-partner-services handler', () => { + let client: CoreApiClient; + let partnerId: string; + let otherPartnerId: string; + let userWorkspaceId: string; + let keepId: string; + let dropId: string; + let otherServiceId: string; + const originalToken = process.env.TWENTY_APP_ACCESS_TOKEN; + + beforeAll(async () => { + client = new CoreApiClient(); + + const memberResult = await client.query({ + workspaceMembers: { + __args: { filter: { userId: { is: 'NOT_NULL' } }, first: 1 }, + edges: { node: { id: true, userId: true } }, + }, + }); + const member = memberResult.workspaceMembers?.edges?.[0]?.node; + if (!member?.userId) { + throw new Error('No workspace member with a linked userId found to drive this test.'); + } + userWorkspaceId = 'integration-test-workspace'; + process.env.TWENTY_APP_ACCESS_TOKEN = makeToken({ + userId: member.userId, + userWorkspaceId, + }); + + const partnerCreated = await client.mutation({ + createPartner: { + __args: { + data: { name: 'Services Integration Test Partner', partnerUserId: member.id }, + }, + id: true, + }, + }); + partnerId = requireId(partnerCreated.createPartner?.id, 'createPartner'); + + const otherPartnerCreated = await client.mutation({ + createPartner: { + __args: { data: { name: 'Other Partner (services test)' } }, + id: true, + }, + }); + otherPartnerId = requireId(otherPartnerCreated.createPartner?.id, 'createPartner'); + + const keep = await client.mutation({ + createPartnerService: { + __args: { + data: { + partnerId, + title: 'Keep me (edited)', + description: 'Original description', + sortOrder: 0, + }, + }, + id: true, + }, + }); + keepId = requireId(keep.createPartnerService?.id, 'createPartnerService'); + + const drop = await client.mutation({ + createPartnerService: { + __args: { + data: { + partnerId, + title: 'Drop me', + description: 'Will be removed', + sortOrder: 1, + }, + }, + id: true, + }, + }); + dropId = requireId(drop.createPartnerService?.id, 'createPartnerService'); + + const otherService = await client.mutation({ + createPartnerService: { + __args: { + data: { + partnerId: otherPartnerId, + title: 'Belongs to someone else', + description: 'Not owned by the caller', + sortOrder: 0, + }, + }, + id: true, + }, + }); + otherServiceId = requireId(otherService.createPartnerService?.id, 'createPartnerService'); + }); + + afterAll(async () => { + if (originalToken === undefined) delete process.env.TWENTY_APP_ACCESS_TOKEN; + else process.env.TWENTY_APP_ACCESS_TOKEN = originalToken; + + await client.mutation({ destroyPartnerService: { __args: { id: keepId }, id: true } }); + await client.mutation({ destroyPartnerService: { __args: { id: otherServiceId }, id: true } }); + await client.mutation({ destroyPartner: { __args: { id: partnerId }, id: true } }); + await client.mutation({ destroyPartner: { __args: { id: otherPartnerId }, id: true } }); + }); + + it('keeps+edits one, creates one, and drops the omitted one', async () => { + const result = await handler({ + headers: {}, + queryStringParameters: {}, + pathParameters: {}, + body: { + services: [ + { + id: keepId, + title: 'Keep me (edited)', + description: 'Updated description', + sortOrder: 0, + }, + { title: 'Brand new service', description: 'Freshly added', sortOrder: 1 }, + ], + }, + isBase64Encoded: false, + requestContext: { http: { method: 'POST', path: '/save-my-partner-services' } }, + userWorkspaceId, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.services).toHaveLength(2); + const kept = result.services.find((service) => service.id === keepId); + expect(kept).toMatchObject({ title: 'Keep me (edited)', description: 'Updated description' }); + expect(result.services.some((service) => service.id === dropId)).toBe(false); + expect(result.services.some((service) => service.title === 'Brand new service')).toBe(true); + }); + + it('refuses a service id owned by another partner', async () => { + const result = await handler({ + headers: {}, + queryStringParameters: {}, + pathParameters: {}, + body: { + services: [ + { id: otherServiceId, title: 'Hijacked', description: 'Should be refused' }, + ], + }, + isBase64Encoded: false, + requestContext: { http: { method: 'POST', path: '/save-my-partner-services' } }, + userWorkspaceId, + }); + + expect(result).toEqual({ ok: false, reason: 'FORBIDDEN' }); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/__tests__/submit-partner-content-for-review.integration-test.ts b/packages/twenty-apps/internal/twenty-partners/src/__tests__/submit-partner-content-for-review.integration-test.ts new file mode 100644 index 0000000000..bbd79e0d66 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/__tests__/submit-partner-content-for-review.integration-test.ts @@ -0,0 +1,163 @@ +// Deferred: written for the batch pass, not run as part of this task. +// resolvePartnerFromRequest only base64-decodes the bearer token (no signature +// check), so a fake unsigned JWT is enough to drive the handler against a real +// workspace member — same trick as resolve-partner-from-request.test.ts. +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { handler } from 'src/logic-functions/submit-partner-content-for-review.logic-function'; + +const makeToken = (payload: Record): string => + `header.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`; + +const requireId = (id: string | undefined, what: string): string => { + if (id === undefined) throw new Error(`${what} did not return an id`); + return id; +}; + +describe('submit-partner-content-for-review handler', () => { + let client: CoreApiClient; + let partnerId: string; + let otherPartnerId: string; + let userWorkspaceId: string; + let wipId: string; + let approvedId: string; + let otherWipId: string; + const originalToken = process.env.TWENTY_APP_ACCESS_TOKEN; + + beforeAll(async () => { + client = new CoreApiClient(); + + const memberResult = await client.query({ + workspaceMembers: { + __args: { filter: { userId: { is: 'NOT_NULL' } }, first: 1 }, + edges: { node: { id: true, userId: true } }, + }, + }); + const member = memberResult.workspaceMembers?.edges?.[0]?.node; + if (!member?.userId) { + throw new Error('No workspace member with a linked userId found to drive this test.'); + } + userWorkspaceId = 'integration-test-workspace'; + process.env.TWENTY_APP_ACCESS_TOKEN = makeToken({ + userId: member.userId, + userWorkspaceId, + }); + + const partnerCreated = await client.mutation({ + createPartner: { + __args: { + data: { name: 'Submit-For-Review Integration Test Partner', partnerUserId: member.id }, + }, + id: true, + }, + }); + partnerId = requireId(partnerCreated.createPartner?.id, 'createPartner'); + + const otherPartnerCreated = await client.mutation({ + createPartner: { + __args: { data: { name: 'Other Partner (submit-for-review test)' } }, + id: true, + }, + }); + otherPartnerId = requireId(otherPartnerCreated.createPartner?.id, 'createPartner'); + + const wip = await client.mutation({ + createPartnerContent: { + __args: { + data: { + partnerId, + name: 'WIP case study', + contentType: ['CASE_STUDY'], + status: 'WIP', + }, + }, + id: true, + }, + }); + wipId = requireId(wip.createPartnerContent?.id, 'createPartnerContent'); + + const approved = await client.mutation({ + createPartnerContent: { + __args: { + data: { + partnerId, + name: 'Already approved case study', + contentType: ['CASE_STUDY'], + status: 'APPROVED', + }, + }, + id: true, + }, + }); + approvedId = requireId(approved.createPartnerContent?.id, 'createPartnerContent'); + + const otherWip = await client.mutation({ + createPartnerContent: { + __args: { + data: { + partnerId: otherPartnerId, + name: 'Belongs to someone else (WIP)', + contentType: ['CASE_STUDY'], + status: 'WIP', + }, + }, + id: true, + }, + }); + otherWipId = requireId(otherWip.createPartnerContent?.id, 'createPartnerContent'); + }); + + afterAll(async () => { + if (originalToken === undefined) delete process.env.TWENTY_APP_ACCESS_TOKEN; + else process.env.TWENTY_APP_ACCESS_TOKEN = originalToken; + + // Guard each id: if beforeAll threw partway through, the unset ones stay undefined — + // skip them so cleanup still reaches every record that was actually created. + for (const id of [wipId, approvedId, otherWipId]) { + if (id) await client.mutation({ destroyPartnerContent: { __args: { id }, id: true } }); + } + for (const id of [partnerId, otherPartnerId]) { + if (id) await client.mutation({ destroyPartner: { __args: { id }, id: true } }); + } + }); + + const callHandler = (recordId: string) => + handler({ + headers: {}, + queryStringParameters: {}, + pathParameters: {}, + body: { recordId }, + isBase64Encoded: false, + requestContext: { + http: { method: 'POST', path: '/submit-partner-content-for-review' }, + }, + userWorkspaceId, + }); + + it('flips a WIP row to UNDER_CUSTOMER_PARTNER_REVIEW', async () => { + const result = await callHandler(wipId); + + expect(result).toEqual({ ok: true, status: 'UNDER_CUSTOMER_PARTNER_REVIEW' }); + + const fetched = await client.query({ + partnerContents: { + __args: { filter: { id: { eq: wipId } }, first: 1 }, + edges: { node: { status: true } }, + }, + }); + expect(fetched.partnerContents?.edges?.[0]?.node?.status).toBe( + 'UNDER_CUSTOMER_PARTNER_REVIEW', + ); + }); + + it('refuses a non-WIP row', async () => { + const result = await callHandler(approvedId); + expect(result).toEqual({ ok: false, reason: 'NOT_SUBMITTABLE' }); + }); + + it('refuses a row owned by another partner', async () => { + const result = await callHandler(otherWipId); + expect(result).toEqual({ ok: false, reason: 'FORBIDDEN' }); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/constants/my-case-studies.constants.ts b/packages/twenty-apps/internal/twenty-partners/src/constants/my-case-studies.constants.ts new file mode 100644 index 0000000000..57219a70ff --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/constants/my-case-studies.constants.ts @@ -0,0 +1,5 @@ +// Plain-data ids for the My Case Studies page, mirroring my-profile.constants.ts. +export const MY_CASE_STUDIES_FRONT_COMPONENT_ID = '1e13a0e5-4dd7-44a6-aaff-081df22f322d'; +export const MY_CASE_STUDIES_PAGE_LAYOUT_ID = 'f5acd909-d066-4add-9cf7-2c4eb8f1c1d0'; +export const MY_CASE_STUDIES_PAGE_TAB_ID = '85c4ec5f-3c04-4e6f-a0e5-4f494f788eea'; +export const MY_CASE_STUDIES_PAGE_WIDGET_ID = 'ec2af263-500e-4256-9324-951fdcad9682'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/constants/my-profile.constants.ts b/packages/twenty-apps/internal/twenty-partners/src/constants/my-profile.constants.ts new file mode 100644 index 0000000000..38141c903d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/constants/my-profile.constants.ts @@ -0,0 +1,275 @@ +// Plain-data constants shared by the my-profile logic function and the +// front component — no SDK imports so both sides can consume this directly. + +export const MY_PROFILE_FRONT_COMPONENT_ID = '92fdaa35-9fc7-4e2d-8db7-77d7f624cec6'; +export const MY_PROFILE_PAGE_LAYOUT_ID = 'efc8a005-57db-44f3-a888-ce3a6aec526b'; +export const MY_PROFILE_PAGE_TAB_ID = 'ff8185d0-cd72-477b-a1a5-77d9cfff6fef'; +export const MY_PROFILE_PAGE_WIDGET_ID = 'abf2a5af-d772-44a6-895b-fd08d741978d'; +export const MY_PROFILE_NAV_ITEM_ID = '6c39ddd9-d0c2-4951-8be5-d064c752d18a'; + +export type SelectOption = { value: string; label: string }; + +export type ProfileOptions = { + country: SelectOption[]; + languagesSpoken: SelectOption[]; + partnerScope: SelectOption[]; + typeOfTeam: SelectOption[]; + availability: SelectOption[]; +}; + +// These mirror the option lists declared on the Partner object (partner.object.ts). +// If they ever drift, the upgrade path is to fetch field-metadata options at +// runtime instead of duplicating them here — not worth it now. +export const PROFILE_OPTIONS: ProfileOptions = { + partnerScope: [ + { value: 'ADVISORY', label: 'Advisory & Discovery' }, + { value: 'SOLUTIONING', label: 'Solutioning' }, + { value: 'DEVELOPMENT', label: 'Custom Development' }, + { value: 'HOSTING', label: 'Hosting & Infrastructure' }, + { value: 'SUPPORT', label: 'Training & Adoption' }, + ], + typeOfTeam: [ + { value: 'SOLO', label: 'Solo' }, + { value: 'AGENCY', label: 'Agency' }, + ], + availability: [ + { value: 'AVAILABLE', label: 'Available' }, + { value: 'UNAVAILABLE', label: 'Unavailable' }, + ], + languagesSpoken: [ + { value: 'ENGLISH', label: 'English' }, + { value: 'FRENCH', label: 'French' }, + { value: 'GERMAN', label: 'German' }, + { value: 'CHINESE', label: 'Chinese' }, + { value: 'SPANISH', label: 'Spanish' }, + { value: 'ARABIC', label: 'Arabic' }, + { value: 'BENGALI', label: 'Bengali' }, + { value: 'CATALAN', label: 'Catalan' }, + { value: 'CZECH', label: 'Czech' }, + { value: 'DANISH', label: 'Danish' }, + { value: 'DUTCH', label: 'Dutch' }, + { value: 'FARSI', label: 'Farsi' }, + { value: 'FINNISH', label: 'Finnish' }, + { value: 'GREEK', label: 'Greek' }, + { value: 'HINDI', label: 'Hindi' }, + { value: 'INDONESIAN', label: 'Indonesian' }, + { value: 'ITALIAN', label: 'Italian' }, + { value: 'JAPANESE', label: 'Japanese' }, + { value: 'KOREAN', label: 'Korean' }, + { value: 'MALAY', label: 'Malay' }, + { value: 'NORWEGIAN', label: 'Norwegian' }, + { value: 'POLISH', label: 'Polish' }, + { value: 'PORTUGUESE', label: 'Portuguese' }, + { value: 'PUNJABI', label: 'Punjabi' }, + { value: 'ROMANIAN', label: 'Romanian' }, + { value: 'RUSSIAN', label: 'Russian' }, + { value: 'SWAHILI', label: 'Swahili' }, + { value: 'SWEDISH', label: 'Swedish' }, + { value: 'TAGALOG', label: 'Tagalog' }, + { value: 'TAMIL', label: 'Tamil' }, + { value: 'THAI', label: 'Thai' }, + { value: 'TURKISH', label: 'Turkish' }, + { value: 'UKRAINIAN', label: 'Ukrainian' }, + { value: 'URDU', label: 'Urdu' }, + { value: 'VIETNAMESE', label: 'Vietnamese' }, + ], + country: [ + { value: 'AFGHANISTAN', label: 'Afghanistan 🇦🇫' }, + { value: 'ALBANIA', label: 'Albania 🇦🇱' }, + { value: 'ALGERIA', label: 'Algeria 🇩🇿' }, + { value: 'ANDORRA', label: 'Andorra 🇦🇩' }, + { value: 'ANGOLA', label: 'Angola 🇦🇴' }, + { value: 'ANTIGUA_AND_BARBUDA', label: 'Antigua & Barbuda 🇦🇬' }, + { value: 'ARGENTINA', label: 'Argentina 🇦🇷' }, + { value: 'ARMENIA', label: 'Armenia 🇦🇲' }, + { value: 'AUSTRALIA', label: 'Australia 🇦🇺' }, + { value: 'AUSTRIA', label: 'Austria 🇦🇹' }, + { value: 'AZERBAIJAN', label: 'Azerbaijan 🇦🇿' }, + { value: 'BAHAMAS', label: 'Bahamas 🇧🇸' }, + { value: 'BAHRAIN', label: 'Bahrain 🇧🇭' }, + { value: 'BANGLADESH', label: 'Bangladesh 🇧🇩' }, + { value: 'BARBADOS', label: 'Barbados 🇧🇧' }, + { value: 'BELARUS', label: 'Belarus 🇧🇾' }, + { value: 'BELGIUM', label: 'Belgium 🇧🇪' }, + { value: 'BELIZE', label: 'Belize 🇧🇿' }, + { value: 'BENIN', label: 'Benin 🇧🇯' }, + { value: 'BHUTAN', label: 'Bhutan 🇧🇹' }, + { value: 'BOLIVIA', label: 'Bolivia 🇧🇴' }, + { value: 'BOSNIA_AND_HERZEGOVINA', label: 'Bosnia & Herzegovina 🇧🇦' }, + { value: 'BOTSWANA', label: 'Botswana 🇧🇼' }, + { value: 'BRAZIL', label: 'Brazil 🇧🇷' }, + { value: 'BRUNEI', label: 'Brunei 🇧🇳' }, + { value: 'BULGARIA', label: 'Bulgaria 🇧🇬' }, + { value: 'BURKINA_FASO', label: 'Burkina Faso 🇧🇫' }, + { value: 'BURUNDI', label: 'Burundi 🇧🇮' }, + { value: 'CAMBODIA', label: 'Cambodia 🇰🇭' }, + { value: 'CAMEROON', label: 'Cameroon 🇨🇲' }, + { value: 'CANADA', label: 'Canada 🇨🇦' }, + { value: 'CAPE_VERDE', label: 'Cape Verde 🇨🇻' }, + { value: 'CENTRAL_AFRICAN_REPUBLIC', label: 'Central African Republic 🇨🇫' }, + { value: 'CHAD', label: 'Chad 🇹🇩' }, + { value: 'CHILE', label: 'Chile 🇨🇱' }, + { value: 'CHINA', label: 'China 🇨🇳' }, + { value: 'COLOMBIA', label: 'Colombia 🇨🇴' }, + { value: 'COMOROS', label: 'Comoros 🇰🇲' }, + { value: 'CONGO', label: 'Congo 🇨🇬' }, + { value: 'DR_CONGO', label: 'DR Congo 🇨🇩' }, + { value: 'COSTA_RICA', label: 'Costa Rica 🇨🇷' }, + { value: 'CROATIA', label: 'Croatia 🇭🇷' }, + { value: 'CUBA', label: 'Cuba 🇨🇺' }, + { value: 'CYPRUS', label: 'Cyprus 🇨🇾' }, + { value: 'CZECH_REPUBLIC', label: 'Czech Republic 🇨🇿' }, + { value: 'DENMARK', label: 'Denmark 🇩🇰' }, + { value: 'DJIBOUTI', label: 'Djibouti 🇩🇯' }, + { value: 'DOMINICA', label: 'Dominica 🇩🇲' }, + { value: 'DOMINICAN_REPUBLIC', label: 'Dominican Republic 🇩🇴' }, + { value: 'ECUADOR', label: 'Ecuador 🇪🇨' }, + { value: 'EGYPT', label: 'Egypt 🇪🇬' }, + { value: 'EL_SALVADOR', label: 'El Salvador 🇸🇻' }, + { value: 'EQUATORIAL_GUINEA', label: 'Equatorial Guinea 🇬🇶' }, + { value: 'ERITREA', label: 'Eritrea 🇪🇷' }, + { value: 'ESTONIA', label: 'Estonia 🇪🇪' }, + { value: 'ESWATINI', label: 'Eswatini 🇸🇿' }, + { value: 'ETHIOPIA', label: 'Ethiopia 🇪🇹' }, + { value: 'FIJI', label: 'Fiji 🇫🇯' }, + { value: 'FINLAND', label: 'Finland 🇫🇮' }, + { value: 'FRANCE', label: 'France 🇫🇷' }, + { value: 'GABON', label: 'Gabon 🇬🇦' }, + { value: 'GAMBIA', label: 'Gambia 🇬🇲' }, + { value: 'GEORGIA', label: 'Georgia 🇬🇪' }, + { value: 'GERMANY', label: 'Germany 🇩🇪' }, + { value: 'GHANA', label: 'Ghana 🇬🇭' }, + { value: 'GREECE', label: 'Greece 🇬🇷' }, + { value: 'GRENADA', label: 'Grenada 🇬🇩' }, + { value: 'GUATEMALA', label: 'Guatemala 🇬🇹' }, + { value: 'GUINEA', label: 'Guinea 🇬🇳' }, + { value: 'GUINEA_BISSAU', label: 'Guinea-Bissau 🇬🇼' }, + { value: 'GUYANA', label: 'Guyana 🇬🇾' }, + { value: 'HAITI', label: 'Haiti 🇭🇹' }, + { value: 'HONDURAS', label: 'Honduras 🇭🇳' }, + { value: 'HUNGARY', label: 'Hungary 🇭🇺' }, + { value: 'ICELAND', label: 'Iceland 🇮🇸' }, + { value: 'INDIA', label: 'India 🇮🇳' }, + { value: 'INDONESIA', label: 'Indonesia 🇮🇩' }, + { value: 'IRAN', label: 'Iran 🇮🇷' }, + { value: 'IRAQ', label: 'Iraq 🇮🇶' }, + { value: 'IRELAND', label: 'Ireland 🇮🇪' }, + { value: 'ISRAEL', label: 'Israel 🇮🇱' }, + { value: 'ITALY', label: 'Italy 🇮🇹' }, + { value: 'IVORY_COAST', label: 'Ivory Coast 🇨🇮' }, + { value: 'JAMAICA', label: 'Jamaica 🇯🇲' }, + { value: 'JAPAN', label: 'Japan 🇯🇵' }, + { value: 'JORDAN', label: 'Jordan 🇯🇴' }, + { value: 'KAZAKHSTAN', label: 'Kazakhstan 🇰🇿' }, + { value: 'KENYA', label: 'Kenya 🇰🇪' }, + { value: 'KIRIBATI', label: 'Kiribati 🇰🇮' }, + { value: 'KOSOVO', label: 'Kosovo 🇽🇰' }, + { value: 'KUWAIT', label: 'Kuwait 🇰🇼' }, + { value: 'KYRGYZSTAN', label: 'Kyrgyzstan 🇰🇬' }, + { value: 'LAOS', label: 'Laos 🇱🇦' }, + { value: 'LATVIA', label: 'Latvia 🇱🇻' }, + { value: 'LEBANON', label: 'Lebanon 🇱🇧' }, + { value: 'LESOTHO', label: 'Lesotho 🇱🇸' }, + { value: 'LIBERIA', label: 'Liberia 🇱🇷' }, + { value: 'LIBYA', label: 'Libya 🇱🇾' }, + { value: 'LIECHTENSTEIN', label: 'Liechtenstein 🇱🇮' }, + { value: 'LITHUANIA', label: 'Lithuania 🇱🇹' }, + { value: 'LUXEMBOURG', label: 'Luxembourg 🇱🇺' }, + { value: 'MADAGASCAR', label: 'Madagascar 🇲🇬' }, + { value: 'MALAWI', label: 'Malawi 🇲🇼' }, + { value: 'MALAYSIA', label: 'Malaysia 🇲🇾' }, + { value: 'MALDIVES', label: 'Maldives 🇲🇻' }, + { value: 'MALI', label: 'Mali 🇲🇱' }, + { value: 'MALTA', label: 'Malta 🇲🇹' }, + { value: 'MARSHALL_ISLANDS', label: 'Marshall Islands 🇲🇭' }, + { value: 'MAURITANIA', label: 'Mauritania 🇲🇷' }, + { value: 'MAURITIUS', label: 'Mauritius 🇲🇺' }, + { value: 'MEXICO', label: 'Mexico 🇲🇽' }, + { value: 'MICRONESIA', label: 'Micronesia 🇫🇲' }, + { value: 'MOLDOVA', label: 'Moldova 🇲🇩' }, + { value: 'MONACO', label: 'Monaco 🇲🇨' }, + { value: 'MONGOLIA', label: 'Mongolia 🇲🇳' }, + { value: 'MONTENEGRO', label: 'Montenegro 🇲🇪' }, + { value: 'MOROCCO', label: 'Morocco 🇲🇦' }, + { value: 'MOZAMBIQUE', label: 'Mozambique 🇲🇿' }, + { value: 'MYANMAR', label: 'Myanmar 🇲🇲' }, + { value: 'NAMIBIA', label: 'Namibia 🇳🇦' }, + { value: 'NAURU', label: 'Nauru 🇳🇷' }, + { value: 'NEPAL', label: 'Nepal 🇳🇵' }, + { value: 'NETHERLANDS', label: 'Netherlands 🇳🇱' }, + { value: 'NEW_ZEALAND', label: 'New Zealand 🇳🇿' }, + { value: 'NICARAGUA', label: 'Nicaragua 🇳🇮' }, + { value: 'NIGER', label: 'Niger 🇳🇪' }, + { value: 'NIGERIA', label: 'Nigeria 🇳🇬' }, + { value: 'NORTH_KOREA', label: 'North Korea 🇰🇵' }, + { value: 'NORTH_MACEDONIA', label: 'North Macedonia 🇲🇰' }, + { value: 'NORWAY', label: 'Norway 🇳🇴' }, + { value: 'OMAN', label: 'Oman 🇴🇲' }, + { value: 'PAKISTAN', label: 'Pakistan 🇵🇰' }, + { value: 'PALAU', label: 'Palau 🇵🇼' }, + { value: 'PALESTINE', label: 'Palestine 🇵🇸' }, + { value: 'PANAMA', label: 'Panama 🇵🇦' }, + { value: 'PAPUA_NEW_GUINEA', label: 'Papua New Guinea 🇵🇬' }, + { value: 'PARAGUAY', label: 'Paraguay 🇵🇾' }, + { value: 'PERU', label: 'Peru 🇵🇪' }, + { value: 'PHILIPPINES', label: 'Philippines 🇵🇭' }, + { value: 'POLAND', label: 'Poland 🇵🇱' }, + { value: 'PORTUGAL', label: 'Portugal 🇵🇹' }, + { value: 'QATAR', label: 'Qatar 🇶🇦' }, + { value: 'ROMANIA', label: 'Romania 🇷🇴' }, + { value: 'RUSSIA', label: 'Russia 🇷🇺' }, + { value: 'RWANDA', label: 'Rwanda 🇷🇼' }, + { value: 'SAINT_KITTS_AND_NEVIS', label: 'Saint Kitts & Nevis 🇰🇳' }, + { value: 'SAINT_LUCIA', label: 'Saint Lucia 🇱🇨' }, + { value: 'SAINT_VINCENT', label: 'Saint Vincent 🇻🇨' }, + { value: 'SAMOA', label: 'Samoa 🇼🇸' }, + { value: 'SAN_MARINO', label: 'San Marino 🇸🇲' }, + { value: 'SAO_TOME_AND_PRINCIPE', label: 'São Tomé & Príncipe 🇸🇹' }, + { value: 'SAUDI_ARABIA', label: 'Saudi Arabia 🇸🇦' }, + { value: 'SENEGAL', label: 'Senegal 🇸🇳' }, + { value: 'SERBIA', label: 'Serbia 🇷🇸' }, + { value: 'SEYCHELLES', label: 'Seychelles 🇸🇨' }, + { value: 'SIERRA_LEONE', label: 'Sierra Leone 🇸🇱' }, + { value: 'SINGAPORE', label: 'Singapore 🇸🇬' }, + { value: 'SLOVAKIA', label: 'Slovakia 🇸🇰' }, + { value: 'SLOVENIA', label: 'Slovenia 🇸🇮' }, + { value: 'SOLOMON_ISLANDS', label: 'Solomon Islands 🇸🇧' }, + { value: 'SOMALIA', label: 'Somalia 🇸🇴' }, + { value: 'SOUTH_AFRICA', label: 'South Africa 🇿🇦' }, + { value: 'SOUTH_KOREA', label: 'South Korea 🇰🇷' }, + { value: 'SOUTH_SUDAN', label: 'South Sudan 🇸🇸' }, + { value: 'SPAIN', label: 'Spain 🇪🇸' }, + { value: 'SRI_LANKA', label: 'Sri Lanka 🇱🇰' }, + { value: 'SUDAN', label: 'Sudan 🇸🇩' }, + { value: 'SURINAME', label: 'Suriname 🇸🇷' }, + { value: 'SWEDEN', label: 'Sweden 🇸🇪' }, + { value: 'SWITZERLAND', label: 'Switzerland 🇨🇭' }, + { value: 'SYRIA', label: 'Syria 🇸🇾' }, + { value: 'TAIWAN', label: 'Taiwan 🇹🇼' }, + { value: 'TAJIKISTAN', label: 'Tajikistan 🇹🇯' }, + { value: 'TANZANIA', label: 'Tanzania 🇹🇿' }, + { value: 'THAILAND', label: 'Thailand 🇹🇭' }, + { value: 'TIMOR_LESTE', label: 'Timor-Leste 🇹🇱' }, + { value: 'TOGO', label: 'Togo 🇹🇬' }, + { value: 'TONGA', label: 'Tonga 🇹🇴' }, + { value: 'TRINIDAD_AND_TOBAGO', label: 'Trinidad & Tobago 🇹🇹' }, + { value: 'TUNISIA', label: 'Tunisia 🇹🇳' }, + { value: 'TURKEY', label: 'Turkey 🇹🇷' }, + { value: 'TURKMENISTAN', label: 'Turkmenistan 🇹🇲' }, + { value: 'TUVALU', label: 'Tuvalu 🇹🇻' }, + { value: 'UGANDA', label: 'Uganda 🇺🇬' }, + { value: 'UKRAINE', label: 'Ukraine 🇺🇦' }, + { value: 'UNITED_ARAB_EMIRATES', label: 'UAE 🇦🇪' }, + { value: 'UNITED_KINGDOM', label: 'UK 🇬🇧' }, + { value: 'UNITED_STATES', label: 'USA 🇺🇸' }, + { value: 'URUGUAY', label: 'Uruguay 🇺🇾' }, + { value: 'UZBEKISTAN', label: 'Uzbekistan 🇺🇿' }, + { value: 'VANUATU', label: 'Vanuatu 🇻🇺' }, + { value: 'VATICAN', label: 'Vatican 🇻🇦' }, + { value: 'VENEZUELA', label: 'Venezuela 🇻🇪' }, + { value: 'VIETNAM', label: 'Vietnam 🇻🇳' }, + { value: 'YEMEN', label: 'Yemen 🇾🇪' }, + { value: 'ZAMBIA', label: 'Zambia 🇿🇲' }, + { value: 'ZIMBABWE', label: 'Zimbabwe 🇿🇼' }, + ], +}; diff --git a/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts b/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts index 48b27d3a84..e1ab29c441 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/constants/universal-identifiers.ts @@ -4,9 +4,16 @@ export const APPLICATION_UNIVERSAL_IDENTIFIER = 'e662fc1f-02c1-41ff-b8ba-c95a447 export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'ee18c3f3-ebe7-4c56-ad6d-aad555cc32db'; export const PARTNER_OBJECT_UNIVERSAL_IDENTIFIER = '39101b39-1c16-4148-9e82-45dc271bb90d'; export const PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd'; +export const PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER = + '91c1bf51-f977-4ca5-9974-954fe23d1c1c'; +export const PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER = + 'cc953277-db73-45c5-9e6d-3967f9b7756f'; export const PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '3fe15ab5-e38b-4914-af17-2270b210aeb2'; export const POST_INSTALL_FN_UNIVERSAL_IDENTIFIER = 'f92bad2e-5905-4757-96ee-af9869d4ca0c'; export const ON_PARTNER_APPLICATION_CREATED_FN_UNIVERSAL_IDENTIFIER = '43888cce-a2aa-4100-afbc-59a4f978ce53'; +export const ON_PARTNER_LINK_CREATED_FN_UNIVERSAL_IDENTIFIER = 'a80a85f8-e32b-429e-82e9-b71cb5da54cd'; +export const ON_PARTNER_SERVICE_CREATED_FN_UNIVERSAL_IDENTIFIER = '20129ceb-981e-43da-a25d-0ff6fbe52060'; +export const ON_PARTNER_CONTENT_CREATED_FN_UNIVERSAL_IDENTIFIER = '18d0f329-d4a6-4b82-87ea-b78aa1a73e47'; export const INTRO_SENT_AT_FIELD_UNIVERSAL_IDENTIFIER = 'fcf39b0c-0547-415e-806d-b238131ad7cc'; // Roles (Task 2) @@ -40,6 +47,21 @@ export const PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER = '13e2334a-6b1e-4080 export const VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '6aed30c6-d80f-4ac6-aab0-db5bc59e5c4b'; export const PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b'; +export const MY_SERVICES_VIEW_UNIVERSAL_IDENTIFIER = + '04d85f9e-342b-4887-bbee-4d52b1dd5cb3'; +export const MY_SERVICES_NAV_UNIVERSAL_IDENTIFIER = + '3cd81fb0-e6b9-4669-90d9-caf3ca6e8841'; + +export const MY_LINKS_VIEW_UNIVERSAL_IDENTIFIER = + '40def5de-e90a-4542-8512-2b63bae248dc'; +export const MY_LINKS_NAV_UNIVERSAL_IDENTIFIER = + 'bd7b422c-0201-4bef-9170-0a415150d29b'; + +export const MY_CASE_STUDIES_VIEW_UNIVERSAL_IDENTIFIER = + 'e7c4a9b2-1d3f-4e5a-9b6c-8d7e6f5a4b3c'; +export const MY_CASE_STUDIES_NAV_UNIVERSAL_IDENTIFIER = + 'f8d5b0c3-2e41-5f6b-8c7d-9e8f7a6b5c4d'; + // Opportunity record page (standard side panel Fields widget) — view-field UIDs // reused from the former custom FIELDS_WIDGET view so sync updates in place. export const OPPORTUNITY_RECORD_PAGE_IS_LISTED_VIEW_FIELD_UNIVERSAL_IDENTIFIER = diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-as-partner-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-as-partner-user-on-workspace-member.field.ts new file mode 100644 index 0000000000..2f84345728 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-contents-as-partner-user-on-workspace-member.field.ts @@ -0,0 +1,29 @@ +import { + FieldType, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, + defineField, +} from 'twenty-sdk/define'; + +import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { + PARTNER_CONTENTS_AS_PARTNER_USER_FIELD_ID, + PARTNER_USER_ON_PARTNER_CONTENT_FIELD_ID, +} from './partner-user-on-partner-content.field'; + +export default defineField({ + universalIdentifier: PARTNER_CONTENTS_AS_PARTNER_USER_FIELD_ID, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + type: FieldType.RELATION, + name: 'partnerContentsAsPartnerUser', + label: 'Partner Content (as partner user)', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + PARTNER_USER_ON_PARTNER_CONTENT_FIELD_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-link-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-link-partner.field.ts new file mode 100644 index 0000000000..5599c69e08 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-link-partner.field.ts @@ -0,0 +1,27 @@ +import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define'; + +import { + PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER, + PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export const PARTNER_LINK_PARTNER_FIELD_ID = 'bae4dda8-8785-4867-a22f-dd66d805d68e'; +export const PARTNER_LINKS_ON_PARTNER_FIELD_ID = + '3fdc8a67-3881-43ef-8c43-4fa3ddaa819c'; + +export default defineField({ + universalIdentifier: PARTNER_LINK_PARTNER_FIELD_ID, + objectUniversalIdentifier: PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'partner', + label: 'Partner', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_LINKS_ON_PARTNER_FIELD_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'partnerId', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-links-as-partner-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-links-as-partner-user-on-workspace-member.field.ts new file mode 100644 index 0000000000..16c908bc93 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-links-as-partner-user-on-workspace-member.field.ts @@ -0,0 +1,29 @@ +import { + FieldType, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, + defineField, +} from 'twenty-sdk/define'; + +import { PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { + PARTNER_LINKS_AS_PARTNER_USER_FIELD_ID, + PARTNER_USER_ON_PARTNER_LINK_FIELD_ID, +} from './partner-user-on-partner-link.field'; + +export default defineField({ + universalIdentifier: PARTNER_LINKS_AS_PARTNER_USER_FIELD_ID, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + type: FieldType.RELATION, + name: 'partnerLinksAsPartnerUser', + label: 'Partner Links (as partner user)', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + PARTNER_USER_ON_PARTNER_LINK_FIELD_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-links-on-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-links-on-partner.field.ts new file mode 100644 index 0000000000..2fe89381de --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-links-on-partner.field.ts @@ -0,0 +1,25 @@ +import { FieldType, RelationType, defineField } from 'twenty-sdk/define'; + +import { + PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER, + PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; +import { + PARTNER_LINK_PARTNER_FIELD_ID, + PARTNER_LINKS_ON_PARTNER_FIELD_ID, +} from './partner-link-partner.field'; + +export default defineField({ + universalIdentifier: PARTNER_LINKS_ON_PARTNER_FIELD_ID, + objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'partnerLinks', + label: 'Partner Links', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_LINK_PARTNER_FIELD_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-service-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-service-partner.field.ts new file mode 100644 index 0000000000..500b096c07 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-service-partner.field.ts @@ -0,0 +1,29 @@ +import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define'; + +import { + PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, + PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export const PARTNER_SERVICE_PARTNER_FIELD_ID = + 'cc2f1f32-65a0-46df-977f-fffc278b899a'; +export const PARTNER_SERVICES_ON_PARTNER_FIELD_ID = + '88ac0831-91d5-4131-823f-3e72de8105b5'; + +export default defineField({ + universalIdentifier: PARTNER_SERVICE_PARTNER_FIELD_ID, + objectUniversalIdentifier: PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'partner', + label: 'Partner', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + PARTNER_SERVICES_ON_PARTNER_FIELD_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'partnerId', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-services-as-partner-user-on-workspace-member.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-services-as-partner-user-on-workspace-member.field.ts new file mode 100644 index 0000000000..bf9b4f1237 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-services-as-partner-user-on-workspace-member.field.ts @@ -0,0 +1,29 @@ +import { + FieldType, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, + defineField, +} from 'twenty-sdk/define'; + +import { PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { + PARTNER_SERVICES_AS_PARTNER_USER_FIELD_ID, + PARTNER_USER_ON_PARTNER_SERVICE_FIELD_ID, +} from './partner-user-on-partner-service.field'; + +export default defineField({ + universalIdentifier: PARTNER_SERVICES_AS_PARTNER_USER_FIELD_ID, + objectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + type: FieldType.RELATION, + name: 'partnerServicesAsPartnerUser', + label: 'Partner Services (as partner user)', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + PARTNER_USER_ON_PARTNER_SERVICE_FIELD_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-services-on-partner.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-services-on-partner.field.ts new file mode 100644 index 0000000000..f2af66c452 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-services-on-partner.field.ts @@ -0,0 +1,25 @@ +import { FieldType, RelationType, defineField } from 'twenty-sdk/define'; + +import { + PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, + PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; +import { + PARTNER_SERVICES_ON_PARTNER_FIELD_ID, + PARTNER_SERVICE_PARTNER_FIELD_ID, +} from './partner-service-partner.field'; + +export default defineField({ + universalIdentifier: PARTNER_SERVICES_ON_PARTNER_FIELD_ID, + objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'partnerServices', + label: 'Partner Services', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: PARTNER_SERVICE_PARTNER_FIELD_ID, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner-content.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner-content.field.ts new file mode 100644 index 0000000000..826a131cc3 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner-content.field.ts @@ -0,0 +1,32 @@ +import { + FieldType, + OnDeleteAction, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, + defineField, +} from 'twenty-sdk/define'; + +import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +export const PARTNER_USER_ON_PARTNER_CONTENT_FIELD_ID = + '8d77a7c6-bf73-4cb7-8a95-93e32d8556e9'; +export const PARTNER_CONTENTS_AS_PARTNER_USER_FIELD_ID = + 'acb4da1d-93fe-4f40-b51f-805b334e4970'; + +export default defineField({ + universalIdentifier: PARTNER_USER_ON_PARTNER_CONTENT_FIELD_ID, + objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'partnerUser', + label: 'Partner User', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + PARTNER_CONTENTS_AS_PARTNER_USER_FIELD_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'partnerUserId', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner-link.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner-link.field.ts new file mode 100644 index 0000000000..912ddde823 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner-link.field.ts @@ -0,0 +1,32 @@ +import { + FieldType, + OnDeleteAction, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, + defineField, +} from 'twenty-sdk/define'; + +import { PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +export const PARTNER_USER_ON_PARTNER_LINK_FIELD_ID = + '294ea3dc-9195-4a2a-8823-18d906f66f83'; +export const PARTNER_LINKS_AS_PARTNER_USER_FIELD_ID = + '81a977c0-85fa-4261-953c-f721b1c9604d'; + +export default defineField({ + universalIdentifier: PARTNER_USER_ON_PARTNER_LINK_FIELD_ID, + objectUniversalIdentifier: PARTNER_LINK_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'partnerUser', + label: 'Partner User', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + PARTNER_LINKS_AS_PARTNER_USER_FIELD_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'partnerUserId', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner-service.field.ts b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner-service.field.ts new file mode 100644 index 0000000000..ad5bfb7637 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/fields/partner-user-on-partner-service.field.ts @@ -0,0 +1,32 @@ +import { + FieldType, + OnDeleteAction, + RelationType, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, + defineField, +} from 'twenty-sdk/define'; + +import { PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +export const PARTNER_USER_ON_PARTNER_SERVICE_FIELD_ID = + 'e35d5cc8-1217-4676-81b2-51112b716b1c'; +export const PARTNER_SERVICES_AS_PARTNER_USER_FIELD_ID = + 'bc7e3a06-d362-4aef-a30b-e8ba0746c781'; + +export default defineField({ + universalIdentifier: PARTNER_USER_ON_PARTNER_SERVICE_FIELD_ID, + objectUniversalIdentifier: PARTNER_SERVICE_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'partnerUser', + label: 'Partner User', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.workspaceMember.universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier: + PARTNER_SERVICES_AS_PARTNER_USER_FIELD_ID, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'partnerUserId', + }, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/call-app-route.ts b/packages/twenty-apps/internal/twenty-partners/src/front-components/call-app-route.ts new file mode 100644 index 0000000000..e08e568b6d --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/front-components/call-app-route.ts @@ -0,0 +1,44 @@ +type ErrorResponse = { messages?: string[]; message?: string; error?: string }; + +const extractErrorMessage = async (response: Response): Promise => { + const text = await response.text().catch(() => ''); + + try { + const parsed = JSON.parse(text) as ErrorResponse; + return ( + parsed.messages?.[0] ?? + parsed.message ?? + parsed.error ?? + `Server error (${response.status})` + ); + } catch { + return text || `Server error (${response.status})`; + } +}; + +export const callAppRoute = async ( + path: string, + body: Record, +): Promise => { + const token = process.env.TWENTY_APP_ACCESS_TOKEN; + + const functionsUrl = process.env.TWENTY_FUNCTIONS_URL; + const apiUrl = process.env.TWENTY_API_URL ?? ''; + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + const url = functionsUrl ? `${functionsUrl}${normalizedPath}` : `${apiUrl}/s${normalizedPath}`; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(await extractErrorMessage(response)); + } + + return response.json(); +}; diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies.front-component.tsx b/packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies.front-component.tsx new file mode 100644 index 0000000000..6b6bdbea50 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies.front-component.tsx @@ -0,0 +1,260 @@ +import { type CSSProperties, useCallback, useEffect, useState } from 'react'; +import { defineFrontComponent } from 'twenty-sdk/define'; +import { enqueueSnackbar, openCommandConfirmationModal } from 'twenty-sdk/front-component'; + +import { MY_CASE_STUDIES_FRONT_COMPONENT_ID } from 'src/constants/my-case-studies.constants'; + +import { callAppRoute } from './call-app-route'; +import { CaseStudyCard } from './my-case-studies/case-study-card'; +import { + buildInitialRows, + newDraftRow, + toSaveBody, + type CaseStudyRow, +} from './my-case-studies/case-study-rows'; +import { COLORS, FONT } from './my-profile/form-fields'; +import type { MyProfilePayload, SaveContentResult } from './my-profile/types'; + +type LoadResult = { ok: true; profile: MyProfilePayload } | { ok: false; reason: string }; + +const pageStyle: CSSProperties = { + fontFamily: FONT, + color: COLORS.fg, + maxWidth: '100%', + padding: '24px 28px', + display: 'flex', + flexDirection: 'column', + gap: 16, +}; +const headerStyle: CSSProperties = { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + gap: 12, +}; +const titleStyle: CSSProperties = { fontSize: 20, fontWeight: 700, margin: 0 }; +const subtitleStyle: CSSProperties = { fontSize: 13, color: COLORS.muted, marginTop: 4 }; +const addButtonStyle: CSSProperties = { + height: 34, + padding: '0 16px', + borderRadius: 6, + border: 'none', + background: COLORS.accent, + color: '#fff', + fontSize: 13, + fontWeight: 600, + fontFamily: FONT, + cursor: 'pointer', + whiteSpace: 'nowrap', +}; +const emptyStyle: CSSProperties = { + border: `1px solid ${COLORS.border}`, + borderRadius: 8, + background: COLORS.surfaceAlt, + padding: 28, + textAlign: 'center', + color: COLORS.muted, + fontSize: 14, +}; +const skeletonStyle: CSSProperties = { + height: 56, + border: `1px solid ${COLORS.border}`, + borderRadius: 8, + background: COLORS.surfaceAlt, + marginBottom: 12, +}; +const errorStyle: CSSProperties = { + border: `1px solid ${COLORS.border}`, + borderRadius: 8, + background: COLORS.surfaceAlt, + padding: 28, + textAlign: 'center', + color: COLORS.muted, + fontSize: 14, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 12, +}; +const retryButtonStyle: CSSProperties = { + height: 32, + padding: '0 16px', + borderRadius: 6, + border: `1px solid ${COLORS.border}`, + background: COLORS.surface, + color: COLORS.fg, + fontSize: 13, + fontFamily: FONT, + cursor: 'pointer', +}; + +const MyCaseStudies = () => { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedKey, setExpandedKey] = useState(null); + const [busyKey, setBusyKey] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = (await callAppRoute('/my-partner-profile', {})) as LoadResult; + if (res.ok) { + setRows(buildInitialRows(res.profile.caseStudies)); + setLoadFailed(false); + } else { + setLoadFailed(true); + await enqueueSnackbar({ message: `Could not load case studies: ${res.reason}`, variant: 'error' }); + } + } catch (error) { + setLoadFailed(true); + await enqueueSnackbar({ + message: error instanceof Error ? error.message : 'Failed to load case studies', + variant: 'error', + }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const patchRow = (key: string, patch: Partial) => + setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r))); + + const handleAdd = () => { + const draft = newDraftRow(); + setRows((prev) => [...prev, draft]); + setExpandedKey(draft.key); + }; + + // The reconcile route takes the whole desired list, so a save persists every card's current + // values; in the accordion you edit one at a time, so this reads as "save this card". + const persist = useCallback( + async (nextRows: CaseStudyRow[], successMessage: string): Promise => { + const res = (await callAppRoute('/save-my-partner-content', toSaveBody(nextRows))) as SaveContentResult; + if (res.ok) { + setRows(buildInitialRows(res.caseStudies)); + await enqueueSnackbar({ message: successMessage, variant: 'success' }); + return true; + } + await enqueueSnackbar({ message: res.reason, variant: 'error' }); + return false; + }, + [], + ); + + const handleSave = async (key: string) => { + if (loadFailed) return; + setBusyKey(key); + try { + // Collapse only on success so a rejected save keeps the card open to retry. + if (await persist(rows, 'Case study saved')) setExpandedKey(null); + } catch (error) { + await enqueueSnackbar({ + message: error instanceof Error ? error.message : 'Failed to save case study', + variant: 'error', + }); + } finally { + setBusyKey(null); + } + }; + + const handleDelete = async (key: string) => { + if (loadFailed) return; + const target = rows.find((r) => r.key === key); + if (!target) return; + + const confirmed = await openCommandConfirmationModal({ + title: 'Delete this case study?', + subtitle: 'It will be removed from your public profile. This cannot be undone.', + confirmButtonText: 'Delete', + confirmButtonAccent: 'danger', + }); + if (confirmed !== 'confirm') return; + + const remaining = rows.filter((r) => r.key !== key); + // A never-saved draft: drop it locally, no server round-trip. + if (!target.id) { + setRows(remaining); + return; + } + setBusyKey(key); + try { + await persist(remaining, 'Case study deleted'); + } catch (error) { + await enqueueSnackbar({ + message: error instanceof Error ? error.message : 'Failed to delete case study', + variant: 'error', + }); + } finally { + setBusyKey(null); + } + }; + + return ( +
+
+
+

My Case Studies

+
Showcase your work on your public partner profile.
+
+ +
+ + {loading ? ( +
+
+
+
+ ) : loadFailed ? ( +
+
We couldn't load your case studies. Please try again.
+ +
+ ) : rows.length === 0 ? ( +
+ No case studies yet. Add one to show clients the work you have delivered. +
+ ) : ( +
+ {rows.map((row) => ( + { + // A save/delete persists the whole list, so block switching cards mid-flight + // to avoid clobbering another card's in-progress edits. + if (busyKey !== null) return; + setExpandedKey((cur) => (cur === row.key ? null : row.key)); + }} + onChange={(patch) => patchRow(row.key, patch)} + onSave={() => void handleSave(row.key)} + onDelete={() => void handleDelete(row.key)} + /> + ))} +
+ )} +
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: MY_CASE_STUDIES_FRONT_COMPONENT_ID, + name: 'My Case Studies', + description: 'Self-service page for a partner to create and edit their case studies.', + component: MyCaseStudies, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies/case-study-card.tsx b/packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies/case-study-card.tsx new file mode 100644 index 0000000000..d86398ac03 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies/case-study-card.tsx @@ -0,0 +1,200 @@ +import { type CSSProperties } from 'react'; + +import { COLORS, Field, FONT, TextInput, UrlInput } from '../my-profile/form-fields'; +import { MarkdownEditor } from '../my-profile/markdown-editor'; +import { type CaseStudyRow } from './case-study-rows'; + +type CaseStudyCardProps = { + row: CaseStudyRow; + expanded: boolean; + busy: boolean; + onToggleExpand: () => void; + onChange: (patch: Partial) => void; + onSave: () => void; + onDelete: () => void; +}; + +const cardStyle: CSSProperties = { + border: `1px solid ${COLORS.border}`, + borderRadius: 8, + background: COLORS.surface, + marginBottom: 12, + fontFamily: FONT, +}; +const summaryStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + padding: '12px 14px', + cursor: 'pointer', +}; +const titleStyle: CSSProperties = { fontSize: 14, fontWeight: 600, color: COLORS.fg }; +const clientStyle: CSSProperties = { + fontSize: 11, + letterSpacing: 0.4, + textTransform: 'uppercase', + color: COLORS.muted, + marginTop: 2, +}; +const chipBase: CSSProperties = { + fontSize: 11, + fontWeight: 600, + padding: '2px 8px', + borderRadius: 999, +}; +const chipPublished: CSSProperties = { ...chipBase, background: COLORS.accent, color: '#fff' }; +const chipDraft: CSSProperties = { + ...chipBase, + background: COLORS.surfaceAlt, + color: COLORS.muted, + border: `1px solid ${COLORS.border}`, +}; +const caretStyle: CSSProperties = { color: COLORS.muted, fontSize: 12 }; +const bodyStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 14, + padding: '4px 14px 16px', + borderTop: `1px solid ${COLORS.border}`, +}; +const actionsStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + flexWrap: 'wrap', +}; +const toggleBase: CSSProperties = { + height: 30, + padding: '0 12px', + borderRadius: 6, + fontSize: 13, + fontFamily: FONT, + cursor: 'pointer', +}; +const togglePublished: CSSProperties = { + ...toggleBase, + border: `1px solid ${COLORS.accent}`, + background: COLORS.accentSoft, + color: COLORS.accent, +}; +const toggleDraft: CSSProperties = { + ...toggleBase, + border: `1px solid ${COLORS.border}`, + background: COLORS.surface, + color: COLORS.muted, +}; +const saveButton: CSSProperties = { + height: 30, + padding: '0 16px', + borderRadius: 6, + border: 'none', + background: COLORS.accent, + color: '#fff', + fontSize: 13, + fontWeight: 600, + fontFamily: FONT, + cursor: 'pointer', +}; +const deleteButton: CSSProperties = { + height: 30, + padding: '0 12px', + borderRadius: 6, + border: `1px solid ${COLORS.border}`, + background: COLORS.surface, + color: '#b3261e', + fontSize: 13, + fontFamily: FONT, + cursor: 'pointer', +}; + +export const CaseStudyCard = ({ + row, + expanded, + busy, + onToggleExpand, + onChange, + onSave, + onDelete, +}: CaseStudyCardProps) => ( +
+
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onToggleExpand(); + } + }} + > +
+
{row.headline.trim() !== '' ? row.headline : 'Untitled case study'}
+ {row.clientName.trim() !== '' ?
{row.clientName}
: null} +
+
+ {row.published ? 'Published' : 'Draft'} + {expanded ? '▾' : '▸'} +
+
+ + {expanded ? ( +
+ + onChange({ clientName: v })} placeholder="Client name" /> + + + onChange({ headline: v })} placeholder="What you delivered" /> + + + onChange({ bodyMarkdown: v })} placeholder="Tell the story of this project…" ariaLabel="Case study story" /> + + + onChange({ caseStudyLink: v })} placeholder="https://…" /> + + + onChange({ coverImageUrl: v })} + placeholder="https://…" + /> + + {row.coverImageUrl.trim() !== '' ? ( + Cover preview + ) : null} + +
+ +
+ + +
+
+
+ ) : null} +
+); diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies/case-study-rows.test.ts b/packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies/case-study-rows.test.ts new file mode 100644 index 0000000000..b259da7c39 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies/case-study-rows.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildInitialRows, + deriveName, + derivePublished, + isBlankDraft, + newDraftRow, + toSaveBody, + type CaseStudyRow, +} from './case-study-rows'; + +const persisted = (over: Partial = {}): CaseStudyRow => ({ + key: 'r1', + id: 'r1', + clientName: 'Acme', + headline: 'Migration', + bodyMarkdown: '## Hi', + coverImageUrl: '', + caseStudyLink: 'https://x.io', + published: true, + ...over, +}); + +describe('case-study-rows', () => { + it('derives published from APPROVED status only', () => { + expect(derivePublished('APPROVED')).toBe(true); + expect(derivePublished('WIP')).toBe(false); + expect(derivePublished(null)).toBe(false); + }); + + it('builds initial rows from the load payload (key = id, published from status)', () => { + const rows = buildInitialRows([ + { id: 'a', name: 'n', clientName: 'C', headline: 'H', bodyMarkdown: 'B', coverImageUrl: null, caseStudyLink: 'https://y', status: 'APPROVED' }, + { id: 'b', name: null, clientName: null, headline: null, bodyMarkdown: null, coverImageUrl: null, caseStudyLink: null, status: 'WIP' }, + ]); + expect(rows[0]).toMatchObject({ key: 'a', id: 'a', clientName: 'C', headline: 'H', bodyMarkdown: 'B', caseStudyLink: 'https://y', published: true }); + expect(rows[1]).toMatchObject({ key: 'b', id: 'b', clientName: '', headline: '', bodyMarkdown: '', caseStudyLink: '', published: false }); + }); + + it('new draft rows have a unique key, no id, and default to draft', () => { + const a = newDraftRow(); + const b = newDraftRow(); + expect(a.id).toBeUndefined(); + expect(a.published).toBe(false); + expect(a.key).not.toBe(b.key); + }); + + it('derives name from headline, falling back when empty', () => { + expect(deriveName(persisted({ headline: ' Rebuild ' }))).toBe('Rebuild'); + expect(deriveName(persisted({ headline: ' ' }))).toBe('Case study'); + }); + + it('treats an untouched new draft as blank, but not a filled or persisted row', () => { + expect(isBlankDraft(newDraftRow())).toBe(true); + expect(isBlankDraft(newDraftRow())).toBe(true); + expect(isBlankDraft(persisted({ id: undefined, key: 'draft-x', headline: 'x' }))).toBe(false); + expect(isBlankDraft(persisted())).toBe(false); + }); + + it('builds the save body: drops blank drafts, keeps id for persisted, carries published + derived name', () => { + const rows = [persisted(), newDraftRow(), { ...newDraftRow(), headline: 'Fresh', published: true }]; + const body = toSaveBody(rows) as { caseStudies: Array> }; + expect(body.caseStudies).toHaveLength(2); + expect(body.caseStudies[0]).toMatchObject({ id: 'r1', name: 'Migration', clientName: 'Acme', bodyMarkdown: '## Hi', caseStudyLink: 'https://x.io', published: true }); + expect(body.caseStudies[1]).toMatchObject({ name: 'Fresh', published: true }); + expect(body.caseStudies[1].id).toBeUndefined(); + }); + + it('carries coverImageUrl into the save body and reads it from the load payload', () => { + const rows = buildInitialRows([ + { id: 'a', name: 'n', clientName: 'C', headline: 'H', bodyMarkdown: 'B', coverImageUrl: 'https://img.example.com/a.png', caseStudyLink: 'https://y', status: 'APPROVED' }, + ]); + expect(rows[0].coverImageUrl).toBe('https://img.example.com/a.png'); + + const body = toSaveBody([persisted({ coverImageUrl: 'https://img.example.com/c.png' })]) as { + caseStudies: Array>; + }; + expect(body.caseStudies[0].coverImageUrl).toBe('https://img.example.com/c.png'); + }); + + it('a new draft with only a cover URL set is not treated as blank', () => { + expect(isBlankDraft({ ...newDraftRow(), coverImageUrl: 'https://img.example.com/x.png' })).toBe(false); + }); +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies/case-study-rows.ts b/packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies/case-study-rows.ts new file mode 100644 index 0000000000..683b96d36c --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/front-components/my-case-studies/case-study-rows.ts @@ -0,0 +1,68 @@ +import type { MyProfilePayload } from '../my-profile/types'; + +export type CaseStudyRow = { + key: string; // stable React key: the record id for saved rows, `draft-N` for new ones + id?: string; // present once persisted + clientName: string; + headline: string; + bodyMarkdown: string; + caseStudyLink: string; + coverImageUrl: string; + published: boolean; +}; + +export const derivePublished = (status: string | null): boolean => status === 'APPROVED'; + +export const buildInitialRows = ( + caseStudies: MyProfilePayload['caseStudies'], +): CaseStudyRow[] => + caseStudies.map((cs) => ({ + key: cs.id, + id: cs.id, + clientName: cs.clientName ?? '', + headline: cs.headline ?? '', + bodyMarkdown: cs.bodyMarkdown ?? '', + caseStudyLink: cs.caseStudyLink ?? '', + coverImageUrl: cs.coverImageUrl ?? '', + published: derivePublished(cs.status), + })); + +let draftCounter = 0; +export const newDraftRow = (): CaseStudyRow => ({ + key: `draft-${(draftCounter += 1)}`, + clientName: '', + headline: '', + bodyMarkdown: '', + caseStudyLink: '', + coverImageUrl: '', + published: false, +}); + +// The internal `name` column is required server-side but never shown to partners; derive it. +export const deriveName = (row: CaseStudyRow): string => + row.headline.trim() !== '' ? row.headline.trim() : 'Case study'; + +export const isBlankDraft = (row: CaseStudyRow): boolean => + row.id === undefined && + row.headline.trim() === '' && + row.bodyMarkdown.trim() === '' && + row.clientName.trim() === '' && + row.caseStudyLink.trim() === '' && + row.coverImageUrl.trim() === ''; + +// The reconcile route needs the whole desired list; blank never-saved drafts are excluded +// so an untouched "Add" doesn't create an empty record. +export const toSaveBody = (rows: CaseStudyRow[]): Record => ({ + caseStudies: rows + .filter((row) => !isBlankDraft(row)) + .map((row) => ({ + ...(row.id ? { id: row.id } : {}), + name: deriveName(row), + clientName: row.clientName, + headline: row.headline, + bodyMarkdown: row.bodyMarkdown, + caseStudyLink: row.caseStudyLink, + coverImageUrl: row.coverImageUrl, + published: row.published, + })), +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile.front-component.tsx b/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile.front-component.tsx new file mode 100644 index 0000000000..411731188e --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile.front-component.tsx @@ -0,0 +1,414 @@ +import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import { defineFrontComponent } from 'twenty-sdk/define'; +import { enqueueSnackbar } from 'twenty-sdk/front-component'; + +import { MY_PROFILE_FRONT_COMPONENT_ID } from 'src/constants/my-profile.constants'; + +import { callAppRoute } from './call-app-route'; +import { MarkdownEditor } from './my-profile/markdown-editor'; +import { ProfilePictureUpload } from './my-profile/ProfilePictureUpload'; +import { + ChipMultiSelect, + COLORS, + CurrencyInput, + Field, + FONT, + SelectInput, + TagInput, + TextInput, + UrlInput, + type SelectOption, +} from './my-profile/form-fields'; + +type Currency = { amountMicros: number | null; currencyCode: string | null } | null; + +type ProfilePayload = { + id: string; + name: string | null; + profilePictureUrl: string | null; + introduction: string | null; + city: string | null; + country: string | null; + languagesSpoken: string[] | null; + partnerScope: string[] | null; + skills: string[] | null; + typeOfTeam: string | null; + availability: string | null; + hourlyRate: Currency; + projectBudgetMin: Currency; + website: string | null; + linkedin: string | null; + calendarLink: string | null; +}; + +type ProfileOptions = { + country: SelectOption[]; + languagesSpoken: SelectOption[]; + partnerScope: SelectOption[]; + typeOfTeam: SelectOption[]; + availability: SelectOption[]; +}; + +type LoadResult = + | { ok: true; profile: ProfilePayload; options: ProfileOptions } + | { ok: false; reason: string }; +type SaveResult = { ok: true } | { ok: false; reason: string }; + +type MoneyField = { amount: number | null; currencyCode: string }; + +type ProfileForm = { + name: string; + introduction: string; + availability: string; + typeOfTeam: string; + hourlyRate: MoneyField; + projectBudgetMin: MoneyField; + partnerScope: string[]; + skills: string[]; + languagesSpoken: string[]; + country: string; + city: string; + website: string; + linkedin: string; + calendarLink: string; +}; + +const SKILL_SUGGESTIONS = [ + 'Migrations', + 'RevOps', + 'Reporting', + 'Forecasting', + 'Automations', + 'No-code ops', + 'API & SDK', + 'API integrations', + 'Self-hosted', + 'EU compliance', + 'Data import', + 'Onboarding', + 'Training', + 'Custom development', +]; + +const MICROS = 1_000_000; + +const toMoneyField = (value: Currency): MoneyField => ({ + amount: value?.amountMicros != null ? value.amountMicros / MICROS : null, + currencyCode: value?.currencyCode ?? 'USD', +}); + +const toProfileForm = (profile: ProfilePayload): ProfileForm => ({ + name: profile.name ?? '', + introduction: profile.introduction ?? '', + availability: profile.availability ?? '', + typeOfTeam: profile.typeOfTeam ?? '', + hourlyRate: toMoneyField(profile.hourlyRate), + projectBudgetMin: toMoneyField(profile.projectBudgetMin), + partnerScope: profile.partnerScope ?? [], + skills: profile.skills ?? [], + languagesSpoken: profile.languagesSpoken ?? [], + country: profile.country ?? '', + city: profile.city ?? '', + website: profile.website ?? '', + linkedin: profile.linkedin ?? '', + calendarLink: profile.calendarLink ?? '', +}); + +const toMicros = (money: MoneyField) => + money.amount == null + ? null + : { amountMicros: Math.round(money.amount * MICROS), currencyCode: money.currencyCode || 'USD' }; + +// Enum/country selectors send null (not '') when reset to blank so the field clears. +const toSaveBody = (form: ProfileForm): Record => ({ + name: form.name, + introduction: form.introduction, + city: form.city, + languagesSpoken: form.languagesSpoken, + partnerScope: form.partnerScope, + skills: form.skills, + website: form.website, + linkedin: form.linkedin, + calendarLink: form.calendarLink, + hourlyRate: toMicros(form.hourlyRate), + projectBudgetMin: toMicros(form.projectBudgetMin), + availability: form.availability === '' ? null : form.availability, + typeOfTeam: form.typeOfTeam === '' ? null : form.typeOfTeam, + country: form.country === '' ? null : form.country, +}); + +const styles = { + root: { + display: 'flex', + justifyContent: 'center', + alignItems: 'flex-start', + width: '100%', + minHeight: 'calc(100dvh - 56px)', + boxSizing: 'border-box', + padding: 32, + fontFamily: FONT, + color: COLORS.fg, + background: COLORS.bg, + } as const, + card: { + width: '100%', + maxWidth: '100%', + background: COLORS.surface, + border: `1px solid ${COLORS.border}`, + borderRadius: 12, + padding: 28, + display: 'flex', + flexDirection: 'column', + gap: 28, + } as const, + title: { fontSize: 20, fontWeight: 700, margin: 0 } as const, + sectionTitle: { + fontSize: 11.5, + fontWeight: 700, + color: COLORS.muted, + textTransform: 'uppercase', + letterSpacing: 0.5, + margin: 0, + } as const, + row2: { display: 'flex', gap: 12 } as const, + footer: { + display: 'flex', + justifyContent: 'flex-end', + paddingTop: 4, + borderTop: `1px solid ${COLORS.border}`, + } as const, + button: { + height: 40, + borderRadius: 8, + border: 'none', + background: COLORS.accent, + color: '#fff', + fontSize: 14, + fontWeight: 650, + fontFamily: FONT, + cursor: 'pointer', + padding: '0 24px', + marginTop: 16, + } as const, +} as const; + +const Section = ({ title, children }: { title: string; children: ReactNode }) => ( +
+

{title}

+ {children} +
+); + +const MyProfile = () => { + const [form, setForm] = useState(null); + const [options, setOptions] = useState(null); + const [pictureUrl, setPictureUrl] = useState(null); + const [partnerId, setPartnerId] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + const set = (key: K, value: ProfileForm[K]) => + setForm((prev) => (prev ? { ...prev, [key]: value } : prev)); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = (await callAppRoute('/my-partner-profile', {})) as LoadResult; + if (res.ok) { + setForm(toProfileForm(res.profile)); + setOptions(res.options); + setPictureUrl(res.profile.profilePictureUrl); + setPartnerId(res.profile.id); + } else { + await enqueueSnackbar({ message: `Could not load profile: ${res.reason}`, variant: 'error' }); + } + } catch (error) { + await enqueueSnackbar({ + message: error instanceof Error ? error.message : 'Failed to load profile', + variant: 'error', + }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const save = useCallback(async () => { + if (!form) return; + setSaving(true); + try { + const res = (await callAppRoute('/save-my-partner-profile', toSaveBody(form))) as SaveResult; + if (res.ok) { + await enqueueSnackbar({ message: 'Profile saved', variant: 'success' }); + await load(); + } else { + await enqueueSnackbar({ message: `Save failed: ${res.reason}`, variant: 'error' }); + } + } catch (error) { + await enqueueSnackbar({ + message: error instanceof Error ? error.message : 'Failed to save profile', + variant: 'error', + }); + } finally { + setSaving(false); + } + }, [form, load]); + + if (loading) return
Loading…
; + if (!form || !options) { + return
No partner profile found for your account.
; + } + + return ( +
+
+

My Profile

+ +
+ {partnerId && ( + + + + )} + + set('name', value)} /> + + + set('introduction', value)} + placeholder="Tell clients about your team…" + ariaLabel="Introduction" + /> + +
+ +
+
+
+ + set('availability', value)} + /> + +
+
+ + set('typeOfTeam', value)} + /> + +
+
+
+
+ + set('hourlyRate', value)} + /> + +
+
+ + set('projectBudgetMin', value)} + /> + +
+
+
+ +
+ + set('partnerScope', value)} + /> + + + set('skills', value)} + /> + + + set('languagesSpoken', value)} + /> + +
+ +
+
+
+ + set('country', value)} + /> + +
+
+ + set('city', value)} /> + +
+
+
+ +
+ + set('website', value)} + placeholder="https://…" + /> + + + set('linkedin', value)} + placeholder="https://linkedin.com/…" + /> + + + set('calendarLink', value)} + placeholder="https://cal.com/…" + /> + +
+ +
+ +
+
+
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: MY_PROFILE_FRONT_COMPONENT_ID, + name: 'My Partner Profile', + description: 'Self-service page for a partner to view and edit their profile.', + component: MyProfile, +}); diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/ProfilePictureUpload.tsx b/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/ProfilePictureUpload.tsx new file mode 100644 index 0000000000..284d464eac --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/ProfilePictureUpload.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import { AppPath, enqueueSnackbar, navigate } from 'twenty-sdk/front-component'; + +import { COLORS, FONT } from './form-fields'; + +const avatarStyle = { + width: 72, + height: 72, + borderRadius: '50%', + objectFit: 'cover', + border: `1px solid ${COLORS.border}`, + background: COLORS.surfaceAlt, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 24, + color: COLORS.muted, + flexShrink: 0, +} as const; + +const buttonStyle = { + height: 34, + borderRadius: 8, + border: `1px solid ${COLORS.border}`, + background: COLORS.surface, + color: COLORS.fg, + fontSize: 13, + fontWeight: 600, + fontFamily: FONT, + cursor: 'pointer', + padding: '0 14px', +} as const; + +// A sandboxed front-component can't read file bytes (the renderer only forwards file +// metadata across the worker boundary), so real image upload has to happen on the +// platform's native record page. This sends the partner there; on return the page +// re-fetches and shows the new photo. +export const ProfilePictureUpload = ({ + url, + recordId, +}: { + url: string | null; + recordId: string; +}) => { + const [busy, setBusy] = useState(false); + + const goToRecord = async () => { + setBusy(true); + try { + await navigate(AppPath.RecordShowPage, { + objectNameSingular: 'partner', + objectRecordId: recordId, + }); + } catch (error) { + await enqueueSnackbar({ + message: error instanceof Error ? error.message : 'Could not open your record', + variant: 'error', + }); + } finally { + setBusy(false); + } + }; + + return ( +
+ {url ? ( + Profile + ) : ( +
🙂
+ )} +
+ + + Opens your record page to upload an image. + +
+
+ ); +}; diff --git a/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/form-fields.tsx b/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/form-fields.tsx new file mode 100644 index 0000000000..bd22760255 --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/front-components/my-profile/form-fields.tsx @@ -0,0 +1,315 @@ +import { useState, type ReactNode } from 'react'; + +export type SelectOption = { value: string; label: string }; + +export const COLORS = { + bg: '#f7f7f8', + surface: '#ffffff', + surfaceAlt: '#f4f5f7', + fg: '#1c1c1c', + muted: '#66646a', + border: '#e7e7eb', + accent: '#4a38f5', + accentSoft: 'rgba(74, 56, 245, 0.1)', +} as const; + +export const FONT = + '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif'; + +const controlBase = { + width: '100%', + boxSizing: 'border-box', + borderRadius: 8, + border: `1px solid ${COLORS.border}`, + background: COLORS.surface, + color: COLORS.fg, + fontSize: 14, + fontFamily: FONT, + outline: 'none', +} as const; + +const inputStyle = { ...controlBase, height: 40, padding: '0 12px' } as const; + +const labelStyle = { + fontSize: 12, + fontWeight: 600, + color: COLORS.muted, +} as const; + +const chipBase = { + display: 'inline-flex', + alignItems: 'center', + gap: 6, + height: 28, + padding: '0 10px', + borderRadius: 999, + fontSize: 12.5, + fontFamily: FONT, + cursor: 'pointer', + border: `1px solid ${COLORS.border}`, + background: COLORS.surface, + color: COLORS.fg, +} as const; + +const chipSelected = { + ...chipBase, + border: `1px solid ${COLORS.accent}`, + background: COLORS.accentSoft, + color: COLORS.accent, + fontWeight: 600, +} as const; + +export const Field = ({ label, children }: { label: string; children: ReactNode }) => ( +
+ {label} + {children} +
+); + +export const TextInput = ({ + value, + onChange, + placeholder, +}: { + value: string; + onChange: (value: string) => void; + placeholder?: string; +}) => ( + onChange(event.target.value)} + /> +); + +export const TextArea = ({ + value, + onChange, + rows = 4, + placeholder, +}: { + value: string; + onChange: (value: string) => void; + rows?: number; + placeholder?: string; +}) => ( +