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..25e07720
--- /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 request template
+ const productId = event.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..ce5f52f6
--- /dev/null
+++ b/infra/lib/product-service/product-service-stack.ts
@@ -0,0 +1,194 @@
+// 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, {
+ requestTemplates: {
+ "application/json": `{
+ "productId": "$input.params('productId')"
+ }`,
+ },
+ 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"
+ }
+}