From d35c19f0ec05cf15bb921c7e5c6088c47a5c07d6 Mon Sep 17 00:00:00 2001 From: Damir Date: Fri, 17 Apr 2026 13:47:57 +0200 Subject: [PATCH 1/2] Add product list and producyById support --- .../product-item-checkout.component.html | 6 +- .../product-item/product-item.component.html | 20 +- .../product-item/product-item.component.scss | 67 +++++++ .../product-item/product-item.component.ts | 2 + FE/src/app/products/product.interface.ts | 12 +- FE/src/app/products/products.service.ts | 6 +- FE/src/environments/environment.prod.ts | 4 +- FE/src/environments/environment.ts | 4 +- infra/bin/infra.ts | 9 +- infra/lib/hello-lambda/handler.ts | 5 + infra/lib/hello-lambda/hello-lambda-stack.ts | 49 +++++ infra/lib/product-service/handler.ts | 136 +++++++++++++ .../product-service/product-service-stack.ts | 189 ++++++++++++++++++ infra/test-event.json | 5 + 14 files changed, 493 insertions(+), 21 deletions(-) create mode 100644 infra/lib/hello-lambda/handler.ts create mode 100644 infra/lib/hello-lambda/hello-lambda-stack.ts create mode 100644 infra/lib/product-service/handler.ts create mode 100644 infra/lib/product-service/product-service-stack.ts create mode 100644 infra/test-event.json diff --git a/FE/src/app/cart/product-item-checkout/product-item-checkout.component.html b/FE/src/app/cart/product-item-checkout/product-item-checkout.component.html index 12b19a3b..1dd27987 100644 --- a/FE/src/app/cart/product-item-checkout/product-item-checkout.component.html +++ b/FE/src/app/cart/product-item-checkout/product-item-checkout.component.html @@ -5,9 +5,9 @@ } @@ -16,7 +16,7 @@ [class.col-md-9]="hideControls()" class="col-12 col-md-6" > -

{{ product().title }}

+

{{ product().name }}

{{ product().description }}

diff --git a/FE/src/app/products/product-item/product-item.component.html b/FE/src/app/products/product-item/product-item.component.html index 69de3eae..23692ebc 100644 --- a/FE/src/app/products/product-item/product-item.component.html +++ b/FE/src/app/products/product-item/product-item.component.html @@ -1,18 +1,26 @@
- +
- {{ product().title }} + {{ product().name }} + {{ product().artist }} + + {{ product().category }} • {{ product().genre }} • {{ product().year }} + -

{{ product().price | number: "1.2-2" | currency }}

+

{{ product().description }}

+

{{ product().price | currency }}

+ @if (!product().inStock) { +

Out of Stock

+ }
@@ -22,7 +30,7 @@ #cartBtn (click)="add()" color="accent" - matTooltip="Add {{ product().title }} to cart" + matTooltip="Add {{ product().name }} to cart" mat-icon-button > shopping_cart @@ -32,9 +40,9 @@ #controls="countControls" (increment)="add()" (decrement)="remove()" - [productName]="product().title" + [productName]="product().name" [count]="countInCart()" - [available]="product().count" + [available]="product().inStock ? 999 : 0" > } diff --git a/FE/src/app/products/product-item/product-item.component.scss b/FE/src/app/products/product-item/product-item.component.scss index 28dbb70d..675bdf73 100644 --- a/FE/src/app/products/product-item/product-item.component.scss +++ b/FE/src/app/products/product-item/product-item.component.scss @@ -1,3 +1,10 @@ +// Make all cards the same height +mat-card { + height: 100%; + display: flex; + flex-direction: column; +} + .img-container { padding-top: 90%; position: relative; @@ -17,3 +24,63 @@ object-position: center center; } } + +// Card header section +mat-card-header { + flex-shrink: 0; +} + +.artist { + display: block; + font-size: 0.9em; + font-weight: normal; + color: rgba(0, 0, 0, 0.6); + margin-top: 4px; +} + +mat-card-subtitle { + font-size: 0.85em; + color: rgba(0, 0, 0, 0.5); + margin-top: 4px; +} + +// Card content section - this will expand to fill available space +mat-card-content { + flex: 1; + display: flex; + flex-direction: column; +} + +.description { + font-size: 0.9em; + line-height: 1.4; + margin-bottom: 8px; + color: rgba(0, 0, 0, 0.7); + // Limit to 2 rows with ellipsis + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; // Standard property for compatibility + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + max-height: 2.8em; // 2 lines * 1.4 line-height +} + +.price { + font-size: 1.1em; + font-weight: 600; + color: #2c3e50; + margin: auto 0 8px 0; // Push to bottom of content area +} + +.out-of-stock { + color: #e74c3c; + font-weight: 600; + margin: 0; + font-size: 0.9em; +} + +// Card actions section - always at bottom +mat-card-actions { + flex-shrink: 0; +} diff --git a/FE/src/app/products/product-item/product-item.component.ts b/FE/src/app/products/product-item/product-item.component.ts index d1210071..860a9198 100644 --- a/FE/src/app/products/product-item/product-item.component.ts +++ b/FE/src/app/products/product-item/product-item.component.ts @@ -22,6 +22,7 @@ import { MatCardContent, MatCardHeader, MatCardImage, + MatCardSubtitle, MatCardTitle, } from '@angular/material/card'; @@ -35,6 +36,7 @@ import { MatCardImage, MatCardHeader, MatCardTitle, + MatCardSubtitle, MatCardContent, MatCardActions, MatIconButton, diff --git a/FE/src/app/products/product.interface.ts b/FE/src/app/products/product.interface.ts index e5d35bca..b5ecd64e 100644 --- a/FE/src/app/products/product.interface.ts +++ b/FE/src/app/products/product.interface.ts @@ -1,10 +1,14 @@ export interface Product { - /** Available count */ - count: number; - description: string; id: string; + name: string; + artist: string; + description: string; price: number; - title: string; + category: string; + genre: string; + year: number; + inStock: boolean; + imageUrl: string; } export interface ProductCheckout extends Product { diff --git a/FE/src/app/products/products.service.ts b/FE/src/app/products/products.service.ts index 52045056..3435cc3e 100644 --- a/FE/src/app/products/products.service.ts +++ b/FE/src/app/products/products.service.ts @@ -56,14 +56,14 @@ export class ProductsService extends ApiService { } getProducts(): Observable { - if (!this.endpointEnabled('bff')) { + if (!this.endpointEnabled('product')) { console.warn( - 'Endpoint "bff" is disabled. To enable change your environment.ts config' + 'Endpoint "product" is disabled. To enable change your environment.ts config' ); return this.http.get('/assets/products.json'); } - const url = this.getUrl('bff', 'products'); + const url = this.getUrl('product', 'products'); return this.http.get(url); } diff --git a/FE/src/environments/environment.prod.ts b/FE/src/environments/environment.prod.ts index a3d95f92..ed62f137 100644 --- a/FE/src/environments/environment.prod.ts +++ b/FE/src/environments/environment.prod.ts @@ -3,14 +3,14 @@ import { Config } from './config.interface'; export const environment: Config = { production: true, apiEndpoints: { - product: 'https://.execute-api.eu-west-1.amazonaws.com/dev', + product: 'https://uf4ds80g46.execute-api.us-east-1.amazonaws.com/prod', order: 'https://.execute-api.eu-west-1.amazonaws.com/dev', import: 'https://.execute-api.eu-west-1.amazonaws.com/dev', bff: 'https://.execute-api.eu-west-1.amazonaws.com/dev', cart: 'https://.execute-api.eu-west-1.amazonaws.com/dev', }, apiEndpointsEnabled: { - product: false, + product: true, order: false, import: false, bff: false, diff --git a/FE/src/environments/environment.ts b/FE/src/environments/environment.ts index a9a52a5a..0a673a7c 100644 --- a/FE/src/environments/environment.ts +++ b/FE/src/environments/environment.ts @@ -7,14 +7,14 @@ import { Config } from './config.interface'; export const environment: Config = { production: false, apiEndpoints: { - product: 'https://.execute-api.eu-west-1.amazonaws.com/dev', + product: 'https://uf4ds80g46.execute-api.us-east-1.amazonaws.com/prod', order: 'https://.execute-api.eu-west-1.amazonaws.com/dev', import: 'https://.execute-api.eu-west-1.amazonaws.com/dev', bff: 'https://.execute-api.eu-west-1.amazonaws.com/dev', cart: 'https://.execute-api.eu-west-1.amazonaws.com/dev', }, apiEndpointsEnabled: { - product: false, + product: true, order: false, import: false, bff: false, diff --git a/infra/bin/infra.ts b/infra/bin/infra.ts index 375190b3..c0352d96 100644 --- a/infra/bin/infra.ts +++ b/infra/bin/infra.ts @@ -2,6 +2,8 @@ import 'source-map-support/register'; import * as cdk from 'aws-cdk-lib'; import { DeployWebAppStack } from '../lib/deploy-web-app-stack'; +import { HelloLambdaStack } from '../lib/hello-lambda/hello-lambda-stack'; +import { ProductServiceStack } from '../lib/product-service/product-service-stack'; const app = new cdk.App(); new DeployWebAppStack(app, 'DeployWebAppStack', { @@ -18,4 +20,9 @@ new DeployWebAppStack(app, 'DeployWebAppStack', { // env: { account: '123456789012', region: 'us-east-1' }, /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */ -}); \ No newline at end of file + +}); + +new HelloLambdaStack(app, 'HelloLambdaStack', {}); + +new ProductServiceStack(app, 'ProductServiceStack', {}); \ No newline at end of file diff --git a/infra/lib/hello-lambda/handler.ts b/infra/lib/hello-lambda/handler.ts new file mode 100644 index 00000000..15946c1b --- /dev/null +++ b/infra/lib/hello-lambda/handler.ts @@ -0,0 +1,5 @@ +export async function main(event: { message: any; }) { + return { + message: `SUCCESS with message ${event.message} 🎉` + }; +} \ No newline at end of file diff --git a/infra/lib/hello-lambda/hello-lambda-stack.ts b/infra/lib/hello-lambda/hello-lambda-stack.ts new file mode 100644 index 00000000..9c123331 --- /dev/null +++ b/infra/lib/hello-lambda/hello-lambda-stack.ts @@ -0,0 +1,49 @@ +// Filename: hello-lambda-stack.ts +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as apigateway from "aws-cdk-lib/aws-apigateway"; +import * as cdk from 'aws-cdk-lib'; +import * as path from 'path'; +import { Construct } from 'constructs'; + +export class HelloLambdaStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const lambdaFunction = new lambda.Function(this, 'lambda-function', { + runtime: lambda.Runtime.NODEJS_20_X, + memorySize: 1024, + timeout: cdk.Duration.seconds(5), + handler: 'handler.main', + code: lambda.Code.fromAsset(path.join(__dirname, './')), + }); + + const api = new apigateway.RestApi(this, "my-api", { + restApiName: "My API Gateway", + description: "This API serves the Lambda functions." + }); + + // Filename: hello-lambda-stack.ts +const helloFromLambdaIntegration = new apigateway.LambdaIntegration(lambdaFunction, { + requestTemplates: { + "application/json": + `{ "message": "$input.params('message')" }` // Map the query param message + }, + integrationResponses: [ + { statusCode: '200' }, + ], + proxy: false, +}); + + // Create a resource /hello and GET request under it + const helloResource = api.root.addResource("hello"); + // On this resource attach a GET method which pass reuest to our Lambda function + helloResource.addMethod('GET', helloFromLambdaIntegration, { + methodResponses: [{ statusCode: '200' }] + }); + + helloResource.addCorsPreflight({ + allowOrigins: ['https://your-frontend-url.com'], + allowMethods: ['GET'], + }); + } +} \ No newline at end of file diff --git a/infra/lib/product-service/handler.ts b/infra/lib/product-service/handler.ts new file mode 100644 index 00000000..8ddb95fc --- /dev/null +++ b/infra/lib/product-service/handler.ts @@ -0,0 +1,136 @@ +// Mock product data - Vinyl Records +const mockProducts = [ + { + id: '1', + name: 'The Dark Side of the Moon', + artist: 'Pink Floyd', + description: 'Classic progressive rock masterpiece from 1973', + price: 34.99, + category: 'Classic Rock', + genre: 'Progressive Rock', + year: 1973, + inStock: true, + imageUrl: 'https://upload.wikimedia.org/wikipedia/en/thumb/a/ab/The_Dark_Side_of_the_Moon_cover.svg/1280px-The_Dark_Side_of_the_Moon_cover.svg.png' + }, + { + id: '2', + name: 'Nevermind', + artist: 'Nirvana', + description: 'Groundbreaking alternative rock album that defined the 90s', + price: 29.99, + category: 'Alternative Rock', + genre: 'Grunge', + year: 1991, + inStock: true, + imageUrl: 'https://www.nirvana.com/wp-content/uploads/sites/2438/2023/10/Nevermind-compressed.jpg' + }, + { + id: '3', + name: 'Led Zeppelin IV', + artist: 'Led Zeppelin', + description: 'Iconic hard rock album featuring "Stairway to Heaven"', + price: 37.99, + category: 'Classic Rock', + genre: 'Hard Rock', + year: 1971, + inStock: false, + imageUrl: 'https://m.media-amazon.com/images/I/81x364UAGAL._AC_SX679_.jpg' + }, + { + id: '4', + name: 'OK Computer', + artist: 'Radiohead', + description: 'Influential alternative rock album exploring modern alienation', + price: 32.99, + category: 'Alternative Rock', + genre: 'Art Rock', + year: 1997, + inStock: true, + imageUrl: 'https://cdn-images.dzcdn.net/images/cover/05a186e0a859a36f9cd51cdae2158fe1/0x1900-000000-80-0-0.jpg' + }, + { + id: '5', + name: 'Abbey Road', + artist: 'The Beatles', + description: 'Final studio album from the Fab Four', + price: 39.99, + category: 'Classic Rock', + genre: 'Rock', + year: 1969, + inStock: true, + imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/a/a4/The_Beatles_Abbey_Road_album_cover.jpg' + }, + { + id: '6', + name: 'The Velvet Underground & Nico', + artist: 'The Velvet Underground', + description: 'Influential art rock album with Andy Warhol artwork', + price: 31.99, + category: 'Alternative Rock', + genre: 'Art Rock', + year: 1967, + inStock: true, + imageUrl: 'https://m.media-amazon.com/images/I/61wJx-+0I2L._UF1000,1000_QL80_.jpg' + }, + { + id: '7', + name: 'Rumours', + artist: 'Fleetwood Mac', + description: 'Best-selling album with classic rock anthems', + price: 35.99, + category: 'Classic Rock', + genre: 'Soft Rock', + year: 1977, + inStock: false, + imageUrl: 'https://m.media-amazon.com/images/I/71BekDJBb3L._UF1000,1000_QL80_.jpg' + }, + { + id: '8', + name: 'Is This It', + artist: 'The Strokes', + description: 'Revolutionary garage rock revival album', + price: 28.99, + category: 'Alternative Rock', + genre: 'Garage Rock', + year: 2001, + inStock: true, + imageUrl: 'https://static.wixstatic.com/media/82fcff_03fe4045dcd04b08bebf07b877dc0cd5~mv2.jpg/v1/fill/w_900,h_900,al_c,q_85/82fcff_03fe4045dcd04b08bebf07b877dc0cd5~mv2.jpg' + } +]; + +export async function main(event: any) { + try { + // Return the full array of products + return mockProducts; + } catch (error) { + console.error('Error in getProductsList:', error); + return { + error: 'Internal server error', + message: 'Failed to retrieve products' + }; + } +} + +export async function getProductById(event: any) { + try { + // Extract productId from path parameters + const productId = event.pathParameters?.productId; + + if (!productId) { + throw new Error('Product ID is required'); + } + + // Find product by ID + const product = mockProducts.find(p => p.id === productId); + + if (!product) { + throw new Error(`Product with ID ${productId} not found`); + } + + // Return the found product + return product; + } catch (error) { + console.error('Error in getProductById:', error); + throw error; + } +} diff --git a/infra/lib/product-service/product-service-stack.ts b/infra/lib/product-service/product-service-stack.ts new file mode 100644 index 00000000..827487c0 --- /dev/null +++ b/infra/lib/product-service/product-service-stack.ts @@ -0,0 +1,189 @@ +// Filename: product-service-stack.ts +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as apigateway from "aws-cdk-lib/aws-apigateway"; +import * as cdk from 'aws-cdk-lib'; +import * as path from 'path'; +import { Construct } from 'constructs'; + +export class ProductServiceStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // Create the getProductsList Lambda function + const getProductsListFunction = new lambda.Function(this, 'getProductsList', { + runtime: lambda.Runtime.NODEJS_20_X, + memorySize: 1024, + timeout: cdk.Duration.seconds(10), + handler: 'handler.main', + code: lambda.Code.fromAsset(path.join(__dirname, './')), + environment: { + NODE_ENV: 'production' + } + }); + + // Create the getProductById Lambda function + const getProductByIdFunction = new lambda.Function(this, 'getProductById', { + runtime: lambda.Runtime.NODEJS_20_X, + memorySize: 1024, + timeout: cdk.Duration.seconds(10), + handler: 'handler.getProductById', + code: lambda.Code.fromAsset(path.join(__dirname, './')), + environment: { + NODE_ENV: 'production' + } + }); + + // Create API Gateway for Product Service + const productApi = new apigateway.RestApi(this, "product-api", { + restApiName: "Product Service API", + description: "API for Product Service operations" + }); + + // Create /products resource + const productsResource = productApi.root.addResource("products"); + + // Create Lambda integration for getProductsList + const getProductsIntegration = new apigateway.LambdaIntegration(getProductsListFunction, { + integrationResponses: [ + { + statusCode: '200', + responseParameters: { + 'method.response.header.Access-Control-Allow-Origin': "'*'", + 'method.response.header.Access-Control-Allow-Headers': "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + 'method.response.header.Access-Control-Allow-Methods': "'GET,OPTIONS'" + } + }, + { + statusCode: '500', + responseParameters: { + 'method.response.header.Access-Control-Allow-Origin': "'*'" + } + } + ], + proxy: false, + }); + + // Add GET method to /products endpoint + productsResource.addMethod('GET', getProductsIntegration, { + methodResponses: [ + { + statusCode: '200', + responseParameters: { + 'method.response.header.Access-Control-Allow-Origin': true, + 'method.response.header.Access-Control-Allow-Headers': true, + 'method.response.header.Access-Control-Allow-Methods': true + } + }, + { + statusCode: '500', + responseParameters: { + 'method.response.header.Access-Control-Allow-Origin': true + } + } + ] + }); + + // Add CORS preflight for /products + productsResource.addCorsPreflight({ + allowOrigins: apigateway.Cors.ALL_ORIGINS, + allowMethods: ['GET', 'OPTIONS'], + allowHeaders: ['Content-Type', 'X-Amz-Date', 'Authorization', 'X-Api-Key', 'X-Amz-Security-Token'] + }); + + // Create /products/{productId} resource + const productByIdResource = productsResource.addResource("{productId}"); + + // Create Lambda integration for getProductById + const getProductByIdIntegration = new apigateway.LambdaIntegration(getProductByIdFunction, { + integrationResponses: [ + { + statusCode: '200', + responseParameters: { + 'method.response.header.Access-Control-Allow-Origin': "'*'", + 'method.response.header.Access-Control-Allow-Headers': "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'", + 'method.response.header.Access-Control-Allow-Methods': "'GET,OPTIONS'" + } + }, + { + statusCode: '400', + responseParameters: { + 'method.response.header.Access-Control-Allow-Origin': "'*'" + } + }, + { + statusCode: '404', + responseParameters: { + 'method.response.header.Access-Control-Allow-Origin': "'*'" + } + }, + { + statusCode: '500', + responseParameters: { + 'method.response.header.Access-Control-Allow-Origin': "'*'" + } + } + ], + proxy: false, + }); + + // Add GET method to /products/{productId} endpoint + productByIdResource.addMethod('GET', getProductByIdIntegration, { + methodResponses: [ + { + statusCode: '200', + responseParameters: { + 'method.response.header.Access-Control-Allow-Origin': true, + 'method.response.header.Access-Control-Allow-Headers': true, + 'method.response.header.Access-Control-Allow-Methods': true + } + }, + { + statusCode: '400', + responseParameters: { + 'method.response.header.Access-Control-Allow-Origin': true + } + }, + { + statusCode: '404', + responseParameters: { + 'method.response.header.Access-Control-Allow-Origin': true + } + }, + { + statusCode: '500', + responseParameters: { + 'method.response.header.Access-Control-Allow-Origin': true + } + } + ] + }); + + // Add CORS preflight for /products/{productId} + productByIdResource.addCorsPreflight({ + allowOrigins: apigateway.Cors.ALL_ORIGINS, + allowMethods: ['GET', 'OPTIONS'], + allowHeaders: ['Content-Type', 'X-Amz-Date', 'Authorization', 'X-Api-Key', 'X-Amz-Security-Token'] + }); + + // Output the API URL + new cdk.CfnOutput(this, 'ProductApiUrl', { + value: productApi.url ?? 'API URL not available', + description: 'Product Service API Gateway URL', + exportName: 'ProductApiUrl' + }); + + // Output the specific products endpoint URL + new cdk.CfnOutput(this, 'ProductsEndpointUrl', { + value: productApi.url + 'products', + description: 'Products List Endpoint URL', + exportName: 'ProductsEndpointUrl' + }); + + // Output the product by ID endpoint URL + new cdk.CfnOutput(this, 'ProductByIdEndpointUrl', { + value: productApi.url + 'products/{productId}', + description: 'Product by ID Endpoint URL', + exportName: 'ProductByIdEndpointUrl' + }); + } +} diff --git a/infra/test-event.json b/infra/test-event.json new file mode 100644 index 00000000..220f3aac --- /dev/null +++ b/infra/test-event.json @@ -0,0 +1,5 @@ +{ + "pathParameters": { + "productId": "1" + } +} From a8bca46713cf11c363f78ef32c666e011fe487fa Mon Sep 17 00:00:00 2001 From: Damir Date: Mon, 20 Apr 2026 09:16:40 +0200 Subject: [PATCH 2/2] Fix productId getting correct params --- infra/lib/product-service/handler.ts | 4 ++-- infra/lib/product-service/product-service-stack.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/infra/lib/product-service/handler.ts b/infra/lib/product-service/handler.ts index 8ddb95fc..25e07720 100644 --- a/infra/lib/product-service/handler.ts +++ b/infra/lib/product-service/handler.ts @@ -113,8 +113,8 @@ export async function main(event: any) { export async function getProductById(event: any) { try { - // Extract productId from path parameters - const productId = event.pathParameters?.productId; + // Extract productId from request template + const productId = event.productId; if (!productId) { throw new Error('Product ID is required'); diff --git a/infra/lib/product-service/product-service-stack.ts b/infra/lib/product-service/product-service-stack.ts index 827487c0..ce5f52f6 100644 --- a/infra/lib/product-service/product-service-stack.ts +++ b/infra/lib/product-service/product-service-stack.ts @@ -95,6 +95,11 @@ export class ProductServiceStack extends cdk.Stack { // Create Lambda integration for getProductById const getProductByIdIntegration = new apigateway.LambdaIntegration(getProductByIdFunction, { + requestTemplates: { + "application/json": `{ + "productId": "$input.params('productId')" + }`, + }, integrationResponses: [ { statusCode: '200',