Розробка кастомного плагіна Vendure

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Розробка кастомного плагіна Vendure
Середня
~3-5 робочих днів
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Розробка кастомного плагіна Vendure

Плагіни в Vendure — це NestJS-модулі з додатковими декораторами. Механізм розширення повністю передбачуваний: плагін може додавати GraphQL-резолвери, сущності БД, сервіси, обробники событій, нові поля в існуючі типи. Жодного monkey-patching, лише DI та TypeORM.

Структура плагіна

src/plugins/loyalty/
├── loyalty.plugin.ts          # Точка входу (NestJS Module)
├── loyalty.service.ts         # Бізнес-логіка
├── loyalty.resolver.ts        # GraphQL резолвери
├── loyalty.entity.ts          # TypeORM сущність
├── loyalty-ui/                # Admin UI розширення (опціонально)
│   ├── loyalty.module.ts
│   └── components/
└── types.ts                   # GraphQL типи

Декоратор @VendurePlugin

// loyalty.plugin.ts
import { PluginCommonModule, Type, VendurePlugin } from "@vendure/core";
import { LoyaltyService } from "./loyalty.service";
import { LoyaltyResolver } from "./loyalty.resolver";
import { LoyaltyAccount } from "./loyalty.entity";
import { loyaltyShopApiExtensions, loyaltyAdminApiExtensions } from "./api-extensions";

@VendurePlugin({
  imports: [PluginCommonModule],
  entities: [LoyaltyAccount],
  shopApiExtensions: {
    schema: loyaltyShopApiExtensions,
    resolvers: [LoyaltyResolver],
  },
  adminApiExtensions: {
    schema: loyaltyAdminApiExtensions,
    resolvers: [LoyaltyAdminResolver],
  },
  providers: [LoyaltyService],
  configuration: (config) => {
    // Можна модифікувати глобальну конфіг
    config.orderOptions.orderItemPriceCalculationStrategy =
      new LoyaltyAwarePriceStrategy();
    return config;
  },
})
export class LoyaltyPlugin {}

TypeORM сущність

// loyalty.entity.ts
import {
  DeepPartial,
  Entity,
  Column,
  PrimaryGeneratedColumn,
  ManyToOne,
  CreateDateColumn,
  UpdateDateColumn,
} from "typeorm";
import { Customer, VendureEntity } from "@vendure/core";

@Entity()
export class LoyaltyAccount extends VendureEntity {
  constructor(input?: DeepPartial<LoyaltyAccount>) {
    super(input);
  }

  @ManyToOne(() => Customer, { onDelete: "CASCADE" })
  customer: Customer;

  @Column()
  customerId: string;

  @Column({ default: 0 })
  points: number;

  @Column({ type: "jsonb", nullable: true })
  transactions: LoyaltyTransaction[];

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

interface LoyaltyTransaction {
  type: "earn" | "spend";
  points: number;
  orderId?: string;
  reason: string;
  date: string;
}

Після додавання сущності в entities: [LoyaltyAccount] Vendure створює таблицю через міграцію.

GraphQL розширення схеми

// api-extensions.ts
import gql from "graphql-tag";

export const loyaltyShopApiExtensions = gql`
  type LoyaltyAccount {
    id: ID!
    points: Int!
    transactions: [LoyaltyTransaction!]!
  }

  type LoyaltyTransaction {
    type: String!
    points: Int!
    reason: String!
    date: DateTime!
    orderId: ID
  }

  extend type Query {
    myLoyaltyAccount: LoyaltyAccount
  }

  extend type Mutation {
    redeemLoyaltyPoints(points: Int!): Order!
  }
`;

// Розширення існуючого типу Customer в Admin API
export const loyaltyAdminApiExtensions = gql`
  extend type Customer {
    loyaltyAccount: LoyaltyAccount
  }
`;

Сервіс з EventBus

// loyalty.service.ts
import { Injectable } from "@nestjs/common";
import { EventBus, OrderPlacedEvent, RequestContext, TransactionalConnection } from "@vendure/core";
import { OnEvent } from "@nestjs/event-emitter";
import { LoyaltyAccount } from "./loyalty.entity";

@Injectable()
export class LoyaltyService implements OnApplicationBootstrap {
  constructor(
    private connection: TransactionalConnection,
    private eventBus: EventBus,
  ) {}

  onApplicationBootstrap() {
    // Підписуємось на подію завершення замовлення
    this.eventBus.ofType(OrderPlacedEvent).subscribe(async (event) => {
      await this.awardPointsForOrder(event.ctx, event.order);
    });
  }

  async awardPointsForOrder(ctx: RequestContext, order: Order) {
    const customerId = order.customerId;
    if (!customerId) return; // анонімне замовлення

    const pointsToAward = Math.floor(order.totalWithTax / 100); // 1 бал = 100 коп

    await this.connection.withTransaction(ctx, async (em) => {
      let account = await em.findOne(LoyaltyAccount, {
        where: { customerId },
      });

      if (!account) {
        account = new LoyaltyAccount({
          customerId,
          points: 0,
          transactions: [],
        });
      }

      account.points += pointsToAward;
      account.transactions = [
        ...account.transactions,
        {
          type: "earn",
          points: pointsToAward,
          orderId: order.id,
          reason: `Замовлення #${order.code}`,
          date: new Date().toISOString(),
        },
      ];

      await em.save(account);
    });
  }

  async getAccountByCustomer(ctx: RequestContext, customerId: string) {
    return this.connection
      .getRepository(ctx, LoyaltyAccount)
      .findOne({ where: { customerId } });
  }

  async redeemPoints(ctx: RequestContext, customerId: string, points: number) {
    const account = await this.getAccountByCustomer(ctx, customerId);
    if (!account || account.points < points) {
      throw new UserInputError("Недостатньо балів");
    }
    account.points -= points;
    account.transactions.push({
      type: "spend",
      points,
      reason: "Списання при замовленні",
      date: new Date().toISOString(),
    });
    return this.connection.getRepository(ctx, LoyaltyAccount).save(account);
  }
}

GraphQL Resolver

// loyalty.resolver.ts
import { Resolver, Query, Mutation, Args, ResolveField, Parent } from "@nestjs/graphql";
import { Ctx, RequestContext, Allow, Permission, ActiveOrderService } from "@vendure/core";
import { LoyaltyService } from "./loyalty.service";

@Resolver()
export class LoyaltyResolver {
  constructor(
    private loyaltyService: LoyaltyService,
    private activeOrderService: ActiveOrderService,
  ) {}

  @Query()
  @Allow(Permission.Owner)
  async myLoyaltyAccount(@Ctx() ctx: RequestContext) {
    if (!ctx.activeUserId) return null;
    return this.loyaltyService.getAccountByCustomer(
      ctx,
      ctx.activeUserId.toString()
    );
  }

  @Mutation()
  @Allow(Permission.Owner)
  async redeemLoyaltyPoints(
    @Ctx() ctx: RequestContext,
    @Args("points") points: number,
  ) {
    const order = await this.activeOrderService.getActiveOrder(ctx, undefined);
    if (!order) throw new Error("No active order");
    await this.loyaltyService.redeemPoints(ctx, ctx.activeUserId!.toString(), points);
    // додати знижку до замовлення...
    return order;
  }
}

Реєстрація плагіна в конфігу

// vendure-config.ts
import { LoyaltyPlugin } from "./plugins/loyalty/loyalty.plugin";

export const config: VendureConfig = {
  // ...
  plugins: [
    // ...
    LoyaltyPlugin,
  ],
};

Міграція після додавання плагіна

npm run build
npx ts-node src/migration.ts generate src/migrations/AddLoyaltyPlugin
npx ts-node src/migration.ts run

Тестування плагіна

// loyalty.service.spec.ts
import { Test } from "@nestjs/testing";
import { createTestEnvironment, testConfig } from "@vendure/testing";
import { LoyaltyPlugin } from "./loyalty.plugin";

describe("LoyaltyPlugin", () => {
  const { server, adminClient, shopClient } = createTestEnvironment({
    ...testConfig,
    plugins: [LoyaltyPlugin],
  });

  beforeAll(async () => {
    await server.init({
      initialData,
      productsCsvPath: path.join(__dirname, "test-products.csv"),
    });
  });

  it("awards points after order placement", async () => {
    await shopClient.asUserWithCredentials("[email protected]", "test");
    await shopClient.query(ADD_ITEM_TO_ORDER, { variantId: "T_1", quantity: 1 });
    // ... завершити checkout
    const { myLoyaltyAccount } = await shopClient.query(GET_LOYALTY_ACCOUNT);
    expect(myLoyaltyAccount.points).toBeGreaterThan(0);
  });
});

Vendure надає createTestEnvironment — повнофункціональний тестовий інстанс з in-memory SQLite, без мокувань.