feat(twenty-partners): expose partnerScope on list + by-slug endpoints (#21126)
## What Adds `partnerScope` (the partner **Categories** multi-select) to the output of the two public partner endpoints: - `list-available-partners` (`/s/partners`) - `get-partner-by-slug` (`/s/partner-by-slug`) Additive only — `deploymentExpertise` is kept, so existing consumers (the current live marketplace) are unaffected. ## Why Part of the partner marketplace rework. The website marketplace (companion branch `rk-rework-marketplace-cards`) consumes `partnerScope` to show/filter partner Categories. The new options + migration live in the signup app PR #21040. ## Merge order (we'll decide) Independent diff — can merge in any order. Couplings to keep in mind: - **Version line:** this branch and #21040 both bump the app `package.json` version; whoever merges second re-bumps. - **Deploy (not merge):** the partners app is deployed manually. Deploy the final combined app (this + #21040) and run `yarn migrate:partner-scope:prod` **before** the website is deployed.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-partners",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
|
||||
export const GET_PARTNER_BY_SLUG_LOGIC_FUNCTION_ID =
|
||||
'5e3e7b88-2cf2-4f56-9a4a-46c4c1d6b0bb';
|
||||
|
||||
type CurrencyValue = { amountMicros: number; currencyCode: string } | null;
|
||||
type LinkValue = { primaryLinkUrl: string | null } | null;
|
||||
|
||||
type Partner = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
slug: string | null;
|
||||
introduction: string | null;
|
||||
languagesSpoken: string[] | null;
|
||||
deploymentExpertise: string[] | null;
|
||||
partnerScope: string[] | null;
|
||||
region: string[] | null;
|
||||
calendarLink: LinkValue;
|
||||
hourlyRate: CurrencyValue;
|
||||
projectBudgetMin: CurrencyValue;
|
||||
projectBudgetTypical: CurrencyValue;
|
||||
linkedin: LinkValue;
|
||||
profilePicture: LinkValue;
|
||||
skills: string[] | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
};
|
||||
|
||||
type GetPartnerBySlugResult =
|
||||
| { ok: true; partner: Partner }
|
||||
| { ok: false; reason: 'NOT_FOUND' | string };
|
||||
|
||||
const handler = async (input: {
|
||||
queryStringParameters?: { slug?: string };
|
||||
}): Promise<GetPartnerBySlugResult> => {
|
||||
const slug = input?.queryStringParameters?.slug;
|
||||
if (typeof slug !== 'string' || slug.length === 0) {
|
||||
return { ok: false, reason: 'Missing slug query parameter' };
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new CoreApiClient();
|
||||
|
||||
const result = await client.query({
|
||||
partners: {
|
||||
__args: {
|
||||
filter: {
|
||||
slug: { eq: slug },
|
||||
validationStage: { eq: 'VALIDATED' },
|
||||
availability: { eq: 'AVAILABLE' },
|
||||
},
|
||||
first: 1,
|
||||
},
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
introduction: true,
|
||||
languagesSpoken: true,
|
||||
deploymentExpertise: true,
|
||||
partnerScope: true,
|
||||
region: true,
|
||||
calendarLink: { primaryLinkUrl: true },
|
||||
hourlyRate: { amountMicros: true, currencyCode: true },
|
||||
projectBudgetMin: { amountMicros: true, currencyCode: true },
|
||||
projectBudgetTypical: { amountMicros: true, currencyCode: true },
|
||||
linkedin: { primaryLinkUrl: true },
|
||||
profilePicture: { primaryLinkUrl: true },
|
||||
skills: true,
|
||||
city: true,
|
||||
country: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const edges = (result?.partners?.edges ?? []) as Array<{ node: Partner }>;
|
||||
const partner = edges[0]?.node;
|
||||
|
||||
if (!partner) {
|
||||
return { ok: false, reason: 'NOT_FOUND' };
|
||||
}
|
||||
|
||||
return { ok: true, partner };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: GET_PARTNER_BY_SLUG_LOGIC_FUNCTION_ID,
|
||||
name: 'get-partner-by-slug',
|
||||
description:
|
||||
'Returns a single VALIDATED + AVAILABLE partner by slug, or NOT_FOUND.',
|
||||
timeoutSeconds: 10,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/partner-by-slug',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
+45
-40
@@ -4,53 +4,58 @@ import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
export const LIST_AVAILABLE_PARTNERS_LOGIC_FUNCTION_ID =
|
||||
'0f91164f-f492-41e8-9bb0-481be5a3d5b9';
|
||||
|
||||
type Partner = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
slug: string | null;
|
||||
introduction: string | null;
|
||||
languagesSpoken: string[] | null;
|
||||
deploymentExpertise: string[] | null;
|
||||
region: string[] | null;
|
||||
calendarLink: { primaryLinkUrl: string | null } | null;
|
||||
};
|
||||
// CoreApiClient is codegenerated from the synced workspace schema, so the query
|
||||
// selection is strictly typed. Keep the fetch in one place and derive the
|
||||
// response shape from it, so the HTTP contract can never drift from what we
|
||||
// actually ask the API for.
|
||||
const queryAvailablePartners = (client: CoreApiClient) =>
|
||||
client.query({
|
||||
partners: {
|
||||
__args: {
|
||||
filter: {
|
||||
validationStage: { eq: 'VALIDATED' },
|
||||
availability: { eq: 'AVAILABLE' },
|
||||
},
|
||||
orderBy: [{ name: 'AscNullsLast' }],
|
||||
first: 100,
|
||||
},
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
introduction: true,
|
||||
languagesSpoken: true,
|
||||
deploymentExpertise: true,
|
||||
partnerScope: true,
|
||||
region: true,
|
||||
calendarLink: { primaryLinkUrl: true },
|
||||
hourlyRate: { amountMicros: true, currencyCode: true },
|
||||
projectBudgetMin: { amountMicros: true, currencyCode: true },
|
||||
projectBudgetTypical: { amountMicros: true, currencyCode: true },
|
||||
linkedin: { primaryLinkUrl: true },
|
||||
profilePicture: { primaryLinkUrl: true },
|
||||
skills: true,
|
||||
city: true,
|
||||
country: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type AvailablePartner = NonNullable<
|
||||
Awaited<ReturnType<typeof queryAvailablePartners>>['partners']
|
||||
>['edges'][number]['node'];
|
||||
|
||||
type ListAvailablePartnersResult =
|
||||
| { ok: true; count: number; partners: Partner[] }
|
||||
| { ok: true; count: number; partners: AvailablePartner[] }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
const handler = async (): Promise<ListAvailablePartnersResult> => {
|
||||
try {
|
||||
const client = new CoreApiClient();
|
||||
|
||||
const result = await client.query({
|
||||
partners: {
|
||||
__args: {
|
||||
filter: {
|
||||
validationStage: { eq: 'VALIDATED' },
|
||||
availability: { eq: 'AVAILABLE' },
|
||||
},
|
||||
orderBy: [{ name: 'AscNullsLast' }],
|
||||
first: 100,
|
||||
},
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
introduction: true,
|
||||
languagesSpoken: true,
|
||||
deploymentExpertise: true,
|
||||
region: true,
|
||||
calendarLink: { primaryLinkUrl: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const partners = (
|
||||
(result?.partners?.edges ?? []) as Array<{ node: Partner }>
|
||||
).map((edge) => edge.node);
|
||||
const result = await queryAvailablePartners(client);
|
||||
const partners = (result.partners?.edges ?? []).map((edge) => edge.node);
|
||||
|
||||
return { ok: true, count: partners.length, partners };
|
||||
} catch (err) {
|
||||
|
||||
@@ -22,6 +22,14 @@ const requireEnv = (name: string): string => {
|
||||
};
|
||||
|
||||
const CAL = 'https://calendly.com/placeholder';
|
||||
const usd = (dollars: number) => ({
|
||||
amountMicros: dollars * 1_000_000,
|
||||
currencyCode: 'USD',
|
||||
});
|
||||
// Deterministic placeholder avatars; pravatar returns a stable image per `u` seed.
|
||||
const avatar = (slug: string) => `https://i.pravatar.cc/300?u=${slug}`;
|
||||
const linkedin = (slug: string) =>
|
||||
`https://www.linkedin.com/company/${slug}`;
|
||||
|
||||
type Partner = {
|
||||
slug: string;
|
||||
@@ -37,19 +45,24 @@ type Partner = {
|
||||
partnerScope: string[];
|
||||
typeOfTeam: string;
|
||||
country: string;
|
||||
city: string;
|
||||
hourlyRateUsd: number | null;
|
||||
projectBudgetMinUsd: number | null;
|
||||
projectBudgetTypicalUsd: number | null;
|
||||
skills: string[];
|
||||
};
|
||||
|
||||
const PARTNERS: Partner[] = [
|
||||
{ slug: 'nine-dots-ventures', name: 'Nine Dots Ventures', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Boutique CRM implementer for real-estate workflows and WhatsApp automation.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['EUROPE', 'MENA'], languagesSpoken: ['ENGLISH', 'FRENCH', 'ARABIC'], partnerTier: 'ADVANCED', partnerScope: ['DATA_MODEL', 'WORKFLOWS', 'APPS'], typeOfTeam: 'AGENCY', country: 'FRANCE' },
|
||||
{ slug: 'elevate-consulting', name: 'Elevate Consulting', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Revenue-operations partner for B2B SaaS teams scaling seed to Series C.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['US', 'LATAM'], languagesSpoken: ['ENGLISH', 'SPANISH'], partnerTier: 'INTERMEDIATE', partnerScope: ['DATA_MIGRATION', 'DATA_MODEL'], typeOfTeam: 'AGENCY', country: 'UNITED_STATES' },
|
||||
{ slug: 'w3villa-technologies', name: 'W3Villa Technologies', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Engineering-heavy partner running large self-hosted Twenty deployments.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['APAC', 'MENA'], languagesSpoken: ['ENGLISH', 'HINDI'], partnerTier: 'ADVANCED', partnerScope: ['HOSTING_ENVIRONMENT', 'APPS', 'WORKFLOWS'], typeOfTeam: 'AGENCY', country: 'INDIA' },
|
||||
{ slug: 'act-education', name: 'Act Education', validationStage: 'VALIDATED', availability: 'UNAVAILABLE', introduction: 'CRM partner for European education providers; compliance-first self-hosting.', calendarLink: CAL, deploymentExpertise: ['SELF_HOST'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'GERMAN'], partnerTier: 'NEW', partnerScope: ['HOSTING_ENVIRONMENT', 'DATA_MODEL'], typeOfTeam: 'SOLO', country: 'GERMANY' },
|
||||
{ slug: 'netzero-systems', name: 'NetZero Systems', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'LATAM go-to-market partner for climate-tech and renewable-energy companies.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['LATAM', 'US'], languagesSpoken: ['ENGLISH', 'SPANISH', 'PORTUGUESE'], partnerTier: 'INTERMEDIATE', partnerScope: ['DATA_MODEL'], typeOfTeam: 'AGENCY', country: 'BRAZIL' },
|
||||
{ slug: 'meridian-craft', name: 'Meridian Craft', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'APAC implementation studio for fintech and logistics; English + Chinese.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['APAC', 'AFRICA'], languagesSpoken: ['ENGLISH', 'CHINESE', 'MALAY'], partnerTier: 'ADVANCED', partnerScope: ['APPS', 'WORKFLOWS'], typeOfTeam: 'AGENCY', country: 'SINGAPORE' },
|
||||
{ slug: 'applicant-studio', name: 'Applicant Studio', validationStage: 'APPLICATION', availability: 'UNAVAILABLE', introduction: 'New applicant; awaiting first review.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'FRENCH'], partnerTier: 'NEW', partnerScope: ['DATA_MODEL'], typeOfTeam: 'SOLO', country: 'FRANCE' },
|
||||
{ slug: 'rising-crm', name: 'Rising CRM', validationStage: 'POTENTIAL', availability: 'AVAILABLE', introduction: 'Promising applicant in evaluation.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['US'], languagesSpoken: ['ENGLISH'], partnerTier: 'NEW', partnerScope: ['WORKFLOWS', 'APPS'], typeOfTeam: 'AGENCY', country: 'UNITED_STATES' },
|
||||
{ slug: 'legacy-partners', name: 'Legacy Partners', validationStage: 'FORMER', availability: 'UNAVAILABLE', introduction: 'Former partner; no longer active in the program.', calendarLink: CAL, deploymentExpertise: ['SELF_HOST'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'GERMAN'], partnerTier: 'INTERMEDIATE', partnerScope: ['HOSTING_ENVIRONMENT'], typeOfTeam: 'AGENCY', country: 'UNITED_KINGDOM' },
|
||||
{ slug: 'declined-co', name: 'Declined Co', validationStage: 'REJECTED', availability: 'UNAVAILABLE', introduction: 'Application rejected after review.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['MENA'], languagesSpoken: ['ENGLISH', 'ARABIC'], partnerTier: 'NEW', partnerScope: ['APPS'], typeOfTeam: 'SOLO', country: 'UNITED_ARAB_EMIRATES' },
|
||||
{ slug: 'nine-dots-ventures', name: 'Nine Dots Ventures', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Boutique CRM implementer for real-estate workflows and WhatsApp automation. Nine Dots runs end-to-end Twenty rollouts for property managers and brokerages across Europe and MENA, with deep multi-language data models and AI-assisted lead intake.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['EUROPE', 'MENA'], languagesSpoken: ['ENGLISH', 'FRENCH', 'ARABIC'], partnerTier: 'ADVANCED', partnerScope: ['DATA_MODEL', 'WORKFLOWS', 'APPS'], typeOfTeam: 'AGENCY', country: 'FRANCE', city: 'Paris', hourlyRateUsd: 250, projectBudgetMinUsd: 15000, projectBudgetTypicalUsd: 80000, skills: ['Real estate', 'WhatsApp', 'Multi-language', 'Workflows', 'Integrations', 'AI'] },
|
||||
{ slug: 'elevate-consulting', name: 'Elevate Consulting', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Revenue-operations partner for B2B SaaS teams scaling seed to Series C. Elevate moves teams off legacy CRMs onto Twenty with a four-week migration playbook, pipeline rebuilds, and analytics handoff.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['US', 'LATAM'], languagesSpoken: ['ENGLISH', 'SPANISH'], partnerTier: 'INTERMEDIATE', partnerScope: ['DATA_MIGRATION', 'DATA_MODEL'], typeOfTeam: 'AGENCY', country: 'UNITED_STATES', city: 'Austin', hourlyRateUsd: 200, projectBudgetMinUsd: 20000, projectBudgetTypicalUsd: 100000, skills: ['RevOps', 'B2B SaaS', 'Data migration', 'Pipelines', 'Salesforce migration', 'HubSpot migration'] },
|
||||
{ slug: 'w3villa-technologies', name: 'W3Villa Technologies', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'Engineering-heavy partner running large self-hosted Twenty deployments. Specializes in hardened Kubernetes hosting, custom integrations, and 24/7 support contracts for regulated industries across APAC and the Gulf.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['APAC', 'MENA'], languagesSpoken: ['ENGLISH', 'HINDI'], partnerTier: 'ADVANCED', partnerScope: ['HOSTING_ENVIRONMENT', 'APPS', 'WORKFLOWS'], typeOfTeam: 'AGENCY', country: 'INDIA', city: 'Bangalore', hourlyRateUsd: 120, projectBudgetMinUsd: 10000, projectBudgetTypicalUsd: 60000, skills: ['Self-hosting', 'Kubernetes', 'DevOps', 'Integrations', 'Workflows', 'Enterprise support'] },
|
||||
{ slug: 'act-education', name: 'Act Education', validationStage: 'VALIDATED', availability: 'UNAVAILABLE', introduction: 'CRM partner for European education providers; compliance-first self-hosting on EU infrastructure with full GDPR data residency and student-record workflows.', calendarLink: CAL, deploymentExpertise: ['SELF_HOST'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'GERMAN'], partnerTier: 'NEW', partnerScope: ['HOSTING_ENVIRONMENT', 'DATA_MODEL'], typeOfTeam: 'SOLO', country: 'GERMANY', city: 'Berlin', hourlyRateUsd: 180, projectBudgetMinUsd: 8000, projectBudgetTypicalUsd: 40000, skills: ['Education', 'Compliance', 'Self-hosting', 'GDPR', 'Data privacy'] },
|
||||
{ slug: 'netzero-systems', name: 'NetZero Systems', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'LATAM go-to-market partner for climate-tech and renewable-energy companies. Builds bilingual sales pipelines, ESG reporting, and grant-management workflows on top of Twenty.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['LATAM', 'US'], languagesSpoken: ['ENGLISH', 'SPANISH', 'PORTUGUESE'], partnerTier: 'INTERMEDIATE', partnerScope: ['DATA_MODEL'], typeOfTeam: 'AGENCY', country: 'BRAZIL', city: 'São Paulo', hourlyRateUsd: 150, projectBudgetMinUsd: 12000, projectBudgetTypicalUsd: 50000, skills: ['Climate tech', 'Renewable energy', 'ESG reporting', 'Bilingual pipelines', 'LATAM go-to-market'] },
|
||||
{ slug: 'meridian-craft', name: 'Meridian Craft', validationStage: 'VALIDATED', availability: 'AVAILABLE', introduction: 'APAC implementation studio for fintech and logistics. Senior team of ex-bank engineers building high-throughput Twenty deployments across Singapore, Hong Kong, and Kuala Lumpur.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['APAC', 'AFRICA'], languagesSpoken: ['ENGLISH', 'CHINESE', 'MALAY'], partnerTier: 'ADVANCED', partnerScope: ['APPS', 'WORKFLOWS'], typeOfTeam: 'AGENCY', country: 'SINGAPORE', city: 'Singapore', hourlyRateUsd: 300, projectBudgetMinUsd: 25000, projectBudgetTypicalUsd: 120000, skills: ['Fintech', 'Logistics', 'APAC', 'High throughput', 'Custom apps', 'Performance tuning'] },
|
||||
{ slug: 'applicant-studio', name: 'Applicant Studio', validationStage: 'APPLICATION', availability: 'UNAVAILABLE', introduction: 'New applicant; awaiting first review.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'FRENCH'], partnerTier: 'NEW', partnerScope: ['DATA_MODEL'], typeOfTeam: 'SOLO', country: 'FRANCE', city: 'Lyon', hourlyRateUsd: null, projectBudgetMinUsd: null, projectBudgetTypicalUsd: null, skills: ['Boutique', 'Design'] },
|
||||
{ slug: 'rising-crm', name: 'Rising CRM', validationStage: 'POTENTIAL', availability: 'AVAILABLE', introduction: 'Promising applicant in evaluation.', calendarLink: CAL, deploymentExpertise: ['CLOUD', 'SELF_HOST'], region: ['US'], languagesSpoken: ['ENGLISH'], partnerTier: 'NEW', partnerScope: ['WORKFLOWS', 'APPS'], typeOfTeam: 'AGENCY', country: 'UNITED_STATES', city: 'New York', hourlyRateUsd: null, projectBudgetMinUsd: null, projectBudgetTypicalUsd: null, skills: ['SMB', 'Quick setup'] },
|
||||
{ slug: 'legacy-partners', name: 'Legacy Partners', validationStage: 'FORMER', availability: 'UNAVAILABLE', introduction: 'Former partner; no longer active in the program.', calendarLink: CAL, deploymentExpertise: ['SELF_HOST'], region: ['EUROPE'], languagesSpoken: ['ENGLISH', 'GERMAN'], partnerTier: 'INTERMEDIATE', partnerScope: ['HOSTING_ENVIRONMENT'], typeOfTeam: 'AGENCY', country: 'UNITED_KINGDOM', city: 'London', hourlyRateUsd: null, projectBudgetMinUsd: null, projectBudgetTypicalUsd: null, skills: ['Enterprise', 'Self-hosting'] },
|
||||
{ slug: 'declined-co', name: 'Declined Co', validationStage: 'REJECTED', availability: 'UNAVAILABLE', introduction: 'Application rejected after review.', calendarLink: CAL, deploymentExpertise: ['CLOUD'], region: ['MENA'], languagesSpoken: ['ENGLISH', 'ARABIC'], partnerTier: 'NEW', partnerScope: ['APPS'], typeOfTeam: 'SOLO', country: 'UNITED_ARAB_EMIRATES', city: 'Dubai', hourlyRateUsd: null, projectBudgetMinUsd: null, projectBudgetTypicalUsd: null, skills: ['MENA', 'Arabic'] },
|
||||
];
|
||||
|
||||
const COMPANIES = [
|
||||
@@ -118,7 +131,13 @@ async function main() {
|
||||
introduction: p.introduction, calendarLink: { primaryLinkUrl: p.calendarLink },
|
||||
deploymentExpertise: p.deploymentExpertise, region: p.region, languagesSpoken: p.languagesSpoken,
|
||||
partnerTier: p.partnerTier, partnerScope: p.partnerScope, typeOfTeam: p.typeOfTeam,
|
||||
country: p.country,
|
||||
country: p.country, city: p.city,
|
||||
skills: p.skills,
|
||||
linkedin: { primaryLinkUrl: linkedin(p.slug) },
|
||||
profilePicture: { primaryLinkUrl: avatar(p.slug) },
|
||||
...(p.hourlyRateUsd != null ? { hourlyRate: usd(p.hourlyRateUsd) } : {}),
|
||||
...(p.projectBudgetMinUsd != null ? { projectBudgetMin: usd(p.projectBudgetMinUsd) } : {}),
|
||||
...(p.projectBudgetTypicalUsd != null ? { projectBudgetTypical: usd(p.projectBudgetTypicalUsd) } : {}),
|
||||
};
|
||||
const id = partnerIdBySlug.get(p.slug);
|
||||
if (id) {
|
||||
|
||||
Reference in New Issue
Block a user