diff --git a/addon/components/o-s-s/feature-card.hbs b/addon/components/o-s-s/feature-card.hbs
new file mode 100644
index 000000000..6e0ed40af
--- /dev/null
+++ b/addon/components/o-s-s/feature-card.hbs
@@ -0,0 +1,8 @@
+
+
+
{{@title}}
+
{{@description}}
+
+
+

+
\ No newline at end of file
diff --git a/addon/components/o-s-s/feature-card.stories.js b/addon/components/o-s-s/feature-card.stories.js
new file mode 100644
index 000000000..eed57a0d3
--- /dev/null
+++ b/addon/components/o-s-s/feature-card.stories.js
@@ -0,0 +1,90 @@
+import { hbs } from 'ember-cli-htmlbars';
+
+const ColorVariants = ['blue', 'violet', 'yellow'];
+const ShadowVariants = ['sm', 'lg'];
+
+export default {
+ title: 'Components/OSS::FeatureCard',
+ component: 'feature-card',
+ argTypes: {
+ title: {
+ description: 'Card title',
+ table: {
+ type: { summary: 'string' },
+ defaultValue: { summary: 'undefined' }
+ },
+ control: { type: 'text' },
+ type: { required: true }
+ },
+ description: {
+ description: 'Card description',
+ table: {
+ type: { summary: 'string' },
+ defaultValue: { summary: 'undefined' }
+ },
+ control: { type: 'text' },
+ type: { required: true }
+ },
+ image: {
+ description: 'Image object including src and optional alt',
+ table: {
+ type: { summary: '{ src: string; alt?: string }' },
+ defaultValue: { summary: 'undefined' }
+ },
+ control: { type: 'object' },
+ type: { required: true }
+ },
+ colorVariant: {
+ description: 'Card background color variant',
+ table: {
+ type: { summary: ColorVariants.join('|') },
+ defaultValue: { summary: 'violet' }
+ },
+ options: ColorVariants,
+ control: { type: 'select' }
+ },
+ shadowVariant: {
+ description: 'Card shadow variant',
+ table: {
+ type: { summary: ShadowVariants.join('|') },
+ defaultValue: { summary: 'sm' }
+ },
+ options: ShadowVariants,
+ control: { type: 'select' }
+ }
+ },
+ parameters: {
+ docs: {
+ description: {
+ component: 'Displays a card with title, description and illustration. It includes color and shadow variants.'
+ }
+ }
+ }
+};
+
+const defaultArgs = {
+ title: 'Audience & content insights',
+ description: 'Pull demographics and media performance into your BI to target smarter and report faster.',
+ image: {
+ src: '/@upfluence/oss-components/assets/images/no-image.svg',
+ alt: 'No image illustration'
+ },
+ colorVariant: 'violet',
+ shadowVariant: 'lg'
+};
+
+const Template = (args) => ({
+ template: hbs`
+
+ `,
+ context: args
+});
+
+export const Default = Template.bind({});
+Default.args = defaultArgs;
diff --git a/addon/components/o-s-s/feature-card.ts b/addon/components/o-s-s/feature-card.ts
new file mode 100644
index 000000000..d380764bd
--- /dev/null
+++ b/addon/components/o-s-s/feature-card.ts
@@ -0,0 +1,77 @@
+import Component from '@glimmer/component';
+import { assert } from '@ember/debug';
+import { isSafeString } from '@upfluence/oss-components/utils';
+import type { IntlService } from 'ember-intl';
+
+export const COLOR_VARIANTS = ['blue', 'violet', 'yellow'] as const;
+export type OSSFeatureCardColorVariant = (typeof COLOR_VARIANTS)[number];
+const DEFAULT_COLOR_VARIANT = 'violet' as const satisfies OSSFeatureCardColorVariant;
+
+export const SHADOW_VARIANTS = ['sm', 'lg'] as const;
+export type OSSFeatureCardShadowVariant = (typeof SHADOW_VARIANTS)[number];
+const DEFAULT_SHADOW_VARIANT = 'sm' as const satisfies OSSFeatureCardShadowVariant;
+
+export type OSSFeatureCardImage = {
+ src: string;
+ alt?: string;
+};
+
+export type OSSFeatureCardArgs = {
+ title: string | ReturnType;
+ description: string | ReturnType;
+ image: OSSFeatureCardImage;
+ colorVariant?: OSSFeatureCardColorVariant;
+ shadowVariant?: OSSFeatureCardShadowVariant;
+};
+
+export default class OSSFeatureCard extends Component {
+ constructor(owner: unknown, args: OSSFeatureCardArgs) {
+ super(owner, args);
+
+ assert(
+ '[OSS::FeatureCard] The @title parameter is mandatory',
+ (typeof args.title === 'string' || isSafeString(args.title)) && args.title.toString().trim().length > 0
+ );
+ assert(
+ '[OSS::FeatureCard] The @description parameter is mandatory',
+ (typeof args.description === 'string' || isSafeString(args.description)) &&
+ args.description.toString().trim().length > 0
+ );
+ assert(
+ '[OSS::FeatureCard] The @image parameter is mandatory and must contain a src key',
+ typeof args?.image?.src === 'string' && args.image.src.trim().length > 0
+ );
+ if (args.colorVariant) {
+ assert(
+ `[OSS::FeatureCard] @colorVariant must be one of: ${COLOR_VARIANTS.join(', ')}`,
+ COLOR_VARIANTS.includes(args.colorVariant)
+ );
+ }
+ if (args.shadowVariant) {
+ assert(
+ `[OSS::FeatureCard] @shadowVariant must be one of: ${SHADOW_VARIANTS.join(', ')}`,
+ SHADOW_VARIANTS.includes(args.shadowVariant)
+ );
+ }
+ }
+
+ get colorVariant(): OSSFeatureCardColorVariant {
+ return this.args.colorVariant ?? DEFAULT_COLOR_VARIANT;
+ }
+
+ get shadowVariant(): OSSFeatureCardShadowVariant {
+ return this.args.shadowVariant ?? DEFAULT_SHADOW_VARIANT;
+ }
+
+ get computedClasses(): string {
+ return [
+ 'oss-feature-card',
+ `oss-feature-card--color-${this.colorVariant}`,
+ `oss-feature-card--shadow-${this.shadowVariant}`
+ ].join(' ');
+ }
+
+ get imageAlt(): string {
+ return this.args.image.alt ?? '';
+ }
+}
diff --git a/addon/components/o-s-s/feature-cards-container.hbs b/addon/components/o-s-s/feature-cards-container.hbs
new file mode 100644
index 000000000..96c6931c6
--- /dev/null
+++ b/addon/components/o-s-s/feature-cards-container.hbs
@@ -0,0 +1,12 @@
+
+ {{#each this.cardsWithComputedVariants as |card|}}
+
+ {{/each}}
+
\ No newline at end of file
diff --git a/addon/components/o-s-s/feature-cards-container.stories.js b/addon/components/o-s-s/feature-cards-container.stories.js
new file mode 100644
index 000000000..940b6371a
--- /dev/null
+++ b/addon/components/o-s-s/feature-cards-container.stories.js
@@ -0,0 +1,98 @@
+import { hbs } from 'ember-cli-htmlbars';
+
+export default {
+ title: 'Components/OSS::FeatureCardsContainer',
+ component: 'feature-cards-container',
+ argTypes: {
+ cards: {
+ description:
+ '1 to 3 feature cards. Uses the same argument shape as OSS::FeatureCard; overlap and rotation are handled by the container. If colorVariant or shadowVariant are omitted, container defaults are applied.',
+ table: {
+ type: {
+ summary:
+ 'Array<{ title: string; description: string; image: { src: string; alt?: string }; colorVariant?: "blue"|"violet"|"yellow"; shadowVariant?: "sm"|"lg" }>'
+ },
+ defaultValue: { summary: 'undefined' }
+ },
+ control: { type: 'object' },
+ type: { required: true }
+ }
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Wrapper that lays out 1 to 3 OSS::FeatureCard components with angles and overlap. Card color/shadow values are passed through from each card object, with defaults when variants are omitted. See [OSS::FeatureCard](?path=/story/components-oss-featurecard--default) for individual card details.'
+ }
+ }
+ }
+};
+
+const defaultImage = {
+ src: '/@upfluence/oss-components/assets/images/no-image.svg',
+ alt: 'No image illustration'
+};
+
+const threeCardsArgs = {
+ cards: [
+ {
+ title: 'Creator discovery at scale',
+ description:
+ 'Discover and enrich creators via API using platform, region, and key attributes to power precise scouting.',
+ image: defaultImage,
+ colorVariant: 'blue',
+ shadowVariant: 'sm'
+ },
+ {
+ title: 'Audience & content insights',
+ description: 'Pull demographics and media performance into your BI to target smarter and report faster.',
+ image: defaultImage,
+ colorVariant: 'violet',
+ shadowVariant: 'lg'
+ },
+ {
+ title: 'Campaign performance tracking',
+ description: 'Track contribution stages, orders and ROI, then sync results to your CRM.',
+ image: defaultImage,
+ colorVariant: 'yellow',
+ shadowVariant: 'sm'
+ }
+ ]
+};
+
+const twoCardsArgs = {
+ cards: threeCardsArgs.cards.slice(0, 2)
+};
+
+const threeCardsWithDefaultsArgs = {
+ cards: threeCardsArgs.cards.map(({ title, description, image }) => ({
+ title,
+ description,
+ image
+ }))
+};
+
+const oneCardArgs = {
+ cards: threeCardsArgs.cards.slice(0, 1)
+};
+
+const Template = (args) => ({
+ template: hbs`
+
+
+
+ `,
+ context: args
+});
+
+export const ThreeCards = Template.bind({});
+ThreeCards.args = threeCardsArgs;
+
+export const ThreeCardsWithContainerDefaults = Template.bind({});
+ThreeCardsWithContainerDefaults.args = threeCardsWithDefaultsArgs;
+
+export const TwoCards = Template.bind({});
+TwoCards.args = twoCardsArgs;
+
+export const OneCard = Template.bind({});
+OneCard.args = oneCardArgs;
diff --git a/addon/components/o-s-s/feature-cards-container.ts b/addon/components/o-s-s/feature-cards-container.ts
new file mode 100644
index 000000000..ea9e9353b
--- /dev/null
+++ b/addon/components/o-s-s/feature-cards-container.ts
@@ -0,0 +1,56 @@
+import { assert } from '@ember/debug';
+import Component from '@glimmer/component';
+
+import type { OSSFeatureCardArgs, OSSFeatureCardColorVariant, OSSFeatureCardShadowVariant } from './feature-card';
+
+type OSSFeatureCardsContainerArgs = {
+ cards: OSSFeatureCardArgs[];
+};
+
+function isCenterCard(cardsCount: number, index: number): boolean {
+ return cardsCount === 1 || (cardsCount === 3 && index === 1);
+}
+
+function getDefaultCardColorVariant(cardsCount: number, index: number): OSSFeatureCardColorVariant {
+ if (isCenterCard(cardsCount, index)) return 'violet';
+
+ if (cardsCount === 2 || cardsCount === 3) return index === 0 ? 'blue' : 'yellow';
+
+ assert('[OSS::FeatureCardsContainer] Internal layout configuration mismatch', false);
+}
+
+function getDefaultCardShadowVariant(cardsCount: number, index: number): OSSFeatureCardShadowVariant {
+ if (isCenterCard(cardsCount, index)) return 'lg';
+
+ if (cardsCount === 2 || cardsCount === 3) return 'sm';
+
+ assert('[OSS::FeatureCardsContainer] Internal layout configuration mismatch', false);
+}
+
+export default class OSSFeatureCardsContainer extends Component {
+ constructor(owner: unknown, args: OSSFeatureCardsContainerArgs) {
+ super(owner, args);
+
+ assert('[OSS::FeatureCardsContainer] The @cards parameter is mandatory', Array.isArray(args.cards));
+ assert(
+ '[OSS::FeatureCardsContainer] @cards must contain between 1 and 3 cards',
+ args.cards.length >= 1 && args.cards.length <= 3
+ );
+ }
+
+ get cardsWithComputedVariants(): OSSFeatureCardArgs[] {
+ const cards = this.args.cards ?? [];
+
+ return cards.map((card, index) => {
+ const cardCount = cards.length;
+ const colorVariant = card.colorVariant ?? getDefaultCardColorVariant(cardCount, index);
+ const shadowVariant = card.shadowVariant ?? getDefaultCardShadowVariant(cardCount, index);
+
+ return {
+ ...card,
+ colorVariant,
+ shadowVariant
+ };
+ });
+ }
+}
diff --git a/app/components/o-s-s/feature-card.js b/app/components/o-s-s/feature-card.js
new file mode 100644
index 000000000..127673b07
--- /dev/null
+++ b/app/components/o-s-s/feature-card.js
@@ -0,0 +1 @@
+export { default } from '@upfluence/oss-components/components/o-s-s/feature-card';
diff --git a/app/components/o-s-s/feature-cards-container.js b/app/components/o-s-s/feature-cards-container.js
new file mode 100644
index 000000000..6d9cc2829
--- /dev/null
+++ b/app/components/o-s-s/feature-cards-container.js
@@ -0,0 +1 @@
+export { default } from '@upfluence/oss-components/components/o-s-s/feature-cards-container';
diff --git a/app/styles/molecules/feature-card.less b/app/styles/molecules/feature-card.less
new file mode 100644
index 000000000..7f79ae485
--- /dev/null
+++ b/app/styles/molecules/feature-card.less
@@ -0,0 +1,62 @@
+.oss-feature-card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-px-24);
+ align-items: flex-start;
+ justify-content: center;
+ max-inline-size: 342px;
+ padding: var(--spacing-px-30) var(--spacing-px-60);
+ border: 1px solid;
+ border-radius: var(--border-radius-md);
+ font-family: var(--font-family-stack);
+
+ &--color-blue {
+ background-color: var(--color-blue-50);
+ border-color: var(--color-blue-100);
+ }
+
+ &--color-violet {
+ background-color: var(--color-violet-50);
+ border-color: var(--color-violet-100);
+ }
+
+ &--color-yellow {
+ background-color: var(--color-warning-50);
+ border-color: var(--color-warning-100);
+ }
+
+ &--shadow-sm {
+ box-shadow: var(--box-shadow-sm);
+ }
+
+ &--shadow-lg {
+ box-shadow: var(--box-shadow-lg);
+ }
+
+ &__content {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-px-3);
+ }
+
+ &__description {
+ --max-number-of-lines: 4;
+
+ font-size: var(--font-size-sm);
+ color: var(--color-gray-600);
+ text-wrap: balance;
+ margin: 0;
+
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: var(--max-number-of-lines);
+ line-clamp: var(--max-number-of-lines);
+ }
+
+ &__illustration {
+ inline-size: 100%;
+ block-size: auto;
+ max-block-size: 122px;
+ object-fit: cover;
+ }
+}
diff --git a/app/styles/molecules/feature-cards-container.less b/app/styles/molecules/feature-cards-container.less
new file mode 100644
index 000000000..78002fdb7
--- /dev/null
+++ b/app/styles/molecules/feature-cards-container.less
@@ -0,0 +1,45 @@
+.oss-feature-cards-container {
+ --two-cards-offset-x: 45%;
+ --three-cards-offset-x: 80%;
+ --rotation-angle: 11.25deg;
+ --center-card-top: 30px;
+ --side-cards-top: 58px;
+
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-block-size: 430px;
+ inline-size: 100%;
+
+ &__item {
+ position: absolute;
+
+ // To target child i in a list of n, we can use: :nth-child(i):nth-last-child(n - i + 1)
+ // Specific 2 cards layout
+ &:first-child:nth-last-child(2) {
+ translate: calc(-1 * var(--two-cards-offset-x));
+ rotate: calc(-1 * var(--rotation-angle));
+ }
+ &:last-child:nth-child(2) {
+ translate: var(--two-cards-offset-x);
+ rotate: var(--rotation-angle);
+ }
+
+ // Specific 3 cards layout
+ &:first-child:nth-last-child(3) {
+ top: var(--side-cards-top);
+ translate: calc(-1 * var(--three-cards-offset-x));
+ rotate: calc(-1 * var(--rotation-angle));
+ }
+ &:nth-child(2):nth-last-child(2) {
+ top: var(--center-card-top);
+ z-index: 1;
+ }
+ &:last-child:nth-child(3) {
+ top: var(--side-cards-top);
+ translate: var(--three-cards-offset-x);
+ rotate: var(--rotation-angle);
+ }
+ }
+}
diff --git a/app/styles/oss-components.less b/app/styles/oss-components.less
index baab44087..ce7f4b0b1 100644
--- a/app/styles/oss-components.less
+++ b/app/styles/oss-components.less
@@ -71,6 +71,8 @@
@import 'molecules/password-input';
@import 'molecules/avatar-group';
@import 'molecules/stack-container';
+@import 'molecules/feature-card';
+@import 'molecules/feature-cards-container';
@import 'molecules/onboarding-state';
@import 'molecules/infinite-select-option';
@import 'organisms/table';
diff --git a/tests/dummy/app/controllers/visual.ts b/tests/dummy/app/controllers/visual.ts
index 02407c670..8da87bdea 100644
--- a/tests/dummy/app/controllers/visual.ts
+++ b/tests/dummy/app/controllers/visual.ts
@@ -3,6 +3,7 @@ import { tracked } from '@glimmer/tracking';
import { action, set } from '@ember/object';
import type { ModeSwitchOption } from '@upfluence/oss-components/components/o-s-s/mode-switch';
import type { FeedbackMessage } from '@upfluence/oss-components/components/o-s-s/input-container';
+import type { OSSFeatureCardArgs, OSSFeatureCardImage } from '@upfluence/oss-components/components/o-s-s/feature-card';
export default class Visual extends Controller {
@tracked toggleValue: boolean = false;
@@ -112,6 +113,44 @@ export default class Visual extends Controller {
@tracked feedbackMessageWarning: FeedbackMessage = { type: 'warning', value: '' };
@tracked feedbackMessageSuccess: FeedbackMessage = { type: 'success', value: '' };
+ featureCardImage: OSSFeatureCardImage = {
+ src: '/@upfluence/oss-components/assets/images/no-image.svg',
+ alt: 'No image illustration'
+ };
+
+ twoFeatureCards: OSSFeatureCardArgs[] = [
+ {
+ title: 'Creator discovery at scale',
+ description:
+ 'Discover and enrich creators via API using platform, region, and key attributes to power precise, data-driven scouting.',
+ image: this.featureCardImage
+ },
+ {
+ title: 'Audience & content insights',
+ description: 'Pull demographics and media performance into your BI to target smarter and report faster.',
+ image: this.featureCardImage
+ }
+ ];
+ threeFeatureCards: OSSFeatureCardArgs[] = [
+ {
+ title: 'Creator discovery at scale',
+ description:
+ 'Discover and enrich creators via API using platform, region, and key attributes to power precise, data-driven scouting.',
+ image: this.featureCardImage
+ },
+ {
+ title: 'Audience & content insights',
+ description: 'Pull demographics and media performance into your BI to target smarter and report faster.',
+ image: this.featureCardImage
+ },
+ {
+ title: 'Campaign performance tracking',
+ description: 'Track contribution stages, orders and discount-code ROI, then sync results to your CRM.',
+ image: this.featureCardImage
+ }
+ ];
+ featureCard: OSSFeatureCardArgs = this.threeFeatureCards[1]!;
+
@tracked progressBarSuccess: number = 30;
@tracked progressBarWarning: number = 25;
@tracked progressBarDanger: number = 15;
diff --git a/tests/dummy/app/templates/visual.hbs b/tests/dummy/app/templates/visual.hbs
index 1f336ff28..e6950b2e3 100644
--- a/tests/dummy/app/templates/visual.hbs
+++ b/tests/dummy/app/templates/visual.hbs
@@ -949,4 +949,29 @@
+
+
\ No newline at end of file
diff --git a/tests/integration/components/o-s-s/feature-card-test.ts b/tests/integration/components/o-s-s/feature-card-test.ts
new file mode 100644
index 000000000..834282065
--- /dev/null
+++ b/tests/integration/components/o-s-s/feature-card-test.ts
@@ -0,0 +1,136 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, setupOnerror } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { COLOR_VARIANTS, SHADOW_VARIANTS } from '@upfluence/oss-components/components/o-s-s/feature-card';
+import { setupIntl } from 'ember-intl/test-support';
+
+module('Integration | Component | o-s-s/feature-card', function (hooks) {
+ setupRenderingTest(hooks);
+ setupIntl(hooks);
+
+ hooks.beforeEach(function () {
+ this.title = this.intl.t('Audience & content insights', { htmlSafe: true });
+ this.description = 'Pull demographics and media performance into your BI to target smarter and report faster.';
+ this.image = {
+ src: '/@upfluence/oss-components/assets/images/no-image.svg',
+ alt: 'No image illustration'
+ };
+ });
+
+ module('Rendering', () => {
+ test('it renders the title', async function (assert) {
+ await render(
+ hbs``
+ );
+
+ assert.dom('.oss-feature-card').exists();
+ assert.dom('.oss-feature-card>:first-child>:first-child').hasText(this.title.toString());
+ });
+
+ test('it renders the description', async function (assert) {
+ await render(
+ hbs``
+ );
+
+ assert.dom('.oss-feature-card').exists();
+ assert.dom('.oss-feature-card__description').hasText(this.description);
+ });
+
+ test('it renders the image', async function (assert) {
+ await render(
+ hbs``
+ );
+
+ assert.dom('.oss-feature-card').exists();
+ assert.dom('.oss-feature-card__illustration').hasAttribute('src', this.image.src);
+ assert.dom('.oss-feature-card__illustration').hasAttribute('alt', this.image.alt);
+ });
+
+ test('it renders extra attributes (splattributes)', async function (assert) {
+ await render(
+ hbs``
+ );
+
+ assert.dom('[data-control-name="feature-card-test"]').exists();
+ });
+ });
+
+ module('Variants', () => {
+ COLOR_VARIANTS.forEach((colorVariant) => {
+ test(`it supports the ${colorVariant} color variant`, async function (assert) {
+ this.colorVariant = colorVariant;
+
+ await render(
+ hbs``
+ );
+
+ assert.dom('.oss-feature-card').hasClass(`oss-feature-card--color-${colorVariant}`);
+ });
+ });
+
+ SHADOW_VARIANTS.forEach((shadowVariant) => {
+ test(`it supports the ${shadowVariant} shadow variant`, async function (assert) {
+ this.shadowVariant = shadowVariant;
+
+ await render(
+ hbs``
+ );
+
+ assert.dom('.oss-feature-card').hasClass(`oss-feature-card--shadow-${shadowVariant}`);
+ });
+ });
+ });
+
+ module('Error management', () => {
+ test('it throws when @title is missing', async function (assert) {
+ setupOnerror((err: any) => {
+ assert.equal(err.message, 'Assertion Failed: [OSS::FeatureCard] The @title parameter is mandatory');
+ });
+
+ await render(hbs``);
+ });
+
+ test('it throws when @description is missing', async function (assert) {
+ setupOnerror((err: any) => {
+ assert.equal(err.message, 'Assertion Failed: [OSS::FeatureCard] The @description parameter is mandatory');
+ });
+
+ await render(hbs``);
+ });
+
+ test('it throws when @image is missing', async function (assert) {
+ setupOnerror((err: any) => {
+ assert.equal(
+ err.message,
+ 'Assertion Failed: [OSS::FeatureCard] The @image parameter is mandatory and must contain a src key'
+ );
+ });
+
+ await render(hbs``);
+ });
+
+ test('it throws when @colorVariant is unknown', async function (assert) {
+ setupOnerror((err: any) => {
+ assert.equal(
+ err.message,
+ 'Assertion Failed: [OSS::FeatureCard] @colorVariant must be one of: blue, violet, yellow'
+ );
+ });
+
+ await render(
+ hbs``
+ );
+ });
+
+ test('it throws when @shadowVariant is unknown', async function (assert) {
+ setupOnerror((err: any) => {
+ assert.equal(err.message, 'Assertion Failed: [OSS::FeatureCard] @shadowVariant must be one of: sm, lg');
+ });
+
+ await render(
+ hbs``
+ );
+ });
+ });
+});
diff --git a/tests/integration/components/o-s-s/feature-cards-container-test.ts b/tests/integration/components/o-s-s/feature-cards-container-test.ts
new file mode 100644
index 000000000..00b25516c
--- /dev/null
+++ b/tests/integration/components/o-s-s/feature-cards-container-test.ts
@@ -0,0 +1,150 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { render, setupOnerror } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import type { OSSFeatureCardArgs } from '@upfluence/oss-components/components/o-s-s/feature-card';
+
+module('Integration | Component | o-s-s/feature-cards-container', function (hooks) {
+ setupRenderingTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.image = {
+ src: '/@upfluence/oss-components/assets/images/no-image.svg',
+ alt: 'No image illustration'
+ };
+ this.cards = [
+ {
+ title: 'Creator discovery at scale',
+ description: 'Discover and enrich creators via API.',
+ image: this.image,
+ colorVariant: 'yellow',
+ shadowVariant: 'lg'
+ },
+ {
+ title: 'Audience & content insights',
+ description: 'Pull demographics and media performance into your BI.',
+ image: this.image,
+ colorVariant: 'blue'
+ },
+ {
+ title: 'Campaign performance tracking',
+ description: 'Track contribution stages and ROI.',
+ image: this.image
+ }
+ ];
+
+ this.defaultedCards = this.cards.map((card: OSSFeatureCardArgs) => ({
+ title: card.title,
+ description: card.description,
+ image: card.image
+ }));
+ });
+
+ test('it renders', async function (assert) {
+ this.cards = this.cards.slice(0, 3);
+
+ await render(hbs``);
+
+ assert.dom('.oss-feature-cards-container').exists();
+ assert.dom('.oss-feature-cards-container .oss-feature-card').exists({ count: 3 });
+ });
+
+ test('it supports 1 card layout', async function (assert) {
+ this.cards = this.cards.slice(0, 1);
+
+ await render(hbs``);
+
+ assert.dom('.oss-feature-cards-container .oss-feature-card').exists({ count: 1 });
+ assert
+ .dom('.oss-feature-cards-container>:first-child')
+ .hasClass('oss-feature-cards-container__item')
+ .hasClass('oss-feature-card--color-yellow')
+ .hasClass('oss-feature-card--shadow-lg');
+ });
+
+ test('it supports 2 cards layout', async function (assert) {
+ this.set('cards', this.cards.slice(0, 2));
+
+ await render(hbs``);
+
+ assert.dom('.oss-feature-cards-container .oss-feature-card').exists({ count: 2 });
+
+ assert
+ .dom('.oss-feature-cards-container>:first-child')
+ .hasClass('oss-feature-card--color-yellow')
+ .hasClass('oss-feature-card--shadow-lg');
+
+ assert
+ .dom('.oss-feature-cards-container>:last-child')
+ .hasClass('oss-feature-card--color-blue')
+ .hasClass('oss-feature-card--shadow-sm');
+ });
+
+ test('it applies computed layout rules for 3 cards', async function (assert) {
+ this.cards = this.cards.slice(0, 3);
+
+ await render(hbs``);
+
+ assert
+ .dom('.oss-feature-cards-container>:first-child')
+ .hasClass('oss-feature-card--color-yellow')
+ .hasClass('oss-feature-card--shadow-lg');
+
+ assert
+ .dom('.oss-feature-cards-container>:nth-child(2)')
+ .hasClass('oss-feature-cards-container__item')
+ .hasClass('oss-feature-card--color-blue')
+ .hasClass('oss-feature-card--shadow-lg');
+
+ assert
+ .dom('.oss-feature-cards-container>:last-child')
+ .hasClass('oss-feature-card--color-yellow')
+ .hasClass('oss-feature-card--shadow-sm');
+ });
+
+ test('it sets default color and shadow variants when missing', async function (assert) {
+ this.cards = this.defaultedCards;
+
+ await render(hbs``);
+
+ assert.dom('.oss-feature-cards-container .oss-feature-card').exists({ count: 3 });
+
+ assert
+ .dom('.oss-feature-cards-container>:nth-child(1)')
+ .hasClass('oss-feature-card--color-blue')
+ .hasClass('oss-feature-card--shadow-sm');
+
+ assert
+ .dom('.oss-feature-cards-container>:nth-child(2)')
+ .hasClass('oss-feature-card--color-violet')
+ .hasClass('oss-feature-card--shadow-lg');
+
+ assert
+ .dom('.oss-feature-cards-container>:nth-child(3)')
+ .hasClass('oss-feature-card--color-yellow')
+ .hasClass('oss-feature-card--shadow-sm');
+ });
+
+ module('Error management', () => {
+ test('it throws when @cards is missing', async function (assert) {
+ setupOnerror((err: any) => {
+ assert.equal(err.message, 'Assertion Failed: [OSS::FeatureCardsContainer] The @cards parameter is mandatory');
+ });
+
+ await render(hbs``);
+ });
+
+ test('it throws when @cards has invalid count', async function (assert) {
+ this.cards = [];
+
+ setupOnerror((err: any) => {
+ assert.equal(
+ err.message,
+ 'Assertion Failed: [OSS::FeatureCardsContainer] @cards must contain between 1 and 3 cards'
+ );
+ });
+
+ await render(hbs``);
+ });
+ });
+});