diff --git a/apps/tools/contentful-role-permissions/constants/index.ts b/apps/tools/contentful-role-permissions/constants/index.ts index 10c9a6ca3257..2953394c20b9 100644 --- a/apps/tools/contentful-role-permissions/constants/index.ts +++ b/apps/tools/contentful-role-permissions/constants/index.ts @@ -35,6 +35,7 @@ export const DEFAULT_EDITABLE_ENTRY_TYPE_IDS = [ 'genericTag', 'genericTagGroup', 'graphCard', + 'grantCardsList', 'latestNewsSlice', 'linkGroup', 'menuLink', diff --git a/apps/web/components/GrantCardsList/GrantCardsList.tsx b/apps/web/components/GrantCardsList/GrantCardsList.tsx new file mode 100644 index 000000000000..c2b816118539 --- /dev/null +++ b/apps/web/components/GrantCardsList/GrantCardsList.tsx @@ -0,0 +1,192 @@ +import format from 'date-fns/format' +import localeEN from 'date-fns/locale/en-GB' +import localeIS from 'date-fns/locale/is' +import { useRouter } from 'next/router' + +import { ActionCard, Box, InfoCardGrid } from '@island.is/island-ui/core' +import { Locale } from '@island.is/shared/types' +import { isDefined } from '@island.is/shared/utils' +import { + Grant, + GrantCardsList as GrantCardsListSchema, + GrantStatus, +} from '@island.is/web/graphql/schema' +import { useLinkResolver } from '@island.is/web/hooks' +import { useI18n } from '@island.is/web/i18n' + +import { TranslationKeys } from './types' + +interface SliceProps { + slice: GrantCardsListSchema +} + +const formatDate = ( + date: Date, + locale: Locale, + stringFormat = 'dd. MMMM yyyy', +): string | undefined => { + try { + return format(date, stringFormat, { + locale: locale === 'is' ? localeIS : localeEN, + }) + } catch (e) { + console.warn('Error formatting date') + return + } +} + +const containsTimePart = (date: string) => date.includes('T') + +const GrantCardsList = ({ slice }: SliceProps) => { + const { activeLocale } = useI18n() + const { linkResolver } = useLinkResolver() + const router = useRouter() + + const namespace = slice.namespace + + const getTranslationString = ( + key: keyof TranslationKeys, + argToInterpolate?: string, + ) => + argToInterpolate + ? namespace[key].replace('{arg}', argToInterpolate) + : namespace[key] + + const parseStatus = (grant: Grant): string | undefined => { + switch (grant.status) { + case GrantStatus.Closed: { + const date = grant.dateTo + ? formatDate(new Date(grant.dateTo), activeLocale) + : undefined + return date + ? getTranslationString( + containsTimePart(date) + ? 'applicationWasOpenToAndWith' + : 'applicationWasOpenTo', + date, + ) + : getTranslationString('applicationClosed') + } + case GrantStatus.ClosedOpeningSoon: { + const date = grant.dateFrom + ? formatDate(new Date(grant.dateFrom), activeLocale) + : undefined + return date + ? getTranslationString('applicationOpensAt', date) + : getTranslationString('applicationClosed') + } + case GrantStatus.ClosedOpeningSoonWithEstimation: { + const date = grant.dateFrom + ? formatDate(new Date(grant.dateFrom), activeLocale, 'MMMM yyyy') + : undefined + return date + ? getTranslationString('applicationEstimatedOpensAt', date) + : getTranslationString('applicationClosed') + } + case GrantStatus.AlwaysOpen: { + return getTranslationString('applicationAlwaysOpen') + } + case GrantStatus.Open: { + const date = grant.dateTo + ? formatDate(new Date(grant.dateTo), activeLocale, 'dd. MMMM.') + : undefined + return date + ? getTranslationString( + containsTimePart(date) + ? 'applicationOpensToWithDay' + : 'applicationOpensTo', + date, + ) + : getTranslationString('applicationOpen') + } + case GrantStatus.ClosedWithNote: + case GrantStatus.OpenWithNote: { + return getTranslationString('applicationSeeDescription') + } + default: + return + } + } + + if (slice.resolvedGrantsList?.items.length === 1) { + const grant = slice.resolvedGrantsList.items[0] + return ( + router.push(grant.applicationUrl?.slug ?? ''), + icon: 'open', + iconType: 'outline', + }} + /> + ) + } + + const cards = slice.resolvedGrantsList?.items + ?.map((grant) => { + if (grant.id) { + return { + id: grant.id, + eyebrow: grant.fund?.title ?? grant.name ?? '', + subEyebrow: grant.fund?.parentOrganization?.title, + title: grant.name ?? '', + description: grant.description ?? '', + logo: + grant.fund?.featuredImage?.url ?? + grant.fund?.parentOrganization?.logo?.url ?? + '', + logoAlt: + grant.fund?.featuredImage?.title ?? + grant.fund?.parentOrganization?.logo?.title ?? + '', + link: { + label: getTranslationString('applicationClosed'), + href: linkResolver( + 'styrkjatorggrant', + [grant?.applicationId ?? ''], + activeLocale, + ).href, + }, + detailLines: [ + grant.dateFrom && grant.dateTo + ? { + icon: 'calendar' as const, + text: `${format( + new Date(grant.dateFrom), + 'dd.MM.yyyy', + )} - ${format(new Date(grant.dateTo), 'dd.MM.yyyy')}`, + } + : null, + grant.status + ? { + icon: 'time' as const, + text: parseStatus(grant), + } + : undefined, + grant.categoryTags + ? { + icon: 'informationCircle' as const, + text: grant.categoryTags + .map((ct) => ct.title) + .filter(isDefined) + .join(', '), + } + : undefined, + ].filter(isDefined), + } + } + return null + }) + .filter(isDefined) + + return ( + + + + ) +} + +export default GrantCardsList diff --git a/apps/web/components/GrantCardsList/index.ts b/apps/web/components/GrantCardsList/index.ts new file mode 100644 index 000000000000..2af26be237b2 --- /dev/null +++ b/apps/web/components/GrantCardsList/index.ts @@ -0,0 +1,5 @@ +import dynamic from 'next/dynamic' + +export const GrantCardsList = dynamic(() => import('./GrantCardsList'), { + ssr: true, +}) diff --git a/apps/web/components/GrantCardsList/types.ts b/apps/web/components/GrantCardsList/types.ts new file mode 100644 index 000000000000..b7d504feb245 --- /dev/null +++ b/apps/web/components/GrantCardsList/types.ts @@ -0,0 +1,18 @@ +export type TranslationKeys = Partial< + Record< + | 'seeMore' + | 'apply' + | 'applicationOpen' + | 'applicationClosed' + | 'applicationOpensSoon' + | 'applicationSeeDescription' + | 'applicationOpensAt' + | 'applicationEstimatedOpensAt' + | 'applicationOpensTo' + | 'applicationOpensToWithDay' + | 'applicationWasOpenTo' + | 'applicationWasOpenToAndWith' + | 'applicationAlwaysOpen', + string + > +> diff --git a/apps/web/components/TableOfContents/TableOfContents.tsx b/apps/web/components/TableOfContents/TableOfContents.tsx index ca369e5327a4..0a2c3a607cf6 100644 --- a/apps/web/components/TableOfContents/TableOfContents.tsx +++ b/apps/web/components/TableOfContents/TableOfContents.tsx @@ -1,7 +1,7 @@ import { FC, useMemo } from 'react' -import { Slice } from '@island.is/api/schema' import { TableOfContents } from '@island.is/island-ui/core' +import { Slice } from '@island.is/web/graphql/schema' import { scrollTo } from '@island.is/web/hooks/useScrollSpy' export const TOC: FC< diff --git a/apps/web/screens/Home/Home.tsx b/apps/web/screens/Home/Home.tsx index 98bd37bb102a..a6b9087537ea 100644 --- a/apps/web/screens/Home/Home.tsx +++ b/apps/web/screens/Home/Home.tsx @@ -1,34 +1,36 @@ import React, { useContext } from 'react' -import { Box, GridContainer } from '@island.is/island-ui/core' -import { useI18n } from '@island.is/web/i18n' -import { Screen } from '@island.is/web/types' -import { useNamespace } from '@island.is/web/hooks' + +import { Box } from '@island.is/island-ui/core' +import { Locale } from '@island.is/shared/types' +import { + CategoryItems, + LifeEventsSection, + NewsItems, + SearchSection, + WatsonChatPanel, +} from '@island.is/web/components' +import { FRONTPAGE_NEWS_TAG_ID } from '@island.is/web/constants' +import { GlobalContext } from '@island.is/web/context' import { ContentLanguage, - QueryGetArticleCategoriesArgs, GetArticleCategoriesQuery, GetFrontpageQuery, - QueryGetFrontpageArgs, GetNewsQuery, + LifeEventPage, + QueryGetArticleCategoriesArgs, + QueryGetFrontpageArgs, + QueryGetNewsArgs, } from '@island.is/web/graphql/schema' +import { useNamespace } from '@island.is/web/hooks' +import { useI18n } from '@island.is/web/i18n' +import { withMainLayout } from '@island.is/web/layouts/main' import { GET_CATEGORIES_QUERY, GET_FRONTPAGE_QUERY, GET_NEWS_QUERY, } from '@island.is/web/screens/queries' -import { - SearchSection, - CategoryItems, - NewLinks, - NewsItems, - LifeEventsSection, - WatsonChatPanel, -} from '@island.is/web/components' -import { withMainLayout } from '@island.is/web/layouts/main' -import { GlobalContext } from '@island.is/web/context' -import { LifeEventPage, QueryGetNewsArgs } from '@island.is/api/schema' -import { FRONTPAGE_NEWS_TAG_ID } from '@island.is/web/constants' -import { Locale } from '@island.is/shared/types' +import { Screen } from '@island.is/web/types' + import { watsonConfig } from './config' interface HomeProps { diff --git a/apps/web/screens/queries/fragments.ts b/apps/web/screens/queries/fragments.ts index bae909c52854..0b5df98aab71 100644 --- a/apps/web/screens/queries/fragments.ts +++ b/apps/web/screens/queries/fragments.ts @@ -792,6 +792,64 @@ export const slices = gql` aspectRatio } + fragment GrantCardsListFields on GrantCardsList { + __typename + id + title + displayTitle + namespace + resolvedGrantsList { + total + items { + id + name + description + applicationId + applicationUrl { + slug + type + } + dateFrom + dateTo + status + statusText + categoryTags { + id + title + genericTagGroup { + title + } + } + typeTag { + id + title + genericTagGroup { + title + } + } + fund { + id + title + link { + slug + type + } + featuredImage { + id + url + } + parentOrganization { + id + title + logo { + url + } + } + } + } + } + } + fragment LatestEventsSliceFields on LatestEventsSlice { title events { @@ -965,6 +1023,7 @@ export const slices = gql` ...FeaturedEventsFields ...GenericListFields ...LatestGenericListItemsFields + ...GrantCardsListFields } fragment AllSlices on Slice { diff --git a/apps/web/utils/richText.tsx b/apps/web/utils/richText.tsx index e0456a2f12ab..a49b8d6cf6a7 100644 --- a/apps/web/utils/richText.tsx +++ b/apps/web/utils/richText.tsx @@ -66,6 +66,7 @@ import { FeaturedSupportQnAs as FeaturedSupportQNAsSchema, Form as FormSchema, GenericList as GenericListSchema, + GrantCardsList as GrantCardsListSchema, MultipleStatistics as MultipleStatisticsSchema, OneColumnText, OverviewLinks as OverviewLinksSliceSchema, @@ -91,6 +92,7 @@ import { UmsCostOfLivingCalculator } from '../components/connected/UmbodsmadurSk import { WHODASCalculator } from '../components/connected/WHODAS/Calculator' import FeaturedEvents from '../components/FeaturedEvents/FeaturedEvents' import FeaturedSupportQNAs from '../components/FeaturedSupportQNAs/FeaturedSupportQNAs' +import { GrantCardsList } from '../components/GrantCardsList' import { EmbedSlice } from '../components/Organization/Slice/EmbedSlice/EmbedSlice' interface TranslationNamespaceProviderProps { @@ -290,6 +292,9 @@ const defaultRenderComponent = { const url = slice?.url ? slice.url + '?w=800' : '' return }, + GrantCardsList: (slice: GrantCardsListSchema) => ( + + ), } export const webRichText = ( diff --git a/libs/cms/src/lib/cms.elasticsearch.service.ts b/libs/cms/src/lib/cms.elasticsearch.service.ts index 181c25af2111..46508e3d8de6 100644 --- a/libs/cms/src/lib/cms.elasticsearch.service.ts +++ b/libs/cms/src/lib/cms.elasticsearch.service.ts @@ -616,6 +616,7 @@ export class CmsElasticsearchService { categories, types, organizations, + funds, }: GetGrantsInput, ): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -705,6 +706,30 @@ export class CmsElasticsearchService { 'tags.key': organizations, }, }, + { + term: { + 'tags.type': 'organization', + }, + }, + ], + }, + }, + }, + }) + } + + if (funds) { + must.push({ + nested: { + path: 'tags', + query: { + bool: { + must: [ + { + terms: { + 'tags.key': funds, + }, + }, { term: { 'tags.type': 'fund', diff --git a/libs/cms/src/lib/cms.module.ts b/libs/cms/src/lib/cms.module.ts index f638377a5c16..f40201661464 100644 --- a/libs/cms/src/lib/cms.module.ts +++ b/libs/cms/src/lib/cms.module.ts @@ -9,6 +9,7 @@ import { FeaturedArticlesResolver, FeaturedEventsResolver, FeaturedSupportQNAsResolver, + GrantCardsListResolver, PowerBiSliceResolver, LatestEventsSliceResolver, TeamListResolver, @@ -41,6 +42,7 @@ import { OrganizationTitleEnByReferenceIdLoader } from './loaders/organizationTi LatestNewsSliceResolver, FeaturedArticlesResolver, FeaturedEventsResolver, + GrantCardsListResolver, FeaturedSupportQNAsResolver, OrganizationLogoByNationalIdLoader, OrganizationLogoByReferenceIdLoader, diff --git a/libs/cms/src/lib/cms.resolver.ts b/libs/cms/src/lib/cms.resolver.ts index cc26a42e6209..07606c82efb1 100644 --- a/libs/cms/src/lib/cms.resolver.ts +++ b/libs/cms/src/lib/cms.resolver.ts @@ -136,6 +136,7 @@ import { GetOrganizationPageStandaloneSitemapLevel1Input, GetOrganizationPageStandaloneSitemapLevel2Input, } from './dto/getOrganizationPageStandaloneSitemap.input' +import { GrantCardsList } from './models/grantCardsList.model' const defaultCache: CacheControlOptions = { maxAge: CACHE_CONTROL_MAX_AGE } @@ -851,6 +852,42 @@ export class PowerBiSliceResolver { } } +@Resolver(() => GrantCardsList) +@CacheControl(defaultCache) +export class GrantCardsListResolver { + constructor( + private cmsElasticsearchService: CmsElasticsearchService, + private cmsContentfulService: CmsContentfulService, + ) {} + + @ResolveField(() => GrantList) + async resolvedGrantsList( + @Parent() { resolvedGrantsList: input }: GrantCardsList, + ): Promise { + if (!input || input?.size === 0) { + return { total: 0, items: [] } + } + + return this.cmsElasticsearchService.getGrants( + getElasticsearchIndex(input.lang), + input, + ) + } + @ResolveField(() => GraphQLJSONObject) + async namespace(@Parent() { resolvedGrantsList: input }: GrantCardsList) { + try { + const respones = await this.cmsContentfulService.getNamespace( + 'GrantsPlaza', + input?.lang ?? 'is', + ) + return JSON.parse(respones?.fields || '{}') + } catch { + // Fallback to empty object in case something goes wrong when fetching or parsing namespace + return {} + } + } +} + @Resolver(() => FeaturedEvents) @CacheControl(defaultCache) export class FeaturedEventsResolver { diff --git a/libs/cms/src/lib/dto/getGrants.input.ts b/libs/cms/src/lib/dto/getGrants.input.ts index 27e8e8067ee9..4f278fa6a9ec 100644 --- a/libs/cms/src/lib/dto/getGrants.input.ts +++ b/libs/cms/src/lib/dto/getGrants.input.ts @@ -43,4 +43,9 @@ export class GetGrantsInput { @IsArray() @IsOptional() organizations?: string[] + + @Field(() => [String], { nullable: true }) + @IsArray() + @IsOptional() + funds?: string[] } diff --git a/libs/cms/src/lib/generated/contentfulTypes.d.ts b/libs/cms/src/lib/generated/contentfulTypes.d.ts index 24d4c5a81f65..19c5e8fdbadf 100644 --- a/libs/cms/src/lib/generated/contentfulTypes.d.ts +++ b/libs/cms/src/lib/generated/contentfulTypes.d.ts @@ -1838,7 +1838,7 @@ export interface IGrantFields { /** Application button label */ grantButtonLabel?: string | undefined - /** Date from */ + /** Open from */ grantDateFrom?: string | undefined /** Open from hour */ @@ -1895,6 +1895,43 @@ export interface IGrant extends Entry { } } +export interface IGrantCardsListFields { + /** Title */ + grantCardListTitle: string + + /** Display title? */ + grantCardsListDisplayTitle?: boolean | undefined + + /** Funds */ + grantCardListFunds?: IFund[] | undefined + + /** Max number of cards */ + grantCardsListMaxNumberOfCards?: number | undefined + + /** Sorting */ + grantCardsListSorting?: + | 'Alphabetical' + | 'Most recently updated first' + | undefined +} + +export interface IGrantCardsList extends Entry { + sys: { + id: string + type: string + createdAt: string + updatedAt: string + locale: string + contentType: { + sys: { + id: 'grantCardsList' + linkType: 'ContentType' + type: 'Link' + } + } + } +} + export interface IGraphCardFields { /** Graph Title */ graphTitle: string @@ -5324,6 +5361,7 @@ export type CONTENT_TYPE = | 'genericTag' | 'genericTagGroup' | 'grant' + | 'grantCardsList' | 'graphCard' | 'groupedMenu' | 'hnippTemplate' diff --git a/libs/cms/src/lib/models/grantCardsList.model.ts b/libs/cms/src/lib/models/grantCardsList.model.ts new file mode 100644 index 000000000000..98a749704aa3 --- /dev/null +++ b/libs/cms/src/lib/models/grantCardsList.model.ts @@ -0,0 +1,54 @@ +import { Field, ObjectType, ID, registerEnumType } from '@nestjs/graphql' + +import { IGrantCardsList } from '../generated/contentfulTypes' +import { CacheField } from '@island.is/nest/graphql' +import { SystemMetadata } from '@island.is/shared/types' +import { GrantList } from './grantList.model' +import { GraphQLJSONObject } from 'graphql-type-json' +import { GetGrantsInput } from '../dto/getGrants.input' +import { ElasticsearchIndexLocale } from '@island.is/content-search-index-manager' + +enum CardSorting { + ALPHABETICAL, + MOST_RECENTLY_UPDATED_FIRST, +} + +registerEnumType(CardSorting, { name: 'GrantCardsListSorting' }) + +@ObjectType() +export class GrantCardsList { + @Field(() => ID) + id!: string + + @Field() + title!: string + + @Field({ nullable: true }) + displayTitle?: boolean + + @CacheField(() => GrantList, { nullable: true }) + resolvedGrantsList?: GetGrantsInput + + @CacheField(() => GraphQLJSONObject) + namespace?: typeof GraphQLJSONObject +} + +export const mapGrantCardsList = ({ + fields, + sys, +}: IGrantCardsList): SystemMetadata => { + return { + typename: 'GrantCardsList', + id: sys.id, + title: fields.grantCardListTitle, + displayTitle: fields.grantCardsListDisplayTitle, + resolvedGrantsList: { + lang: + sys.locale === 'is-IS' + ? 'is' + : (sys.locale as ElasticsearchIndexLocale), + funds: fields?.grantCardListFunds?.map((f) => f.sys.id) ?? undefined, + size: fields.grantCardsListMaxNumberOfCards, + }, + } +} diff --git a/libs/cms/src/lib/search/importers/grants.service.ts b/libs/cms/src/lib/search/importers/grants.service.ts index ead7003d046b..a586c9942a80 100644 --- a/libs/cms/src/lib/search/importers/grants.service.ts +++ b/libs/cms/src/lib/search/importers/grants.service.ts @@ -91,10 +91,18 @@ export class GrantsSyncService implements CmsSyncProvider { } }) + if (mapped.fund) { + tags.push({ + type: 'fund', + key: mapped.fund.id, + value: mapped.fund.title, + }) + } + if (mapped.fund?.parentOrganization?.slug) { tags.push({ - key: mapped.fund.parentOrganization.slug, type: 'organization', + key: mapped.fund.parentOrganization.slug, value: mapped.fund.parentOrganization.title, }) } diff --git a/libs/cms/src/lib/unions/slice.union.ts b/libs/cms/src/lib/unions/slice.union.ts index c1030069963e..1f0f3241bc28 100644 --- a/libs/cms/src/lib/unions/slice.union.ts +++ b/libs/cms/src/lib/unions/slice.union.ts @@ -48,6 +48,7 @@ import { IFeaturedEvents, IGenericList, ILatestGenericListItems, + IGrantCardsList, } from '../generated/contentfulTypes' import { Image, mapImage } from '../models/image.model' import { Asset, mapAsset } from '../models/asset.model' @@ -145,6 +146,10 @@ import { LatestGenericListItems, mapLatestGenericListItems, } from '../models/latestGenericListItems.model' +import { + GrantCardsList, + mapGrantCardsList, +} from '../models/grantCardsList.model' export type SliceTypes = | ITimeline @@ -191,6 +196,7 @@ export type SliceTypes = | IChartNumberBox | IFeaturedEvents | IGenericList + | IGrantCardsList | ILatestGenericListItems export const SliceUnion = createUnionType({ @@ -244,6 +250,7 @@ export const SliceUnion = createUnionType({ FeaturedEvents, GenericList, LatestGenericListItems, + GrantCardsList, ], resolveType: (document) => document.typename, // typename is appended to request on indexing }) @@ -341,6 +348,8 @@ export const mapSliceUnion = (slice: SliceTypes): typeof SliceUnion => { return mapGenericList(slice as IGenericList) case 'latestGenericListItems': return mapLatestGenericListItems(slice as ILatestGenericListItems) + case 'grantCardsList': + return mapGrantCardsList(slice as IGrantCardsList) default: throw new ApolloError(`Can not convert to slice: ${contentType}`) } diff --git a/libs/island-ui/core/src/lib/InfoCardGrid/InfoCard.tsx b/libs/island-ui/core/src/lib/InfoCardGrid/InfoCard.tsx index 339d50453d1a..3ec4e8776b7d 100644 --- a/libs/island-ui/core/src/lib/InfoCardGrid/InfoCard.tsx +++ b/libs/island-ui/core/src/lib/InfoCardGrid/InfoCard.tsx @@ -54,7 +54,7 @@ export const InfoCard = ({ size, ...restOfProps }: InfoCardProps) => { component={LinkV2} href={restOfProps.link.href} background={size === 'small' ? 'yellow100' : 'white'} - borderColor="white" + borderColor="blue200" color="blue" borderWidth="standard" width="full"