Merge pull request 'beta' (#10) from beta into main
All checks were successful
Production CI/CD Pipeline / build-and-test (push) Successful in 1m29s
Production CI/CD Pipeline / deploy-prod (push) Successful in 38s

Reviewed-on: #10
This commit is contained in:
vignesh 2026-10-04 16:17:24 +00:00
commit 4eaabe1315
26 changed files with 4275 additions and 726 deletions

1
.env.production Normal file
View file

@ -0,0 +1 @@
VITE_API_BASE_URL=https://betapartner-api.tradhox.com/api

12
cdk.json Normal file
View file

@ -0,0 +1,12 @@
{
"app": "npx ts-node --prefer-ts-exts cdk/app.ts",
"watch": {
"include": [
"cdk/**"
],
"exclude": [
"README.md",
"cdk/cdk.out"
]
}
}

View file

@ -1,7 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
import 'source-map-support/register.js';
import * as cdk from 'aws-cdk-lib'; import * as cdk from 'aws-cdk-lib';
import { BetaSupplierSiteStack } from './betasupplier-site-stack.js'; import { BetaSupplierSiteStack } from './betasupplier-site-stack';
const app = new cdk.App(); const app = new cdk.App();
new BetaSupplierSiteStack(app, 'BetaSupplierSiteStack', { new BetaSupplierSiteStack(app, 'BetaSupplierSiteStack', {

View file

@ -20,7 +20,7 @@ export class BetaSupplierSiteStack extends cdk.Stack {
encryption: s3.BucketEncryption.S3_MANAGED, encryption: s3.BucketEncryption.S3_MANAGED,
}); });
const ec2Origin = new origins.HttpOrigin('api.tradhox.com', { const ec2Origin = new origins.HttpOrigin('betapartner-api.tradhox.com', {
protocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY, protocolPolicy: cloudfront.OriginProtocolPolicy.HTTP_ONLY,
httpPort: 8080, httpPort: 8080,
}); });

6
cdk/cdk.context.json Normal file
View file

@ -0,0 +1,6 @@
{
"hosted-zone:account=764709663363:domainName=tradhox.com:region=ap-south-2": {
"Id": "/hostedzone/Z05079927Q6UCDIWAH4E",
"Name": "tradhox.com."
}
}

12
cdk/cdk.json Normal file
View file

@ -0,0 +1,12 @@
{
"app": "npx tsx app.ts",
"watch": {
"include": [
"**"
],
"exclude": [
"README.md",
"cdk.out"
]
}
}

View file

@ -0,0 +1,51 @@
{
"version": "54.0.0",
"files": {
"faa95a81ae7d7373f3e1f242268f904eb748d8d0fdd306e8a6fe515a1905a7d6": {
"displayName": "BetaSupplierSiteStack/Custom::S3AutoDeleteObjectsCustomResourceProvider Code",
"source": {
"path": "asset.faa95a81ae7d7373f3e1f242268f904eb748d8d0fdd306e8a6fe515a1905a7d6",
"packaging": "zip"
},
"destinations": {
"764709663363-ap-south-2-41ddd48c": {
"bucketName": "cdk-hnb659fds-assets-764709663363-ap-south-2",
"objectKey": "faa95a81ae7d7373f3e1f242268f904eb748d8d0fdd306e8a6fe515a1905a7d6.zip",
"region": "ap-south-2",
"assumeRoleArn": "arn:${AWS::Partition}:iam::764709663363:role/cdk-hnb659fds-file-publishing-role-764709663363-ap-south-2"
}
}
},
"ffb21b7e396cc684c46812aea1d2ff7ea22401e05038c047ae9f071082c68586": {
"displayName": "SiteCertificate/CertificateRequestorFunction/Code",
"source": {
"path": "asset.ffb21b7e396cc684c46812aea1d2ff7ea22401e05038c047ae9f071082c68586",
"packaging": "zip"
},
"destinations": {
"764709663363-ap-south-2-89e47e6b": {
"bucketName": "cdk-hnb659fds-assets-764709663363-ap-south-2",
"objectKey": "ffb21b7e396cc684c46812aea1d2ff7ea22401e05038c047ae9f071082c68586.zip",
"region": "ap-south-2",
"assumeRoleArn": "arn:${AWS::Partition}:iam::764709663363:role/cdk-hnb659fds-file-publishing-role-764709663363-ap-south-2"
}
}
},
"7ed3cfff7120b7b31997b41625d0f260352266b5eaa8c56533cf1f509441f2e7": {
"displayName": "BetaSupplierSiteStack Template",
"source": {
"path": "BetaSupplierSiteStack.template.json",
"packaging": "file"
},
"destinations": {
"764709663363-ap-south-2-2ab9b314": {
"bucketName": "cdk-hnb659fds-assets-764709663363-ap-south-2",
"objectKey": "7ed3cfff7120b7b31997b41625d0f260352266b5eaa8c56533cf1f509441f2e7.json",
"region": "ap-south-2",
"assumeRoleArn": "arn:${AWS::Partition}:iam::764709663363:role/cdk-hnb659fds-file-publishing-role-764709663363-ap-south-2"
}
}
}
},
"dockerImages": {}
}

View file

@ -0,0 +1,288 @@
{
"/BetaSupplierSiteStack": [
{
"type": "aws:cdk:creationStack",
"data": [
"<anonymous> (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\app.ts:6:1)",
"...node internals..."
]
}
],
"/BetaSupplierSiteStack/Custom::S3AutoDeleteObjectsCustomResourceProvider": [
{
"type": "aws:cdk:is-custom-resource-handler-customResourceProvider",
"data": true
}
],
"/BetaSupplierSiteStack/BetaSupplierSiteBucketName": [
{
"type": "aws:cdk:logicalId",
"data": "BetaSupplierSiteBucketName"
},
{
"type": "aws:cdk:creationStack",
"data": [
"new BetaSupplierSiteStack (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\betasupplier-site-stack.ts:80:5)",
"<anonymous> (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\app.ts:6:1)",
"...node internals..."
]
}
],
"/BetaSupplierSiteStack/BetaSupplierSiteCloudFrontDomainName": [
{
"type": "aws:cdk:logicalId",
"data": "BetaSupplierSiteCloudFrontDomainName"
},
{
"type": "aws:cdk:creationStack",
"data": [
"new BetaSupplierSiteStack (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\betasupplier-site-stack.ts:85:5)",
"<anonymous> (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\app.ts:6:1)",
"...node internals..."
]
}
],
"/BetaSupplierSiteStack/AWSCloudFrontPartitionHostedZoneIdMap": [
{
"type": "aws:cdk:logicalId",
"data": "AWSCloudFrontPartitionHostedZoneIdMap"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...new ARecord2 in aws-cdk-lib...",
"new BetaSupplierSiteStack (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\betasupplier-site-stack.ts:91:5)",
"<anonymous> (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\app.ts:6:1)",
"...node internals..."
]
}
],
"/BetaSupplierSiteStack/BootstrapVersion": [
{
"type": "aws:cdk:logicalId",
"data": "BootstrapVersion"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...aws-cdk-lib...",
"(no user code in 10 frames, use --stack-trace-limit to capture more)"
]
}
],
"/BetaSupplierSiteStack/CheckBootstrapVersion": [
{
"type": "aws:cdk:logicalId",
"data": "CheckBootstrapVersion"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...aws-cdk-lib...",
"(no user code in 10 frames, use --stack-trace-limit to capture more)"
]
}
],
"/BetaSupplierSiteStack/BetaSupplierSiteBucket/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "BetaSupplierSiteBucketC3053F9A"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...new Bucket2 in aws-cdk-lib...",
"new BetaSupplierSiteStack (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\betasupplier-site-stack.ts:15:27)",
"<anonymous> (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\app.ts:6:1)",
"...node internals..."
]
},
{
"type": "aws:cdk:analytics:mixin",
"data": {
"mixin": "aws-cdk-lib.aws_s3.mixins.BucketAutoDeleteObjects"
}
}
],
"/BetaSupplierSiteStack/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role": [
{
"type": "aws:cdk:logicalId",
"data": "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...aws-cdk-lib...",
"(no user code in 10 frames, use --stack-trace-limit to capture more)"
]
}
],
"/BetaSupplierSiteStack/Custom::S3AutoDeleteObjectsCustomResourceProvider/Handler": [
{
"type": "aws:cdk:logicalId",
"data": "CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...aws-cdk-lib...",
"(no user code in 10 frames, use --stack-trace-limit to capture more)"
]
}
],
"/BetaSupplierSiteStack/SiteCertificate/CertificateRequestorFunction": [
{
"type": "aws:cdk:is-custom-resource-handler-runtime-family",
"data": 0
}
],
"/BetaSupplierSiteStack/BetaSupplierSiteDistribution/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "BetaSupplierSiteDistributionD03E58A9"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...new Distribution2 in aws-cdk-lib...",
"new BetaSupplierSiteStack (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\betasupplier-site-stack.ts:43:26)",
"<anonymous> (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\app.ts:6:1)",
"...node internals..."
]
}
],
"/BetaSupplierSiteStack/SiteAliasRecord/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "SiteAliasRecord3C0AF5BF"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...new ARecord2 in aws-cdk-lib...",
"new BetaSupplierSiteStack (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\betasupplier-site-stack.ts:91:5)",
"<anonymous> (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\app.ts:6:1)",
"...node internals..."
]
}
],
"/BetaSupplierSiteStack/CDKMetadata/Default": [
{
"type": "aws:cdk:logicalId",
"data": "CDKMetadata"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...aws-cdk-lib, node internals...",
"(no user code in 10 frames, use --stack-trace-limit to capture more)"
]
}
],
"/BetaSupplierSiteStack/BetaSupplierSiteBucket/Policy/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "BetaSupplierSiteBucketPolicy7E1C9F1E"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...new Bucket2 in aws-cdk-lib...",
"new BetaSupplierSiteStack (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\betasupplier-site-stack.ts:15:27)",
"<anonymous> (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\app.ts:6:1)",
"...node internals..."
]
},
{
"type": "aws:cdk:analytics:mixin",
"data": {
"mixin": "aws-cdk-lib.aws_s3.mixins.BucketPolicyStatements"
}
}
],
"/BetaSupplierSiteStack/BetaSupplierSiteBucket/AutoDeleteObjectsCustomResource/Default": [
{
"type": "aws:cdk:logicalId",
"data": "BetaSupplierSiteBucketAutoDeleteObjectsCustomResource789310C3"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...aws-cdk-lib...",
"(no user code in 10 frames, use --stack-trace-limit to capture more)"
]
}
],
"/BetaSupplierSiteStack/SiteCertificate/CertificateRequestorFunction/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "SiteCertificateCertificateRequestorFunction7CFA7DEA"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...new DnsValidatedCertificate2 in aws-cdk-lib...",
"new BetaSupplierSiteStack (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\betasupplier-site-stack.ts:34:25)",
"<anonymous> (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\app.ts:6:1)",
"...node internals..."
]
}
],
"/BetaSupplierSiteStack/SiteCertificate/CertificateRequestorResource/Default": [
{
"type": "aws:cdk:logicalId",
"data": "SiteCertificateCertificateRequestorResource6021082A"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...new DnsValidatedCertificate2 in aws-cdk-lib...",
"new BetaSupplierSiteStack (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\betasupplier-site-stack.ts:34:25)",
"<anonymous> (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\app.ts:6:1)",
"...node internals..."
]
}
],
"/BetaSupplierSiteStack/SiteCertificate/CertificateRequestorFunction/ServiceRole/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "SiteCertificateCertificateRequestorFunctionServiceRole645E891D"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...new DnsValidatedCertificate2 in aws-cdk-lib...",
"new BetaSupplierSiteStack (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\betasupplier-site-stack.ts:34:25)",
"<anonymous> (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\app.ts:6:1)"
]
}
],
"/BetaSupplierSiteStack/BetaSupplierSiteDistribution/Origin1/S3OriginAccessControl/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "BetaSupplierSiteDistributionOrigin1S3OriginAccessControlF9377C01"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...new Distribution2 in aws-cdk-lib...",
"new BetaSupplierSiteStack (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\betasupplier-site-stack.ts:43:26)",
"<anonymous> (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\app.ts:6:1)",
"...node internals..."
]
}
],
"/BetaSupplierSiteStack/SiteCertificate/CertificateRequestorFunction/ServiceRole/DefaultPolicy/Resource": [
{
"type": "aws:cdk:logicalId",
"data": "SiteCertificateCertificateRequestorFunctionServiceRoleDefaultPolicy96ED5C9C"
},
{
"type": "aws:cdk:creationStack",
"data": [
"...new DnsValidatedCertificate2 in aws-cdk-lib...",
"new BetaSupplierSiteStack (D:\\Tradhox_workspace\\supplier_central_frontend\\cdk\\betasupplier-site-stack.ts:34:25)"
]
}
]
}

View file

@ -0,0 +1,584 @@
{
"Resources": {
"BetaSupplierSiteBucketC3053F9A": {
"Type": "AWS::S3::Bucket",
"Properties": {
"BucketEncryption": {
"ServerSideEncryptionConfiguration": [
{
"ServerSideEncryptionByDefault": {
"SSEAlgorithm": "AES256"
}
}
]
},
"BucketName": "betasuppliersite-764709663363-ap-south-2",
"PublicAccessBlockConfiguration": {
"BlockPublicAcls": true,
"BlockPublicPolicy": true,
"IgnorePublicAcls": true,
"RestrictPublicBuckets": true
},
"Tags": [
{
"Key": "aws-cdk:auto-delete-objects",
"Value": "true"
}
]
},
"UpdateReplacePolicy": "Delete",
"DeletionPolicy": "Delete",
"Metadata": {
"aws:cdk:path": "BetaSupplierSiteStack/BetaSupplierSiteBucket/Resource"
}
},
"BetaSupplierSiteBucketPolicy7E1C9F1E": {
"Type": "AWS::S3::BucketPolicy",
"Properties": {
"Bucket": {
"Ref": "BetaSupplierSiteBucketC3053F9A"
},
"PolicyDocument": {
"Statement": [
{
"Action": [
"s3:PutBucketPolicy",
"s3:GetBucket*",
"s3:List*",
"s3:DeleteObject*"
],
"Effect": "Allow",
"Principal": {
"AWS": {
"Fn::GetAtt": [
"CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092",
"Arn"
]
}
},
"Resource": [
{
"Fn::GetAtt": [
"BetaSupplierSiteBucketC3053F9A",
"Arn"
]
},
{
"Fn::Join": [
"",
[
{
"Fn::GetAtt": [
"BetaSupplierSiteBucketC3053F9A",
"Arn"
]
},
"/*"
]
]
}
]
},
{
"Action": "s3:GetObject",
"Condition": {
"StringEquals": {
"AWS:SourceArn": {
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":cloudfront::",
{
"Ref": "AWS::AccountId"
},
":distribution/",
{
"Ref": "BetaSupplierSiteDistributionD03E58A9"
}
]
]
}
}
},
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Resource": {
"Fn::Join": [
"",
[
{
"Fn::GetAtt": [
"BetaSupplierSiteBucketC3053F9A",
"Arn"
]
},
"/*"
]
]
}
}
],
"Version": "2012-10-17"
}
},
"Metadata": {
"aws:cdk:path": "BetaSupplierSiteStack/BetaSupplierSiteBucket/Policy/Resource"
}
},
"BetaSupplierSiteBucketAutoDeleteObjectsCustomResource789310C3": {
"Type": "Custom::S3AutoDeleteObjects",
"Properties": {
"ServiceToken": {
"Fn::GetAtt": [
"CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F",
"Arn"
]
},
"BucketName": {
"Ref": "BetaSupplierSiteBucketC3053F9A"
}
},
"DependsOn": [
"BetaSupplierSiteBucketPolicy7E1C9F1E"
],
"UpdateReplacePolicy": "Delete",
"DeletionPolicy": "Delete",
"Metadata": {
"aws:cdk:path": "BetaSupplierSiteStack/BetaSupplierSiteBucket/AutoDeleteObjectsCustomResource/Default"
}
},
"CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Action": "sts:AssumeRole",
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
}
}
]
},
"ManagedPolicyArns": [
{
"Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
]
},
"Metadata": {
"aws:cdk:path": "BetaSupplierSiteStack/Custom::S3AutoDeleteObjectsCustomResourceProvider/Role"
}
},
"CustomS3AutoDeleteObjectsCustomResourceProviderHandler9D90184F": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-764709663363-ap-south-2",
"S3Key": "faa95a81ae7d7373f3e1f242268f904eb748d8d0fdd306e8a6fe515a1905a7d6.zip"
},
"Timeout": 900,
"MemorySize": 128,
"Handler": "index.handler",
"Role": {
"Fn::GetAtt": [
"CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092",
"Arn"
]
},
"Runtime": "nodejs24.x",
"Description": {
"Fn::Join": [
"",
[
"Lambda function for auto-deleting objects in ",
{
"Ref": "BetaSupplierSiteBucketC3053F9A"
},
" S3 bucket."
]
]
}
},
"DependsOn": [
"CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092"
],
"Metadata": {
"aws:cdk:path": "BetaSupplierSiteStack/Custom::S3AutoDeleteObjectsCustomResourceProvider/Handler",
"aws:asset:path": "asset.faa95a81ae7d7373f3e1f242268f904eb748d8d0fdd306e8a6fe515a1905a7d6",
"aws:asset:property": "Code"
}
},
"SiteCertificateCertificateRequestorFunctionServiceRole645E891D": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Statement": [
{
"Action": "sts:AssumeRole",
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
}
}
],
"Version": "2012-10-17"
},
"ManagedPolicyArns": [
{
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
]
]
}
]
},
"Metadata": {
"aws:cdk:path": "BetaSupplierSiteStack/SiteCertificate/CertificateRequestorFunction/ServiceRole/Resource"
}
},
"SiteCertificateCertificateRequestorFunctionServiceRoleDefaultPolicy96ED5C9C": {
"Type": "AWS::IAM::Policy",
"Properties": {
"PolicyDocument": {
"Statement": [
{
"Action": [
"acm:RequestCertificate",
"acm:DescribeCertificate",
"acm:DeleteCertificate",
"acm:AddTagsToCertificate"
],
"Effect": "Allow",
"Resource": "*"
},
{
"Action": "route53:GetChange",
"Effect": "Allow",
"Resource": "*"
},
{
"Action": "route53:changeResourceRecordSets",
"Condition": {
"ForAllValues:StringEquals": {
"route53:ChangeResourceRecordSetsRecordTypes": [
"CNAME"
],
"route53:ChangeResourceRecordSetsActions": [
"UPSERT"
]
},
"ForAllValues:StringLike": {
"route53:ChangeResourceRecordSetsNormalizedRecordNames": [
"*.betapartners.tradhox.com"
]
}
},
"Effect": "Allow",
"Resource": {
"Fn::Join": [
"",
[
"arn:",
{
"Ref": "AWS::Partition"
},
":route53:::hostedzone/Z05079927Q6UCDIWAH4E"
]
]
}
}
],
"Version": "2012-10-17"
},
"PolicyName": "SiteCertificateCertificateRequestorFunctionServiceRoleDefaultPolicy96ED5C9C",
"Roles": [
{
"Ref": "SiteCertificateCertificateRequestorFunctionServiceRole645E891D"
}
]
},
"Metadata": {
"aws:cdk:path": "BetaSupplierSiteStack/SiteCertificate/CertificateRequestorFunction/ServiceRole/DefaultPolicy/Resource"
}
},
"SiteCertificateCertificateRequestorFunction7CFA7DEA": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "cdk-hnb659fds-assets-764709663363-ap-south-2",
"S3Key": "ffb21b7e396cc684c46812aea1d2ff7ea22401e05038c047ae9f071082c68586.zip"
},
"Handler": "index.certificateRequestHandler",
"Role": {
"Fn::GetAtt": [
"SiteCertificateCertificateRequestorFunctionServiceRole645E891D",
"Arn"
]
},
"Runtime": "nodejs24.x",
"Timeout": 900
},
"DependsOn": [
"SiteCertificateCertificateRequestorFunctionServiceRoleDefaultPolicy96ED5C9C",
"SiteCertificateCertificateRequestorFunctionServiceRole645E891D"
],
"Metadata": {
"aws:cdk:path": "BetaSupplierSiteStack/SiteCertificate/CertificateRequestorFunction/Resource",
"aws:asset:path": "asset.ffb21b7e396cc684c46812aea1d2ff7ea22401e05038c047ae9f071082c68586",
"aws:asset:is-bundled": false,
"aws:asset:property": "Code"
}
},
"SiteCertificateCertificateRequestorResource6021082A": {
"Type": "AWS::CloudFormation::CustomResource",
"Properties": {
"ServiceToken": {
"Fn::GetAtt": [
"SiteCertificateCertificateRequestorFunction7CFA7DEA",
"Arn"
]
},
"DomainName": "betapartners.tradhox.com",
"HostedZoneId": "Z05079927Q6UCDIWAH4E",
"Region": "us-east-1"
},
"UpdateReplacePolicy": "Delete",
"DeletionPolicy": "Delete",
"Metadata": {
"aws:cdk:path": "BetaSupplierSiteStack/SiteCertificate/CertificateRequestorResource/Default"
}
},
"BetaSupplierSiteDistributionOrigin1S3OriginAccessControlF9377C01": {
"Type": "AWS::CloudFront::OriginAccessControl",
"Properties": {
"OriginAccessControlConfig": {
"Name": "BetaSupplierSiteStackBetaSupOrigin1S3OriginAccessControl50127AEA",
"OriginAccessControlOriginType": "s3",
"SigningBehavior": "always",
"SigningProtocol": "sigv4"
}
},
"Metadata": {
"aws:cdk:path": "BetaSupplierSiteStack/BetaSupplierSiteDistribution/Origin1/S3OriginAccessControl/Resource"
}
},
"BetaSupplierSiteDistributionD03E58A9": {
"Type": "AWS::CloudFront::Distribution",
"Properties": {
"DistributionConfig": {
"Aliases": [
"betapartners.tradhox.com"
],
"CacheBehaviors": [
{
"AllowedMethods": [
"GET",
"HEAD",
"OPTIONS",
"PUT",
"PATCH",
"POST",
"DELETE"
],
"CachePolicyId": "4135ea2d-6df8-44a3-9df3-4b5a84be39ad",
"Compress": true,
"OriginRequestPolicyId": "216adef6-5c7f-47e4-b989-5492eafa07d3",
"PathPattern": "/api/*",
"TargetOriginId": "BetaSupplierSiteStackBetaSupplierSiteDistributionOrigin26FE0C2F5",
"ViewerProtocolPolicy": "redirect-to-https"
}
],
"CustomErrorResponses": [
{
"ErrorCachingMinTTL": 0,
"ErrorCode": 404,
"ResponseCode": 200,
"ResponsePagePath": "/index.html"
},
{
"ErrorCachingMinTTL": 0,
"ErrorCode": 403,
"ResponseCode": 200,
"ResponsePagePath": "/index.html"
}
],
"DefaultCacheBehavior": {
"AllowedMethods": [
"GET",
"HEAD"
],
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
"CachedMethods": [
"GET",
"HEAD"
],
"Compress": true,
"TargetOriginId": "BetaSupplierSiteStackBetaSupplierSiteDistributionOrigin118CF032F",
"ViewerProtocolPolicy": "redirect-to-https"
},
"DefaultRootObject": "index.html",
"Enabled": true,
"HttpVersion": "http2",
"IPV6Enabled": true,
"Origins": [
{
"DomainName": {
"Fn::GetAtt": [
"BetaSupplierSiteBucketC3053F9A",
"RegionalDomainName"
]
},
"Id": "BetaSupplierSiteStackBetaSupplierSiteDistributionOrigin118CF032F",
"OriginAccessControlId": {
"Fn::GetAtt": [
"BetaSupplierSiteDistributionOrigin1S3OriginAccessControlF9377C01",
"Id"
]
},
"S3OriginConfig": {
"OriginAccessIdentity": ""
}
},
{
"CustomOriginConfig": {
"HTTPPort": 8080,
"OriginProtocolPolicy": "http-only",
"OriginSSLProtocols": [
"TLSv1.2"
]
},
"DomainName": "betapartner-api.tradhox.com",
"Id": "BetaSupplierSiteStackBetaSupplierSiteDistributionOrigin26FE0C2F5"
}
],
"ViewerCertificate": {
"AcmCertificateArn": {
"Fn::GetAtt": [
"SiteCertificateCertificateRequestorResource6021082A",
"Arn"
]
},
"MinimumProtocolVersion": "TLSv1.2_2021",
"SslSupportMethod": "sni-only"
}
}
},
"Metadata": {
"aws:cdk:path": "BetaSupplierSiteStack/BetaSupplierSiteDistribution/Resource"
}
},
"SiteAliasRecord3C0AF5BF": {
"Type": "AWS::Route53::RecordSet",
"Properties": {
"AliasTarget": {
"DNSName": {
"Fn::GetAtt": [
"BetaSupplierSiteDistributionD03E58A9",
"DomainName"
]
},
"HostedZoneId": {
"Fn::FindInMap": [
"AWSCloudFrontPartitionHostedZoneIdMap",
{
"Ref": "AWS::Partition"
},
"zoneId"
]
}
},
"HostedZoneId": "Z05079927Q6UCDIWAH4E",
"Name": "betapartners.tradhox.com.",
"Type": "A"
},
"Metadata": {
"aws:cdk:path": "BetaSupplierSiteStack/SiteAliasRecord/Resource"
}
},
"CDKMetadata": {
"Type": "AWS::CDK::Metadata",
"Properties": {
"Analytics": "v2:deflate64:H4sIAAAAAAAA/5VRQU7DMBB8S3N2DSQgJG5pKm6oVSJxQahynG20rWNX3nULsvJ3lETQcODAacY7O6PROpXpYypvF+pCS90clwZrGStW+ijUhXaRMhlXQR+BRbG3E3uLSYcfaJOnZG4b9imTo0RyWs0DuzUYYNjUB9BMSf8uJmnrDOrPa+r0/kf2ZKhYMXRg59ErRdALDZ5xj3rQlVUteLm29KoMNoqhKa6yMKqrGyXjc7Ca0dmh1TfvBapOxtIZGMYjXrtPrBeU7RQRMMl8AKGNC83eO8syrpHYYx3G5CrbeGzR5loDUeEse2eGpD/Gc3MvvAsMD5mMeQna+WZsNLIKuO9FEYhdVwK54PXU94f/krbenbEBP5xKjJUrVi3aVszNm8CnMP78izqd0La9sK4BeaCbc3ov0zt5uzgQ4tIHy9iBLCf8AjedqQBWAgAA"
},
"Metadata": {
"aws:cdk:path": "BetaSupplierSiteStack/CDKMetadata/Default"
}
}
},
"Outputs": {
"BetaSupplierSiteBucketName": {
"Description": "The name of the S3 bucket for the betasupplier site",
"Value": {
"Ref": "BetaSupplierSiteBucketC3053F9A"
}
},
"BetaSupplierSiteCloudFrontDomainName": {
"Description": "The domain name of the CloudFront distribution for betasupplier site",
"Value": {
"Fn::GetAtt": [
"BetaSupplierSiteDistributionD03E58A9",
"DomainName"
]
}
}
},
"Mappings": {
"AWSCloudFrontPartitionHostedZoneIdMap": {
"aws": {
"zoneId": "Z2FDTNDATAQYW2"
},
"aws-cn": {
"zoneId": "Z3RFFRIM2A3IF5"
}
}
},
"Parameters": {
"BootstrapVersion": {
"Type": "AWS::SSM::Parameter::Value<String>",
"Default": "/cdk-bootstrap/hnb659fds/version",
"Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]"
}
},
"Rules": {
"CheckBootstrapVersion": {
"Assertions": [
{
"Assert": {
"Fn::Not": [
{
"Fn::Contains": [
[
"1",
"2",
"3",
"4",
"5"
],
{
"Ref": "BootstrapVersion"
}
]
}
]
},
"AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI."
}
]
}
}
}

View file

@ -0,0 +1 @@
"use strict";var f=Object.create,i=Object.defineProperty,I=Object.getOwnPropertyDescriptor,C=Object.getOwnPropertyNames,w=Object.getPrototypeOf,P=Object.prototype.hasOwnProperty,A=(t,e)=>{for(var o in e)i(t,o,{get:e[o],enumerable:!0})},d=(t,e,o,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of C(e))!P.call(t,s)&&s!==o&&i(t,s,{get:()=>e[s],enumerable:!(r=I(e,s))||r.enumerable});return t},l=(t,e,o)=>(o=t!=null?f(w(t)):{},d(e||!t||!t.__esModule?i(o,"default",{value:t,enumerable:!0}):o,t)),B=t=>d(i({},"__esModule",{value:!0}),t),q={};A(q,{autoDeleteHandler:()=>S,handler:()=>H}),module.exports=B(q);var h=require("@aws-sdk/client-s3"),y=l(require("https")),m=l(require("url")),a={sendHttpRequest:D,log:T,includeStackTraces:!0,userHandlerIndex:"./index"},p="AWSCDK::CustomResourceProviderFramework::CREATE_FAILED",L="AWSCDK::CustomResourceProviderFramework::MISSING_PHYSICAL_ID";function R(t){return async(e,o)=>{let r={...e,ResponseURL:"..."};if(a.log(JSON.stringify(r,void 0,2)),e.RequestType==="Delete"&&e.PhysicalResourceId===p){a.log("ignoring DELETE event caused by a failed CREATE event"),await u("SUCCESS",e);return}try{let s=await t(r,o),n=k(e,s);await u("SUCCESS",n)}catch(s){let n={...e,Reason:a.includeStackTraces?s.stack:s.message};n.PhysicalResourceId||(e.RequestType==="Create"?(a.log("CREATE failed, responding with a marker physical resource id so that the subsequent DELETE will be ignored"),n.PhysicalResourceId=p):a.log(`ERROR: Malformed event. "PhysicalResourceId" is required: ${JSON.stringify(e)}`)),await u("FAILED",n)}}}function k(t,e={}){let o=e.PhysicalResourceId??t.PhysicalResourceId??t.RequestId;if(t.RequestType==="Delete"&&o!==t.PhysicalResourceId)throw new Error(`DELETE: cannot change the physical resource ID from "${t.PhysicalResourceId}" to "${e.PhysicalResourceId}" during deletion`);return{...t,...e,PhysicalResourceId:o}}async function u(t,e){let o={Status:t,Reason:e.Reason??t,StackId:e.StackId,RequestId:e.RequestId,PhysicalResourceId:e.PhysicalResourceId||L,LogicalResourceId:e.LogicalResourceId,NoEcho:e.NoEcho,Data:e.Data},r=m.parse(e.ResponseURL),s=`${r.protocol}//${r.hostname}/${r.pathname}?***`;a.log("submit response to cloudformation",s,o);let n=JSON.stringify(o),E={hostname:r.hostname,path:r.path,method:"PUT",headers:{"content-type":"","content-length":Buffer.byteLength(n,"utf8")}};await O({attempts:5,sleep:1e3},a.sendHttpRequest)(E,n)}async function D(t,e){return new Promise((o,r)=>{try{let s=y.request(t,n=>{n.resume(),!n.statusCode||n.statusCode>=400?r(new Error(`Unsuccessful HTTP response: ${n.statusCode}`)):o()});s.on("error",r),s.write(e),s.end()}catch(s){r(s)}})}function T(t,...e){console.log(t,...e)}function O(t,e){return async(...o)=>{let r=t.attempts,s=t.sleep;for(;;)try{return await e(...o)}catch(n){if(r--<=0)throw n;await b(Math.floor(Math.random()*s)),s*=2}}}async function b(t){return new Promise(e=>setTimeout(e,t))}var g="aws-cdk:auto-delete-objects",x=JSON.stringify({Version:"2012-10-17",Statement:[]}),c=new h.S3({}),H=R(S);async function S(t){switch(t.RequestType){case"Create":return;case"Update":return{PhysicalResourceId:(await F(t)).PhysicalResourceId};case"Delete":return N(t.ResourceProperties?.BucketName)}}async function F(t){let e=t,o=e.OldResourceProperties?.BucketName;return{PhysicalResourceId:e.ResourceProperties?.BucketName??o}}async function _(t){try{let e=(await c.getBucketPolicy({Bucket:t}))?.Policy??x,o=JSON.parse(e);o.Statement.push({Principal:"*",Effect:"Deny",Action:["s3:PutObject"],Resource:[`arn:aws:s3:::${t}/*`]}),await c.putBucketPolicy({Bucket:t,Policy:JSON.stringify(o)})}catch(e){if(e.name==="NoSuchBucket")throw e;console.log(`Could not set new object deny policy on bucket '${t}' prior to deletion.`)}}async function U(t){let e;do{e=await c.listObjectVersions({Bucket:t});let o=[...e.Versions??[],...e.DeleteMarkers??[]];if(o.length===0)return;let r=o.map(s=>({Key:s.Key,VersionId:s.VersionId}));await c.deleteObjects({Bucket:t,Delete:{Objects:r}})}while(e?.IsTruncated)}async function N(t){if(!t)throw new Error("No BucketName was provided.");try{if(!await W(t)){console.log(`Bucket does not have '${g}' tag, skipping cleaning.`);return}await _(t),await U(t)}catch(e){if(e.name==="NoSuchBucket"){console.log(`Bucket '${t}' does not exist.`);return}throw e}}async function W(t){return(await c.getBucketTagging({Bucket:t})).TagSet?.some(o=>o.Key===g&&o.Value==="true")}

File diff suppressed because one or more lines are too long

1
cdk/cdk.out/cdk.out Normal file
View file

@ -0,0 +1 @@
{"version":"54.0.0"}

508
cdk/cdk.out/manifest.json Normal file
View file

@ -0,0 +1,508 @@
{
"version": "54.0.0",
"artifacts": {
"BetaSupplierSiteStack.assets": {
"type": "cdk:asset-manifest",
"properties": {
"file": "BetaSupplierSiteStack.assets.json",
"requiresBootstrapStackVersion": 6,
"bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version"
}
},
"BetaSupplierSiteStack": {
"type": "aws:cloudformation:stack",
"environment": "aws://764709663363/ap-south-2",
"properties": {
"templateFile": "BetaSupplierSiteStack.template.json",
"terminationProtection": false,
"validateOnSynth": false,
"assumeRoleArn": "arn:${AWS::Partition}:iam::764709663363:role/cdk-hnb659fds-deploy-role-764709663363-ap-south-2",
"cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::764709663363:role/cdk-hnb659fds-cfn-exec-role-764709663363-ap-south-2",
"stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-764709663363-ap-south-2/7ed3cfff7120b7b31997b41625d0f260352266b5eaa8c56533cf1f509441f2e7.json",
"requiresBootstrapStackVersion": 6,
"bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version",
"additionalDependencies": [
"BetaSupplierSiteStack.assets"
],
"lookupRole": {
"arn": "arn:${AWS::Partition}:iam::764709663363:role/cdk-hnb659fds-lookup-role-764709663363-ap-south-2",
"requiresBootstrapStackVersion": 8,
"bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version"
}
},
"dependencies": [
"BetaSupplierSiteStack.assets"
],
"additionalMetadataFile": "BetaSupplierSiteStack.metadata.json",
"displayName": "BetaSupplierSiteStack"
},
"Tree": {
"type": "cdk:tree",
"properties": {
"file": "tree.json"
}
},
"aws-cdk-lib/feature-flag-report": {
"type": "cdk:feature-flag-report",
"properties": {
"module": "aws-cdk-lib",
"flags": {
"@aws-cdk/aws-signer:signingProfileNamePassedToCfn": {
"recommendedValue": true,
"explanation": "Pass signingProfileName to CfnSigningProfile"
},
"@aws-cdk/core:newStyleStackSynthesis": {
"recommendedValue": true,
"explanation": "Switch to new stack synthesis method which enables CI/CD",
"unconfiguredBehavesLike": {
"v2": true
}
},
"@aws-cdk/core:stackRelativeExports": {
"recommendedValue": true,
"explanation": "Name exports based on the construct paths relative to the stack, rather than the global construct path",
"unconfiguredBehavesLike": {
"v2": true
}
},
"@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": {
"recommendedValue": true,
"explanation": "Disable implicit openListener when custom security groups are provided"
},
"@aws-cdk/aws-rds:lowercaseDbIdentifier": {
"recommendedValue": true,
"explanation": "Force lowercasing of RDS Cluster names in CDK",
"unconfiguredBehavesLike": {
"v2": true
}
},
"@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": {
"recommendedValue": true,
"explanation": "Allow adding/removing multiple UsagePlanKeys independently",
"unconfiguredBehavesLike": {
"v2": true
}
},
"@aws-cdk/aws-lambda:recognizeVersionProps": {
"recommendedValue": true,
"explanation": "Enable this feature flag to opt in to the updated logical id calculation for Lambda Version created using the `fn.currentVersion`.",
"unconfiguredBehavesLike": {
"v2": true
}
},
"@aws-cdk/aws-lambda:recognizeLayerVersion": {
"recommendedValue": true,
"explanation": "Enable this feature flag to opt in to the updated logical id calculation for Lambda Version created using the `fn.currentVersion`."
},
"@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": {
"recommendedValue": true,
"explanation": "Enable this feature flag to have cloudfront distributions use the security policy TLSv1.2_2021 by default.",
"unconfiguredBehavesLike": {
"v2": true
}
},
"@aws-cdk/core:checkSecretUsage": {
"recommendedValue": true,
"explanation": "Enable this flag to make it impossible to accidentally use SecretValues in unsafe locations"
},
"@aws-cdk/core:target-partitions": {
"recommendedValue": [
"aws",
"aws-cn"
],
"explanation": "What regions to include in lookup tables of environment agnostic stacks"
},
"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": {
"recommendedValue": true,
"explanation": "ECS extensions will automatically add an `awslogs` driver if no logging is specified"
},
"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": {
"recommendedValue": true,
"explanation": "Enable this feature flag to have Launch Templates generated by the `InstanceRequireImdsv2Aspect` use unique names."
},
"@aws-cdk/aws-ecs:arnFormatIncludesClusterName": {
"recommendedValue": true,
"explanation": "ARN format used by ECS. In the new ARN format, the cluster name is part of the resource ID."
},
"@aws-cdk/aws-iam:minimizePolicies": {
"recommendedValue": true,
"explanation": "Minimize IAM policies by combining Statements"
},
"@aws-cdk/core:validateSnapshotRemovalPolicy": {
"recommendedValue": true,
"explanation": "Error on snapshot removal policies on resources that do not support it."
},
"@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": {
"recommendedValue": true,
"explanation": "Generate key aliases that include the stack name"
},
"@aws-cdk/aws-s3:createDefaultLoggingPolicy": {
"recommendedValue": true,
"explanation": "Enable this feature flag to create an S3 bucket policy by default in cases where an AWS service would automatically create the Policy if one does not exist."
},
"@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": {
"recommendedValue": true,
"explanation": "Restrict KMS key policy for encrypted Queues a bit more"
},
"@aws-cdk/aws-apigateway:disableCloudWatchRole": {
"recommendedValue": true,
"explanation": "Make default CloudWatch Role behavior safe for multiple API Gateways in one environment"
},
"@aws-cdk/core:enablePartitionLiterals": {
"recommendedValue": true,
"explanation": "Make ARNs concrete if AWS partition is known"
},
"@aws-cdk/aws-events:eventsTargetQueueSameAccount": {
"recommendedValue": true,
"explanation": "Event Rules may only push to encrypted SQS queues in the same account"
},
"@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": {
"recommendedValue": true,
"explanation": "Avoid setting the \"ECS\" deployment controller when adding a circuit breaker"
},
"@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": {
"recommendedValue": true,
"explanation": "Enable this feature to create default policy names for imported roles that depend on the stack the role is in."
},
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": {
"recommendedValue": true,
"explanation": "Use S3 Bucket Policy instead of ACLs for Server Access Logging"
},
"@aws-cdk/aws-route53-patters:useCertificate": {
"recommendedValue": true,
"explanation": "Use the official `Certificate` resource instead of `DnsValidatedCertificate`"
},
"@aws-cdk/customresources:installLatestAwsSdkDefault": {
"recommendedValue": false,
"explanation": "Whether to install the latest SDK by default in AwsCustomResource"
},
"@aws-cdk/aws-rds:databaseProxyUniqueResourceName": {
"recommendedValue": true,
"explanation": "Use unique resource name for Database Proxy"
},
"@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": {
"recommendedValue": true,
"explanation": "Remove CloudWatch alarms from deployment group"
},
"@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": {
"recommendedValue": true,
"explanation": "Include authorizer configuration in the calculation of the API deployment logical ID."
},
"@aws-cdk/aws-ec2:launchTemplateDefaultUserData": {
"recommendedValue": true,
"explanation": "Define user data for a launch template by default when a machine image is provided."
},
"@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": {
"recommendedValue": true,
"explanation": "SecretTargetAttachments uses the ResourcePolicy of the attached Secret."
},
"@aws-cdk/aws-redshift:columnId": {
"recommendedValue": true,
"explanation": "Whether to use an ID to track Redshift column changes"
},
"@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": {
"recommendedValue": true,
"explanation": "Enable AmazonEMRServicePolicy_v2 managed policies"
},
"@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": {
"recommendedValue": true,
"explanation": "Restrict access to the VPC default security group"
},
"@aws-cdk/aws-apigateway:requestValidatorUniqueId": {
"recommendedValue": true,
"explanation": "Generate a unique id for each RequestValidator added to a method"
},
"@aws-cdk/aws-kms:aliasNameRef": {
"recommendedValue": true,
"explanation": "KMS Alias name and keyArn will have implicit reference to KMS Key"
},
"@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal": {
"recommendedValue": true,
"explanation": "Enable grant methods on Aliases imported by name to use kms:ResourceAliases condition"
},
"@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": {
"recommendedValue": true,
"explanation": "Generate a launch template when creating an AutoScalingGroup"
},
"@aws-cdk/core:includePrefixInUniqueNameGeneration": {
"recommendedValue": true,
"explanation": "Include the stack prefix in the stack name generation process"
},
"@aws-cdk/aws-efs:denyAnonymousAccess": {
"recommendedValue": true,
"explanation": "EFS denies anonymous clients accesses"
},
"@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": {
"recommendedValue": true,
"explanation": "Enables support for Multi-AZ with Standby deployment for opensearch domains"
},
"@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": {
"recommendedValue": true,
"explanation": "Enables aws-lambda-nodejs.Function to use the latest available NodeJs runtime as the default"
},
"@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": {
"recommendedValue": true,
"explanation": "When enabled, mount targets will have a stable logicalId that is linked to the associated subnet."
},
"@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": {
"recommendedValue": true,
"explanation": "When enabled, a scope of InstanceParameterGroup for AuroraClusterInstance with each parameters will change."
},
"@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": {
"recommendedValue": true,
"explanation": "When enabled, will always use the arn for identifiers for CfnSourceApiAssociation in the GraphqlApi construct rather than id."
},
"@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": {
"recommendedValue": true,
"explanation": "When enabled, creating an RDS database cluster from a snapshot will only render credentials for snapshot credentials."
},
"@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": {
"recommendedValue": true,
"explanation": "When enabled, the CodeCommit source action is using the default branch name 'main'."
},
"@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": {
"recommendedValue": true,
"explanation": "When enabled, the logical ID of a Lambda permission for a Lambda action includes an alarm ID."
},
"@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": {
"recommendedValue": true,
"explanation": "Enables Pipeline to set the default value for crossAccountKeys to false."
},
"@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": {
"recommendedValue": true,
"explanation": "Enables Pipeline to set the default pipeline type to V2."
},
"@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": {
"recommendedValue": true,
"explanation": "When enabled, IAM Policy created from KMS key grant will reduce the resource scope to this key only."
},
"@aws-cdk/pipelines:reduceAssetRoleTrustScope": {
"recommendedValue": true,
"explanation": "Remove the root account principal from PipelineAssetsFileRole trust policy",
"unconfiguredBehavesLike": {
"v2": true
}
},
"@aws-cdk/aws-eks:nodegroupNameAttribute": {
"recommendedValue": true,
"explanation": "When enabled, nodegroupName attribute of the provisioned EKS NodeGroup will not have the cluster name prefix."
},
"@aws-cdk/aws-eks:useNativeOidcProvider": {
"recommendedValue": true,
"explanation": "When enabled, EKS V2 clusters will use the native OIDC provider resource AWS::IAM::OIDCProvider instead of creating the OIDCProvider with a custom resource (iam.OpenIDConnectProvider)."
},
"@aws-cdk/aws-ec2:ebsDefaultGp3Volume": {
"recommendedValue": true,
"explanation": "When enabled, the default volume type of the EBS volume will be GP3"
},
"@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": {
"recommendedValue": true,
"explanation": "When enabled, remove default deployment alarm settings"
},
"@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": {
"recommendedValue": false,
"explanation": "When enabled, the custom resource used for `AwsCustomResource` will configure the `logApiResponseData` property as true by default"
},
"@aws-cdk/aws-s3:keepNotificationInImportedBucket": {
"recommendedValue": false,
"explanation": "When enabled, Adding notifications to a bucket in the current stack will not remove notification from imported stack."
},
"@aws-cdk/aws-stepfunctions-tasks:useNewS3UriParametersForBedrockInvokeModelTask": {
"recommendedValue": true,
"explanation": "When enabled, use new props for S3 URI field in task definition of state machine for bedrock invoke model.",
"unconfiguredBehavesLike": {
"v2": true
}
},
"@aws-cdk/core:explicitStackTags": {
"recommendedValue": true,
"explanation": "When enabled, stack tags need to be assigned explicitly on a Stack."
},
"@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": {
"recommendedValue": true,
"explanation": "When enabled, we will only grant the necessary permissions when users specify cloudwatch log group through logConfiguration"
},
"@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": {
"recommendedValue": true,
"explanation": "When enabled will allow you to specify a resource policy per replica, and not copy the source table policy to all replicas"
},
"@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": {
"recommendedValue": true,
"explanation": "When enabled, initOptions.timeout and resourceSignalTimeout values will be summed together."
},
"@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": {
"recommendedValue": true,
"explanation": "When enabled, a Lambda authorizer Permission created when using GraphqlApi will be properly scoped with a SourceArn."
},
"@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": {
"recommendedValue": true,
"explanation": "When enabled, the value of property `instanceResourceId` in construct `DatabaseInstanceReadReplica` will be set to the correct value which is `DbiResourceId` instead of currently `DbInstanceArn`"
},
"@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": {
"recommendedValue": true,
"explanation": "When enabled, CFN templates added with `cfn-include` will error if the template contains Resource Update or Create policies with CFN Intrinsics that include non-primitive values."
},
"@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": {
"recommendedValue": true,
"explanation": "When enabled, both `@aws-sdk` and `@smithy` packages will be excluded from the Lambda Node.js 18.x runtime to prevent version mismatches in bundled applications."
},
"@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": {
"recommendedValue": true,
"explanation": "When enabled, the resource of IAM Run Ecs policy generated by SFN EcsRunTask will reference the definition, instead of constructing ARN."
},
"@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": {
"recommendedValue": true,
"explanation": "When enabled, the BastionHost construct will use the latest Amazon Linux 2023 AMI, instead of Amazon Linux 2."
},
"@aws-cdk/core:aspectStabilization": {
"recommendedValue": true,
"explanation": "When enabled, a stabilization loop will be run when invoking Aspects during synthesis.",
"unconfiguredBehavesLike": {
"v2": true
}
},
"@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": {
"recommendedValue": true,
"explanation": "When enabled, use a new method for DNS Name of user pool domain target without creating a custom resource."
},
"@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": {
"recommendedValue": true,
"explanation": "When enabled, the default security group ingress rules will allow IPv6 ingress from anywhere"
},
"@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": {
"recommendedValue": true,
"explanation": "When enabled, the default behaviour of OIDC provider will reject unauthorized connections"
},
"@aws-cdk/core:enableAdditionalMetadataCollection": {
"recommendedValue": true,
"explanation": "When enabled, CDK will expand the scope of usage data collected to better inform CDK development and improve communication for security concerns and emerging issues."
},
"@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": {
"recommendedValue": false,
"explanation": "[Deprecated] When enabled, Lambda will create new inline policies with AddToRolePolicy instead of adding to the Default Policy Statement"
},
"@aws-cdk/aws-s3:setUniqueReplicationRoleName": {
"recommendedValue": true,
"explanation": "When enabled, CDK will automatically generate a unique role name that is used for s3 object replication."
},
"@aws-cdk/pipelines:reduceStageRoleTrustScope": {
"recommendedValue": true,
"explanation": "Remove the root account principal from Stage addActions trust policy",
"unconfiguredBehavesLike": {
"v2": true
}
},
"@aws-cdk/aws-events:requireEventBusPolicySid": {
"recommendedValue": true,
"explanation": "When enabled, grantPutEventsTo() will use resource policies with Statement IDs for service principals."
},
"@aws-cdk/core:aspectPrioritiesMutating": {
"recommendedValue": true,
"explanation": "When set to true, Aspects added by the construct library on your behalf will be given a priority of MUTATING."
},
"@aws-cdk/aws-dynamodb:retainTableReplica": {
"recommendedValue": true,
"explanation": "When enabled, table replica will be default to the removal policy of source table unless specified otherwise."
},
"@aws-cdk/cognito:logUserPoolClientSecretValue": {
"recommendedValue": false,
"explanation": "When disabled, the value of the user pool client secret will not be logged in the custom resource lambda function logs."
},
"@aws-cdk/pipelines:reduceCrossAccountActionRoleTrustScope": {
"recommendedValue": true,
"explanation": "When enabled, scopes down the trust policy for the cross-account action role",
"unconfiguredBehavesLike": {
"v2": true
}
},
"@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2": {
"recommendedValue": true,
"explanation": "When enabled, the resultWriterV2 property of DistributedMap will be used insted of resultWriter"
},
"@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions": {
"recommendedValue": true,
"explanation": "Add an S3 trust policy to a KMS key resource policy for SNS subscriptions."
},
"@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway": {
"recommendedValue": true,
"explanation": "When enabled, the EgressOnlyGateway resource is only created if private subnets are defined in the dual-stack VPC."
},
"@aws-cdk/aws-ec2-alpha:useResourceIdForVpcV2Migration": {
"recommendedValue": false,
"explanation": "When enabled, use resource IDs for VPC V2 migration"
},
"@aws-cdk/aws-s3:publicAccessBlockedByDefault": {
"recommendedValue": true,
"explanation": "When enabled, setting any combination of options for BlockPublicAccess will automatically set true for any options not defined."
},
"@aws-cdk/aws-lambda:useCdkManagedLogGroup": {
"recommendedValue": true,
"explanation": "When enabled, CDK creates and manages loggroup for the lambda function"
},
"@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault": {
"recommendedValue": true,
"explanation": "When enabled, Network Load Balancer will be created with a security group by default."
},
"@aws-cdk/aws-stepfunctions-tasks:httpInvokeDynamicJsonPathEndpoint": {
"recommendedValue": true,
"explanation": "When enabled, allows using a dynamic apiEndpoint with JSONPath format in HttpInvoke tasks.",
"unconfiguredBehavesLike": {
"v2": true
}
},
"@aws-cdk/aws-ecs-patterns:uniqueTargetGroupId": {
"recommendedValue": true,
"explanation": "When enabled, ECS patterns will generate unique target group IDs to prevent conflicts during load balancer replacement"
},
"@aws-cdk/aws-route53-patterns:useDistribution": {
"recommendedValue": true,
"explanation": "Use the `Distribution` resource instead of `CloudFrontWebDistribution`"
},
"@aws-cdk/aws-cloudfront:defaultFunctionRuntimeV2_0": {
"recommendedValue": true,
"explanation": "Use cloudfront-js-2.0 as the default runtime for CloudFront Functions"
},
"@aws-cdk/aws-elasticloadbalancingv2:usePostQuantumTlsPolicy": {
"recommendedValue": true,
"explanation": "When enabled, HTTPS/TLS listeners use post-quantum TLS policy by default"
},
"@aws-cdk/core:automaticL1Traits": {
"recommendedValue": true,
"explanation": "Automatically use the default L1 traits for L1 constructs`",
"unconfiguredBehavesLike": {
"v2": true
}
},
"@aws-cdk/aws-batch:defaultToAL2023": {
"recommendedValue": true,
"explanation": "Use AL2023 as the default imageType for EC2 Batch compute environments instead of the deprecated AL2"
},
"@aws-cdk/aws-eks:defaultToAL2023": {
"recommendedValue": true,
"explanation": "Use AL2023 as the default AMI type for EKS managed node groups using non-GPU instance types instead of the deprecated AL2"
},
"@aws-cdk/core:annotationsInValidationReport": {
"recommendedValue": true,
"explanation": "Include construct annotations (warnings and errors) in the policy validation report"
},
"@aws-cdk/core:defaultCrossStackReferences": {
"recommendedValue": "weak",
"explanation": "Controls whether cross-stack references are strong, weak, or both",
"unconfiguredBehavesLike": {
"v2": "strong"
}
},
"@aws-cdk/core:validateAgainstDefaultRules": {
"recommendedValue": true,
"explanation": "Treat CloudFormation Validate findings as errors"
},
"@aws-cdk/aws-ecs:removeEmptyLoadBalancers": {
"recommendedValue": true,
"explanation": "Render an empty `LoadBalancers` array on an ECS service that has no target groups"
}
}
}
}
},
"minimumCliVersion": "2.1144.0"
}

1
cdk/cdk.out/tree.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,5 @@
{
"version": "54.0.0",
"title": "Validation Report",
"pluginReports": []
}

655
package-lock.json generated
View file

@ -13,7 +13,8 @@
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^6.28.0" "react-router-dom": "^6.28.0",
"selenium-webdriver": "^4.50.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
@ -22,12 +23,69 @@
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.6.1", "autoprefixer": "^10.6.1",
"aws-cdk-lib": "^2.272.0",
"constructs": "^10.8.1",
"postcss": "^8.5.28", "postcss": "^8.5.28",
"source-map-support": "^0.5.21",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"ts-node": "^10.9.2",
"typescript": "~5.7.2", "typescript": "~5.7.2",
"vite": "^6.0.0" "vite": "^6.0.0"
} }
}, },
"node_modules/@aws-cdk/asset-awscli-v1": {
"version": "2.2.292",
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.292.tgz",
"integrity": "sha512-d4aMFsAFj19FtxVyw8IzlUKv5Zu4sIvgnEjI2IU6IWBJgVbJ4aFnadANqYa+6MwB1CQbGOg0jh8WE77M+Nb/9A==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@aws-cdk/asset-node-proxy-agent-v6": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.3.tgz",
"integrity": "sha512-98We1QWfin9vtuuQtNFhIIAEWb8CreyYTAP0Vn8HqSBoNiFJM9nZgifJeQawT9Wq0NX1HCNFlxCyTccIrK1PSA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@aws-cdk/cloud-assembly-schema": {
"version": "54.26.0",
"resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-54.26.0.tgz",
"integrity": "sha512-zYI3XhQ/bpuSXuPaF77f7u2q6rZBPwdrPh8a0v+e/B3IxbRQAtQv0fmacQ8uYh9RPVuIVnExE6IsFJBSALH7BA==",
"bundleDependencies": [
"jsonschema",
"semver"
],
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"jsonschema": "^1.5.0",
"semver": "^7.8.5"
},
"engines": {
"node": ">= 18.0.0"
}
},
"node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": {
"version": "1.5.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": {
"version": "7.8.5",
"dev": true,
"inBundle": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@ -310,6 +368,36 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bazel/runfiles": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@bazel/runfiles/-/runfiles-6.5.0.tgz",
"integrity": "sha512-RzahvqTkfpY2jsDxo8YItPX+/iZ6hbiikw1YhE0bA9EKBR5Og8Pa6FHn9PO9M0zaXRVsr0GFQLKbB/0rzy9SzA==",
"license": "Apache-2.0"
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@ -1466,6 +1554,34 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8" "vite": "^5.2.0 || ^6 || ^7 || ^8"
} }
}, },
"node_modules/@tsconfig/node10": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.13.tgz",
"integrity": "sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1569,6 +1685,39 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.6.1", "version": "10.6.1",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz",
@ -1606,6 +1755,236 @@
"postcss": "^8.1.0" "postcss": "^8.1.0"
} }
}, },
"node_modules/aws-cdk-lib": {
"version": "2.272.0",
"resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.272.0.tgz",
"integrity": "sha512-XHqU7msDtp03JLoFqtbIuNPDeCebQxOBwRcuL6BpH8B1rV/sMHSdH9EY0ZRn+P004RKHs/+b5DPssN6F966k1A==",
"bundleDependencies": [
"@aws/cloudformation-validate",
"@balena/dockerignore",
"@aws-cdk/cloud-assembly-api",
"case",
"fs-extra",
"ignore",
"jsonschema",
"minimatch",
"punycode",
"semver",
"yaml",
"mime-types"
],
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-cdk/asset-awscli-v1": "2.2.292",
"@aws-cdk/asset-node-proxy-agent-v6": "^2.1.2",
"@aws-cdk/cloud-assembly-api": "^2.2.6",
"@aws-cdk/cloud-assembly-schema": "^54.24.0",
"@aws/cloudformation-validate": "1.12.1",
"@balena/dockerignore": "^1.0.2",
"case": "1.6.3",
"fs-extra": "^11.3.6",
"ignore": "^5.3.2",
"jsonschema": "^1.5.0",
"mime-types": "^2.1.35",
"minimatch": "^10.2.5",
"punycode": "^2.3.1",
"semver": "^7.8.5",
"yaml": "1.10.3"
},
"engines": {
"node": ">= 20.0.0"
},
"peerDependencies": {
"constructs": "^10.5.0"
}
},
"node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": {
"version": "2.2.6",
"dev": true,
"inBundle": true,
"license": "Apache-2.0",
"dependencies": {
"jsonschema": "^1.5.0",
"semver": "^7.8.4"
},
"engines": {
"node": ">= 18.0.0"
},
"peerDependencies": {
"@aws-cdk/cloud-assembly-schema": ">=54.5.0"
}
},
"node_modules/aws-cdk-lib/node_modules/@aws/cloudformation-validate": {
"version": "1.12.1",
"dev": true,
"inBundle": true,
"license": "Apache-2.0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": {
"version": "1.0.2",
"dev": true,
"inBundle": true,
"license": "Apache-2.0"
},
"node_modules/aws-cdk-lib/node_modules/balanced-match": {
"version": "4.0.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/aws-cdk-lib/node_modules/brace-expansion": {
"version": "5.0.9",
"dev": true,
"inBundle": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/aws-cdk-lib/node_modules/case": {
"version": "1.6.3",
"dev": true,
"inBundle": true,
"license": "(MIT OR GPL-3.0-or-later)",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/aws-cdk-lib/node_modules/fs-extra": {
"version": "11.3.6",
"dev": true,
"inBundle": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/aws-cdk-lib/node_modules/graceful-fs": {
"version": "4.2.11",
"dev": true,
"inBundle": true,
"license": "ISC"
},
"node_modules/aws-cdk-lib/node_modules/ignore": {
"version": "5.3.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/aws-cdk-lib/node_modules/jsonfile": {
"version": "6.2.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/aws-cdk-lib/node_modules/jsonschema": {
"version": "1.5.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/aws-cdk-lib/node_modules/mime-db": {
"version": "1.52.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/aws-cdk-lib/node_modules/mime-types": {
"version": "2.1.35",
"dev": true,
"inBundle": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/aws-cdk-lib/node_modules/minimatch": {
"version": "10.2.5",
"dev": true,
"inBundle": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/aws-cdk-lib/node_modules/punycode": {
"version": "2.3.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/aws-cdk-lib/node_modules/semver": {
"version": "7.8.5",
"dev": true,
"inBundle": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/aws-cdk-lib/node_modules/universalify": {
"version": "2.0.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/aws-cdk-lib/node_modules/yaml": {
"version": "1.10.3",
"dev": true,
"inBundle": true,
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.11.23", "version": "2.11.23",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz",
@ -1653,6 +2032,13 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/bulma": { "node_modules/bulma": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/bulma/-/bulma-1.0.4.tgz", "resolved": "https://registry.npmjs.org/bulma/-/bulma-1.0.4.tgz",
@ -1680,6 +2066,13 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/constructs": {
"version": "10.8.1",
"resolved": "https://registry.npmjs.org/constructs/-/constructs-10.8.1.tgz",
"integrity": "sha512-98yGXYyhePqPYh3cYu8nzBERmAhC0DONe3UD03okK0nehZ7hYP4wgZuf02a04+uOWxnTJ5Rpp5m0GRNpwyLGGA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -1687,6 +2080,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@ -1722,6 +2128,16 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/diff": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.427", "version": "1.5.427",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz",
@ -1859,6 +2275,24 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@ -1902,6 +2336,27 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jszip": {
"version": "3.10.2",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
"integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@ -2192,6 +2647,13 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -2228,6 +2690,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2284,6 +2752,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/react": { "node_modules/react": {
"version": "19.3.0", "version": "19.3.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
@ -2347,6 +2821,21 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.63.3", "version": "4.63.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz",
@ -2393,12 +2882,43 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.28.0", "version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/selenium-webdriver": {
"version": "4.50.0",
"resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.50.0.tgz",
"integrity": "sha512-E8DhBVMpnsNwShcxSWG8oCVJLQoPZiEciqz/inH7YkKvnR/kBiSNjaJux+7iuA4x6/77N8T68vWMTJgW169kOg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/SeleniumHQ"
},
{
"type": "opencollective",
"url": "https://opencollective.com/selenium"
}
],
"license": "Apache-2.0",
"dependencies": {
"@bazel/runfiles": "^6.5.0",
"jszip": "^3.10.2",
"tmp": "^0.2.7",
"ws": "^8.22.0"
},
"engines": {
"node": ">= 22.0.0"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@ -2409,6 +2929,22 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -2419,6 +2955,26 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
@ -2457,6 +3013,59 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"license": "MIT",
"engines": {
"node": ">=14.14"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.7.3", "version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
@ -2509,6 +3118,19 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.3", "version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
@ -2584,12 +3206,43 @@
} }
} }
}, },
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
} }
} }
} }

View file

@ -16,7 +16,8 @@
"lucide-react": "^0.475.0", "lucide-react": "^0.475.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^6.28.0" "react-router-dom": "^6.28.0",
"selenium-webdriver": "^4.50.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
@ -25,8 +26,12 @@
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.6.1", "autoprefixer": "^10.6.1",
"aws-cdk-lib": "^2.272.0",
"constructs": "^10.8.1",
"postcss": "^8.5.28", "postcss": "^8.5.28",
"source-map-support": "^0.5.21",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"ts-node": "^10.9.2",
"typescript": "~5.7.2", "typescript": "~5.7.2",
"vite": "^6.0.0" "vite": "^6.0.0"
} }

View file

@ -5,7 +5,7 @@ import { api } from '../services/api';
interface SellerContextType { interface SellerContextType {
// Authentication // Authentication
user: UserProfile | null; user: UserProfile | null;
login: (emailOrPhone: string, password?: string) => Promise<any>; login: (emailOrPhone: string, password?: string, otpCode?: string, otpType?: string) => Promise<any>;
register: (userData: { username?: string; email?: string; phone: string; password?: string }) => Promise<any>; register: (userData: { username?: string; email?: string; phone: string; password?: string }) => Promise<any>;
logout: () => void; logout: () => void;
@ -21,7 +21,7 @@ interface SellerContextType {
// Inventory & Products // Inventory & Products
inventory: InventoryItem[]; inventory: InventoryItem[];
fetchData: () => Promise<void>; fetchData: () => Promise<void>;
updateStock: (id: string, newStock: number) => void; updateStock: (id: string, newStock: number) => Promise<void>;
addProduct: (product: Omit<InventoryItem, 'id' | 'salesCount'>) => Promise<void>; addProduct: (product: Omit<InventoryItem, 'id' | 'salesCount'>) => Promise<void>;
deleteProduct: (id: string) => Promise<void>; deleteProduct: (id: string) => Promise<void>;
@ -55,6 +55,7 @@ const defaultOnboarding: OnboardingData = {
email: '', email: '',
phone: '', phone: '',
isOtpVerified: false, isOtpVerified: false,
isEmailVerified: false,
taxMode: 'gstin', taxMode: 'gstin',
gstin: '', gstin: '',
enrolmentId: '', enrolmentId: '',
@ -153,13 +154,21 @@ export const SellerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
sku: p.sku || `SKU-${p.id}`, sku: p.sku || `SKU-${p.id}`,
title: p.title || 'Product', title: p.title || 'Product',
category: p.category || 'General', category: p.category || 'General',
craftTechnique: 'Authentic Craft', craftTechnique: p.craftTechnique || 'Authentic Craft',
giTagged: p.is_gi_tagged || false, giTagged: p.is_gi_tagged || false,
price: parseFloat(p.price) || 0, price: parseFloat(p.price) || 0,
mrp: parseFloat(p.compare_at_price) || ((parseFloat(p.price) || 0) * 1.2), mrp: parseFloat(p.compare_at_price) || ((parseFloat(p.price) || 0) * 1.2),
stock: p.stock || 0, stock: p.stock || 0,
status: p.stock === 0 ? 'out_of_stock' : p.stock < 5 ? 'low_stock' : 'active', status: p.stock === 0 ? 'out_of_stock' : p.stock < 5 ? 'low_stock' : 'active',
approvalStatus: (p.status && (p.status.toLowerCase() === 'approved' || p.status.toLowerCase() === 'active')) ? 'approved' : (p.status && p.status.toLowerCase() === 'rejected' ? 'rejected' : 'pending'),
image: p.image || '/images/placeholder.jpg', image: p.image || '/images/placeholder.jpg',
additional_images: p.additional_images || [],
video_url: p.video_url || '',
description: p.description || '',
compare_at_price: parseFloat(p.compare_at_price) || undefined,
package_weight: p.package_weight || 1.0,
shipping_profile: p.shipping_profile || 'Standard Fragile',
uploadSource: p.upload_source || (p.sku?.startsWith('TRDX-') || p.sku?.includes('CSV') || p.sku?.startsWith('BATCH-') ? 'bulk' : 'individual'),
salesCount: 0 salesCount: 0
}))); })));
@ -241,15 +250,16 @@ export const SellerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
localStorage.setItem('tradhox_app_submitted', 'true'); localStorage.setItem('tradhox_app_submitted', 'true');
}; };
const login = async (emailOrPhone: string, password?: string) => { const login = async (emailOrPhone: string, password?: string, otpCode?: string, otpType?: string) => {
try { try {
const data = await api.login(emailOrPhone, password || 'Password123!'); const data = await api.login(emailOrPhone, password, otpCode, otpType);
localStorage.setItem('tradhox_user_auth', JSON.stringify(data)); localStorage.setItem('tradhox_user_auth', JSON.stringify(data));
const userEmail = (data.user?.email && !data.user.email.endsWith('@tradhox.com')) ? data.user.email : '';
const loggedUser: UserProfile = { const loggedUser: UserProfile = {
id: String(data.user.id), id: String(data.user.id),
name: data.user.first_name ? `${data.user.first_name} ${data.user.last_name || ''}`.trim() : data.user.username, name: data.user.first_name ? `${data.user.first_name} ${data.user.last_name || ''}`.trim() : data.user.username,
email: data.user.email, email: userEmail,
phone: data.user.profile?.phone || emailOrPhone, phone: data.user.profile?.phone || emailOrPhone,
role: 'Seller', role: 'Seller',
avatar: data.user.profile?.logo_url || '/images/avatars/seller-main.jpg', avatar: data.user.profile?.logo_url || '/images/avatars/seller-main.jpg',
@ -272,8 +282,8 @@ export const SellerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const register = async (userData: { username?: string; email?: string; phone: string; password?: string }) => { const register = async (userData: { username?: string; email?: string; phone: string; password?: string }) => {
const cleanPhone = userData.phone.replace(/[^0-9]/g, ''); const cleanPhone = userData.phone.replace(/[^0-9]/g, '');
const username = userData.username || `artisan_${cleanPhone.slice(-6) || Math.floor(1000 + Math.random() * 9000)}`; const username = userData.username || (cleanPhone ? cleanPhone : (userData.email ? userData.email.split('@')[0] : `user_${Date.now()}`));
const email = userData.email || `${username}@tradhox.com`; const email = (userData.email && !userData.email.endsWith('@tradhox.com')) ? userData.email : '';
const password = userData.password || 'Password123!'; const password = userData.password || 'Password123!';
const payload = { const payload = {
@ -288,10 +298,11 @@ export const SellerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const data = await api.register(payload); const data = await api.register(payload);
localStorage.setItem('tradhox_user_auth', JSON.stringify(data)); localStorage.setItem('tradhox_user_auth', JSON.stringify(data));
const registeredEmail = (data.user?.email && !data.user.email.endsWith('@tradhox.com')) ? data.user.email : email;
const registeredUser: UserProfile = { const registeredUser: UserProfile = {
id: String(data.user?.id || 'seller-001'), id: String(data.user?.id || 'seller-001'),
name: data.user?.first_name ? `${data.user?.first_name} ${data.user?.last_name || ''}`.trim() : (data.user?.username || username), name: data.user?.first_name ? `${data.user?.first_name} ${data.user?.last_name || ''}`.trim() : (data.user?.username || username),
email: data.user?.email || email, email: registeredEmail,
phone: data.user?.profile?.phone || userData.phone, phone: data.user?.profile?.phone || userData.phone,
role: 'Seller', role: 'Seller',
avatar: data.user?.profile?.logo_url || '/images/avatars/seller-main.jpg', avatar: data.user?.profile?.logo_url || '/images/avatars/seller-main.jpg',
@ -368,21 +379,51 @@ export const SellerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
setPayouts([]); setPayouts([]);
}; };
const updateStock = (id: string, newStock: number) => { const updateStock = async (id: string, newStock: number) => {
const clamped = Math.max(0, newStock);
setInventory(prev => setInventory(prev =>
prev.map(item => prev.map(item =>
item.id === id item.id === id
? { ? {
...item, ...item,
stock: Math.max(0, newStock), stock: clamped,
status: newStock === 0 ? 'out_of_stock' : newStock < 5 ? 'low_stock' : 'active' status: clamped === 0 ? 'out_of_stock' : clamped < 5 ? 'low_stock' : 'active'
} }
: item : item
) )
); );
try {
if (id && !id.startsWith('prod-')) {
await api.patchProduct(id, { stock: clamped });
}
} catch (err) {
console.warn('Failed to persist stock update to API, kept local optimistic state:', err);
}
}; };
const addProduct = async (product: Omit<InventoryItem, 'id' | 'salesCount'>) => { const addProduct = async (product: Omit<InventoryItem, 'id' | 'salesCount'>) => {
const newLocalItem: InventoryItem = {
id: `prod-${Date.now()}`,
sku: product.sku || `TRDX-${Date.now().toString().slice(-5)}`,
title: product.title,
category: product.category,
craftTechnique: product.craftTechnique || 'Authentic Craft',
giTagged: product.giTagged || false,
price: product.price,
mrp: product.mrp,
stock: product.stock,
status: product.status,
image: product.image,
additional_images: product.additional_images,
video_url: product.video_url,
description: product.description,
compare_at_price: product.compare_at_price || product.mrp,
package_weight: product.package_weight || 1.0,
shipping_profile: product.shipping_profile || 'Standard Fragile',
salesCount: 0
};
try { try {
await api.createProduct({ await api.createProduct({
title: product.title, title: product.title,
@ -390,12 +431,21 @@ export const SellerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
category: product.category, category: product.category,
price: product.price, price: product.price,
stock: product.stock, stock: product.stock,
image: product.image image: product.image,
description: product.description || '',
is_gi_tagged: product.giTagged || false,
additional_images: product.additional_images || [],
video_url: product.video_url || null,
compare_at_price: product.compare_at_price || product.mrp || null,
package_weight: product.package_weight || 1.0,
shipping_profile: product.shipping_profile || 'Standard Fragile',
track_inventory: product.stock > 0,
processing_days: 3
}); });
fetchData(); await fetchData();
} catch (e) { } catch (e) {
console.error('Failed to add product', e); console.warn('Backend API create product notice (adding to local inventory state):', e);
throw e; setInventory(prev => [newLocalItem, ...prev]);
} }
}; };
@ -527,4 +577,4 @@ export const useSeller = () => {
throw new Error('useSeller must be used within a SellerProvider'); throw new Error('useSeller must be used within a SellerProvider');
} }
return ctx; return ctx;
}; };

View file

@ -41,11 +41,9 @@ export const LoginPage: React.FC<LoginPageProps> = ({ initialMode }) => {
}, [location.pathname]); }, [location.pathname]);
// Login state // Login state
const [loginTab, setLoginTab] = useState<'mobile' | 'email'>('mobile');
const [mobileNumber, setMobileNumber] = useState(''); const [mobileNumber, setMobileNumber] = useState('');
const [loginOtp, setLoginOtp] = useState(''); const [loginOtp, setLoginOtp] = useState('');
const [otpSent, setOtpSent] = useState(false); const [otpSent, setOtpSent] = useState(false);
const [emailAddress, setEmailAddress] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isPhoneFocused, setIsPhoneFocused] = useState(false); const [isPhoneFocused, setIsPhoneFocused] = useState(false);
@ -92,6 +90,7 @@ export const LoginPage: React.FC<LoginPageProps> = ({ initialMode }) => {
const handleGetOtp = async () => { const handleGetOtp = async () => {
setLoginError(''); setLoginError('');
setOtpSent(false);
const clean = mobileNumber.replace(/\D/g, ''); const clean = mobileNumber.replace(/\D/g, '');
if (clean.length !== 10) { if (clean.length !== 10) {
setLoginError('Please enter a valid 10-digit mobile number'); setLoginError('Please enter a valid 10-digit mobile number');
@ -101,10 +100,11 @@ export const LoginPage: React.FC<LoginPageProps> = ({ initialMode }) => {
try { try {
await api.sendWhatsAppOtp(clean, code, 'mobile_number_verification'); await api.sendWhatsAppOtp(clean, code, 'mobile_number_verification');
setOtpSent(true); setOtpSent(true);
setLoginOtp(''); // Leave empty so user enters the 6-digit code received on WhatsApp setLoginOtp(''); // Empty so user types the OTP received on WhatsApp
} catch (err) { } catch (err: any) {
console.warn('WhatsApp service call error:', err); console.warn('WhatsApp service call error:', err);
setLoginError('Failed to dispatch OTP. Please try again.'); setOtpSent(false);
setLoginError(err?.message || 'Failed to dispatch OTP. Please try again.');
} }
}; };
@ -112,50 +112,22 @@ export const LoginPage: React.FC<LoginPageProps> = ({ initialMode }) => {
e.preventDefault(); e.preventDefault();
setLoginError(''); setLoginError('');
try { try {
if (loginTab === 'mobile') { const clean = mobileNumber.replace(/\D/g, '');
const clean = mobileNumber.replace(/\D/g, ''); if (clean.length !== 10) {
if (clean.length !== 10) { setLoginError('Please enter a valid 10-digit mobile number');
setLoginError('Please enter a valid 10-digit mobile number'); return;
return; }
} if (!loginOtp || loginOtp.length < 6) {
if (!loginOtp || loginOtp.length < 6) { setLoginError('Please enter the 6-digit verification code sent to your mobile number');
setLoginError('Please enter the 6-digit verification code sent to your mobile number'); return;
return; }
} // Authenticate session to obtain access token
// Authenticate session to obtain access token const data = await login(clean, password, loginOtp, 'phone');
const data = await login(clean, password);
// Verify OTP strictly against backend OTPRecord table
try {
const verifyRes = await api.verifyOtp('phone', loginOtp);
if (!verifyRes || !verifyRes.verified) {
throw new Error(verifyRes?.error || 'Invalid or expired OTP code. Please enter the latest OTP received.');
}
} catch (otpErr: any) {
// Clear session since OTP verification failed
localStorage.removeItem('tradhox_user_auth');
localStorage.removeItem('tradhox_user');
const msg = otpErr?.message || 'Invalid or expired OTP code. Please enter the latest OTP received.';
setLoginError(msg);
return;
}
if (data?.user?.profile?.is_profile_complete) { if (data?.user?.profile?.is_profile_complete) {
navigate('/dashboard'); navigate('/dashboard');
} else {
navigate('/onboarding');
}
} else { } else {
if (!emailAddress) { navigate('/onboarding');
setLoginError('Please enter a valid email address');
return;
}
const data = await login(emailAddress, password);
if (data?.user?.profile?.is_profile_complete) {
navigate('/dashboard');
} else {
navigate('/onboarding');
}
} }
} catch (err: any) { } catch (err: any) {
console.error('Login error:', err); console.error('Login error:', err);
@ -446,55 +418,6 @@ export const LoginPage: React.FC<LoginPageProps> = ({ initialMode }) => {
Access your dashboard, manage orders, and grow your authentic craft brand. Access your dashboard, manage orders, and grow your authentic craft brand.
</p> </p>
{/* Segmented Toggle Tabs */}
<div
className="mb-5 p-1 is-flex"
style={{
backgroundColor: '#F1F3F5',
borderRadius: '9999px',
width: '100%',
maxWidth: '420px'
}}
>
<button
type="button"
onClick={() => setLoginTab('mobile')}
className="button is-small is-flex-grow-1"
style={{
backgroundColor: loginTab === 'mobile' ? '#FFFFFF' : 'transparent',
color: loginTab === 'mobile' ? '#1A202C' : '#718096',
fontWeight: loginTab === 'mobile' ? 700 : 500,
border: 'none',
borderRadius: '9999px',
boxShadow: loginTab === 'mobile' ? '0 2px 6px rgba(0, 0, 0, 0.08)' : 'none',
height: '36px',
transition: 'all 0.15s ease'
}}
>
<Smartphone size={14} className="mr-2" />
<span>Mobile OTP Login</span>
</button>
<button
type="button"
onClick={() => setLoginTab('email')}
className="button is-small is-flex-grow-1"
style={{
backgroundColor: loginTab === 'email' ? '#FFFFFF' : 'transparent',
color: loginTab === 'email' ? '#1A202C' : '#718096',
fontWeight: loginTab === 'email' ? 700 : 500,
border: 'none',
borderRadius: '9999px',
boxShadow: loginTab === 'email' ? '0 2px 6px rgba(0, 0, 0, 0.08)' : 'none',
height: '36px',
transition: 'all 0.15s ease'
}}
>
<Mail size={14} className="mr-2" />
<span>Email & Password</span>
</button>
</div>
{loginError && ( {loginError && (
<div className="notification is-danger is-light p-2.5 mb-4 is-size-7"> <div className="notification is-danger is-light p-2.5 mb-4 is-size-7">
{loginError} {loginError}
@ -503,236 +426,129 @@ export const LoginPage: React.FC<LoginPageProps> = ({ initialMode }) => {
{/* Login Form Contents */} {/* Login Form Contents */}
<form onSubmit={handleLoginSubmit}> <form onSubmit={handleLoginSubmit}>
{loginTab === 'mobile' ? ( {/* Mobile Number Field */}
<> <div className="field mb-1">
{/* Mobile Number Field */} <label
<div className="field mb-1"> className="label mb-1.5"
<label style={{
className="label mb-1.5" fontSize: '0.68rem',
style={{ fontWeight: 700,
fontSize: '0.68rem', color: '#718096',
fontWeight: 700, letterSpacing: '0.06em',
color: '#718096', textTransform: 'uppercase'
letterSpacing: '0.06em', }}
textTransform: 'uppercase' >
}} REGISTERED MOBILE NUMBER
> </label>
REGISTERED MOBILE NUMBER
</label>
<div <div
className="is-flex is-align-items-center" className="is-flex is-align-items-center"
style={{ style={{
border: isPhoneFocused ? '1.5px solid var(--trad-maroon)' : '1.5px solid #E2E8F0', border: isPhoneFocused ? '1.5px solid var(--trad-maroon)' : '1.5px solid #E2E8F0',
borderRadius: '12px', borderRadius: '12px',
backgroundColor: '#FFFFFF', backgroundColor: '#FFFFFF',
overflow: 'hidden', overflow: 'hidden',
boxShadow: isPhoneFocused ? '0 0 0 3px rgba(103, 27, 38, 0.12)' : 'none', boxShadow: isPhoneFocused ? '0 0 0 3px rgba(103, 27, 38, 0.12)' : 'none',
transition: 'border-color 0.15s ease, box-shadow 0.15s ease' transition: 'border-color 0.15s ease, box-shadow 0.15s ease'
}} }}
> >
<span <span
className="pl-3.5 pr-2.5 is-flex is-align-items-center" className="pl-3.5 pr-2.5 is-flex is-align-items-center"
style={{ style={{
color: '#4A5568', color: '#4A5568',
fontWeight: 600, fontWeight: 600,
fontSize: '0.875rem', fontSize: '0.875rem',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
userSelect: 'none' userSelect: 'none'
}} }}
> >
IN +91 IN +91
</span> </span>
<div style={{ width: '1px', height: '22px', backgroundColor: '#E2E8F0', flexShrink: 0 }} /> <div style={{ width: '1px', height: '22px', backgroundColor: '#E2E8F0', flexShrink: 0 }} />
<input <input
type="tel" type="tel"
maxLength={10} maxLength={10}
placeholder="Enter 10–digit mobile number" placeholder="Enter 10–digit mobile number"
value={mobileNumber} value={mobileNumber}
onChange={(e) => setMobileNumber(e.target.value)} onChange={(e) => setMobileNumber(e.target.value)}
onFocus={() => setIsPhoneFocused(true)} onFocus={() => setIsPhoneFocused(true)}
onBlur={() => setIsPhoneFocused(false)} onBlur={() => setIsPhoneFocused(false)}
style={{ style={{
border: 'none', border: 'none',
outline: 'none', outline: 'none',
boxShadow: 'none', boxShadow: 'none',
backgroundColor: 'transparent', backgroundColor: 'transparent',
height: '46px', height: '46px',
fontSize: '0.9rem', fontSize: '0.9rem',
color: '#1A202C', color: '#1A202C',
flex: 1, flex: 1,
padding: '0 12px', padding: '0 12px',
minWidth: 0 minWidth: 0
}} }}
/> />
<button <button
type="button" type="button"
onClick={handleGetOtp} onClick={handleGetOtp}
className="button is-ghost px-3 px-4-tablet" className="button is-ghost px-3 px-4-tablet"
style={{ style={{
color: 'var(--trad-maroon)', color: 'var(--trad-maroon)',
fontWeight: 700, fontWeight: 700,
fontSize: '0.85rem', fontSize: '0.85rem',
height: '46px', height: '46px',
textDecoration: 'none', textDecoration: 'none',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
flexShrink: 0 flexShrink: 0
}} }}
> >
Get OTP Get OTP
</button> </button>
</div> </div>
</div> </div>
{/* Verification SMS notice & Forgot password row */} {/* Verification SMS notice & Forgot password row */}
<div className="is-flex is-justify-content-space-between is-align-items-center mb-4 mt-1.5 px-0.5"> <div className="is-flex is-justify-content-space-between is-align-items-center mb-4 mt-1.5 px-0.5">
<span className="is-size-7" style={{ color: '#718096' }}> <span className="is-size-7" style={{ color: '#718096' }}>
A 6-digit verification code will be sent via SMS. A 6-digit verification code will be sent via SMS.
</span> </span>
<span </div>
onClick={switchToReset}
style={{
color: '#1A202C',
fontSize: '0.75rem',
fontWeight: 600,
cursor: 'pointer'
}}
>
Forgot password?
</span>
</div>
{/* OTP Input Field */} {/* OTP Input Field */}
<div className="field mb-5"> <div className="field mb-5">
<label <label
className="label mb-1.5" className="label mb-1.5"
style={{ style={{
fontSize: '0.68rem', fontSize: '0.68rem',
fontWeight: 700, fontWeight: 700,
color: '#718096', color: '#718096',
letterSpacing: '0.06em', letterSpacing: '0.06em',
textTransform: 'uppercase' textTransform: 'uppercase'
}} }}
> >
ONE-TIME PASSWORD (OTP) ONE-TIME PASSWORD (OTP)
</label> </label>
<div className="control"> <div className="control">
<input <input
type="text" type="text"
maxLength={6} maxLength={6}
placeholder="Enter 6–digit OTP received" placeholder="Enter 6–digit OTP received"
value={loginOtp} value={loginOtp}
onChange={(e) => setLoginOtp(e.target.value)} onChange={(e) => setLoginOtp(e.target.value)}
className="input" className="input"
style={{ style={{
border: '1.5px solid #E2E8F0', border: '1.5px solid #E2E8F0',
borderRadius: '12px', borderRadius: '12px',
height: '46px', height: '46px',
fontSize: '0.9rem', fontSize: '0.9rem',
color: '#1A202C' color: '#1A202C'
}} }}
/> />
</div> </div>
{otpSent && ( {otpSent && (
<p className="help has-text-success mt-1"> <p className="help has-text-success mt-1">
✓ Verification code dispatched to +91 {mobileNumber} via WhatsApp Service. ✓ Verification code dispatched to +91 {mobileNumber} via WhatsApp Service.
</p> </p>
)} )}
</div> </div>
</>
) : (
<>
{/* Email Address Field */}
<div className="field mb-4">
<label
className="label mb-1.5"
style={{
fontSize: '0.68rem',
fontWeight: 700,
color: '#718096',
letterSpacing: '0.06em',
textTransform: 'uppercase'
}}
>
REGISTERED EMAIL ADDRESS
</label>
<div className="control has-icons-left">
<input
type="email"
placeholder="Enter registered email address"
value={emailAddress}
onChange={(e) => setEmailAddress(e.target.value)}
className="input"
style={{
border: '1.5px solid #E2E8F0',
borderRadius: '12px',
height: '46px',
fontSize: '0.9rem',
color: '#1A202C'
}}
/>
<span className="icon is-small is-left" style={{ height: '46px' }}>
<Mail size={16} color="#718096" />
</span>
</div>
</div>
{/* Password Field */}
<div className="field mb-1">
<div className="is-flex is-justify-content-space-between is-align-items-center mb-1.5">
<label
className="label mb-0"
style={{
fontSize: '0.68rem',
fontWeight: 700,
color: '#718096',
letterSpacing: '0.06em',
textTransform: 'uppercase'
}}
>
PASSWORD
</label>
<span
onClick={switchToReset}
style={{
color: '#1A202C',
fontSize: '0.75rem',
fontWeight: 600,
cursor: 'pointer'
}}
>
Forgot password?
</span>
</div>
<div className="control has-icons-left has-icons-right">
<input
type={showPassword ? 'text' : 'password'}
placeholder="Enter your account password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input"
style={{
border: '1.5px solid #E2E8F0',
borderRadius: '12px',
height: '46px',
fontSize: '0.9rem',
color: '#1A202C'
}}
/>
<span className="icon is-small is-left" style={{ height: '46px' }}>
<Lock size={16} color="#718096" />
</span>
<span
className="icon is-small is-right is-clickable"
style={{ height: '46px', cursor: 'pointer', pointerEvents: 'all' }}
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff size={16} color="#718096" /> : <Eye size={16} color="#718096" />}
</span>
</div>
</div>
</>
)}
{/* Remember Device Checkbox */} {/* Remember Device Checkbox */}
<div className="field mb-4"> <div className="field mb-4">

View file

@ -90,6 +90,93 @@ export const OnboardingPage: React.FC = () => {
const [step2EnrolmentIdInput, setStep2EnrolmentIdInput] = useState(''); const [step2EnrolmentIdInput, setStep2EnrolmentIdInput] = useState('');
const [step2EnrolmentVerified, setStep2EnrolmentVerified] = useState(false); const [step2EnrolmentVerified, setStep2EnrolmentVerified] = useState(false);
// Step 2 Email OTP Verification State
const [emailOtp, setEmailOtp] = useState<string[]>(['', '', '', '', '', '']);
const [emailOtpSent, setEmailOtpSent] = useState(false);
const [emailOtpCountdown, setEmailOtpCountdown] = useState(0);
const [emailOtpLoading, setEmailOtpLoading] = useState(false);
const [emailOtpError, setEmailOtpError] = useState('');
const [emailOtpSuccessMsg, setEmailOtpSuccessMsg] = useState('');
const [isEmailVerified, setIsEmailVerified] = useState(onboarding.isEmailVerified || false);
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
if (emailOtpCountdown > 0) {
timer = setTimeout(() => setEmailOtpCountdown(emailOtpCountdown - 1), 1000);
}
return () => clearTimeout(timer);
}, [emailOtpCountdown]);
const handleSendEmailOtp = async () => {
setEmailOtpError('');
setEmailOtpSuccessMsg('');
const email = (onboarding.email || '').trim();
if (!email || !email.includes('@')) {
setEmailOtpError('Please enter a valid email address');
return;
}
setEmailOtpLoading(true);
try {
await api.sendEmailOtp(email, undefined, 'verification');
setEmailOtpSent(true);
setEmailOtp(['', '', '', '', '', '']);
setEmailOtpSuccessMsg(`✓ Verification code dispatched to ${email}`);
setEmailOtpCountdown(60);
setTimeout(() => {
document.getElementById('email-otp-0')?.focus();
}, 300);
} catch (err: any) {
console.warn('Failed to send email OTP:', err);
setEmailOtpError(err?.message || 'Failed to dispatch verification code. Please try again.');
} finally {
setEmailOtpLoading(false);
}
};
const handleEmailOtpChange = (index: number, val: string) => {
const clean = val.replace(/[^0-9]/g, '').slice(-1);
const newOtp = [...emailOtp];
newOtp[index] = clean;
setEmailOtp(newOtp);
if (clean && index < 5) {
document.getElementById(`email-otp-${index + 1}`)?.focus();
}
};
const handleEmailOtpKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !emailOtp[index] && index > 0) {
const prev = document.getElementById(`email-otp-${index - 1}`);
prev?.focus();
}
};
const handleVerifyEmailOtp = async () => {
setEmailOtpError('');
setEmailOtpSuccessMsg('');
const email = (onboarding.email || '').trim();
const code = emailOtp.join('');
if (code.length < 6) {
setEmailOtpError('Please enter the complete 6-digit OTP code');
return;
}
setEmailOtpLoading(true);
try {
const res = await api.verifyOtp('email', code, email);
if (res && res.verified) {
setIsEmailVerified(true);
updateOnboarding({ isEmailVerified: true, email });
setEmailOtpSuccessMsg('✓ Email address verified successfully!');
setEmailOtpSent(false);
} else {
setEmailOtpError(res?.error || 'Invalid or expired OTP code.');
}
} catch (err: any) {
setEmailOtpError(err?.message || 'Invalid or expired OTP code.');
} finally {
setEmailOtpLoading(false);
}
};
// Step 3 Specific State matching stitch wireframe // Step 3 Specific State matching stitch wireframe
const [step3SameAsBusiness, setStep3SameAsBusiness] = useState(false); const [step3SameAsBusiness, setStep3SameAsBusiness] = useState(false);
const [step3Landmark, setStep3Landmark] = useState(''); const [step3Landmark, setStep3Landmark] = useState('');
@ -221,7 +308,7 @@ export const OnboardingPage: React.FC = () => {
setStep1Loading(true); setStep1Loading(true);
try { try {
// Real-time backend OTP verification call with strict validation // Real-time backend OTP verification call with strict validation
const res = await api.verifyOtp('phone', enteredOtp); const res = await api.verifyOtp('phone', enteredOtp, cleanPhone);
if (res && res.verified) { if (res && res.verified) {
updateOnboarding({ isOtpVerified: true, phone: cleanPhone }); updateOnboarding({ isOtpVerified: true, phone: cleanPhone });
handleNext(); handleNext();
@ -1132,7 +1219,7 @@ export const OnboardingPage: React.FC = () => {
}, 300); }, 300);
} catch (resendErr: any) { } catch (resendErr: any) {
console.warn('WhatsApp service call exception:', resendErr); console.warn('WhatsApp service call exception:', resendErr);
setStep1Error('Failed to resend OTP. Please try again.'); setStep1Error(resendErr?.message || 'Failed to resend OTP. Please try again.');
} }
} else { } else {
try { try {
@ -1142,14 +1229,27 @@ export const OnboardingPage: React.FC = () => {
setStep1OtpSuccessMsg(`✓ Verification code dispatched to +91 ${clean} via WhatsApp Service.`); setStep1OtpSuccessMsg(`✓ Verification code dispatched to +91 ${clean} via WhatsApp Service.`);
setOtpCountdown(90); setOtpCountdown(90);
setHasSentOtp(true); setHasSentOtp(true);
setStep1Error('');
setTimeout(() => { setTimeout(() => {
document.getElementById('step1-otp-0')?.focus(); document.getElementById('step1-otp-0')?.focus();
}, 300); }, 300);
} catch (err: any) { } catch (err: any) {
console.warn('Registration failed:', err); console.warn('Registration notice, dispatching OTP directly:', err);
const msg = err?.message || 'Mobile number is already registered. Please log in instead.'; const code = Math.floor(100000 + Math.random() * 900000).toString();
setStep1Error(msg.includes('already registered') ? 'Mobile number is already registered. Please log in instead.' : msg); try {
setStep1OtpSuccessMsg(''); await api.sendWhatsAppOtp(clean, code, 'mobile_number_verification');
setStep1Otp(['', '', '', '', '', '']);
setStep1OtpSuccessMsg(`✓ Verification code dispatched to +91 ${clean} via WhatsApp Service.`);
setOtpCountdown(90);
setHasSentOtp(true);
setStep1Error('');
setTimeout(() => {
document.getElementById('step1-otp-0')?.focus();
}, 300);
} catch (otpErr: any) {
setStep1Error(otpErr?.message || 'Failed to dispatch verification code. Please try again.');
setStep1OtpSuccessMsg('');
}
} }
} }
} }
@ -1506,7 +1606,8 @@ export const OnboardingPage: React.FC = () => {
border: '1.5px solid #CBD5E0', border: '1.5px solid #CBD5E0',
fontSize: '0.88rem', fontSize: '0.88rem',
color: '#111827', color: '#111827',
boxShadow: 'none' boxShadow: 'none',
width: '100%'
}} }}
/> />
</div> </div>

View file

@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { useSeller } from '../../context/SellerContext'; import { useSeller } from '../../context/SellerContext';
import { api } from '../../services/api';
import { import {
ArrowRight, ArrowRight,
ArrowLeft, ArrowLeft,
@ -72,8 +73,60 @@ const CLUSTERS: Record<string, string[]> = {
}; };
export const AddProductPage: React.FC = () => { export const AddProductPage: React.FC = () => {
const [step, setStep] = useState(2); // Setting to 2 by default for demonstration const [step, setStep] = useState(1);
const [autoSaved, setAutoSaved] = useState(false); const [autoSaved, setAutoSaved] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [uploadingCover, setUploadingCover] = useState(false);
const [uploadingSupporting, setUploadingSupporting] = useState<boolean[]>([false, false, false, false]);
const [uploadingVideo, setUploadingVideo] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Step 2 state - S3 URLs for API submission
const [image, setImage] = useState('');
const [supportingImages, setSupportingImages] = useState<string[]>(['', '', '', '']);
const [videoUrl, setVideoUrl] = useState('');
const [videoName, setVideoName] = useState('');
// Step 2 state - Local previews for seamless, unblocked browser rendering
const [coverPreview, setCoverPreview] = useState<string>('');
const [supportingPreviews, setSupportingPreviews] = useState<string[]>(['', '', '', '']);
const [videoPreview, setVideoPreview] = useState<string>('');
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setUploadingCover(true);
setErrorMessage(null);
// 1. Instant reliable local preview
const localUrl = URL.createObjectURL(file);
setCoverPreview(localUrl);
const reader = new FileReader();
reader.onloadend = () => {
if (!image) setImage(reader.result as string);
simulateAutoSave();
};
reader.readAsDataURL(file);
// 2. Upload to S3 storage bucket
try {
const presignedData = await api.getPresignedUrl('cover', file.type || 'image/jpeg', sku);
if (presignedData && presignedData.presigned_url) {
await api.uploadToS3(presignedData.presigned_url, file);
console.log('Successfully uploaded product cover to S3:', presignedData.s3_key);
if (presignedData.s3_key) {
setImage(`https://betasupplierdocumentstorage.s3.ap-south-2.amazonaws.com/${presignedData.s3_key}`);
}
}
} catch (err) {
console.warn('S3 direct upload notice (using local preview fallback):', err);
} finally {
setUploadingCover(false);
}
}
};
const { addProduct } = useSeller(); const { addProduct } = useSeller();
const navigate = useNavigate(); const navigate = useNavigate();
@ -83,9 +136,87 @@ export const AddProductPage: React.FC = () => {
const [cluster, setCluster] = useState(''); const [cluster, setCluster] = useState('');
const [giTagged, setGiTagged] = useState(false); const [giTagged, setGiTagged] = useState(false);
const [giCertRef, setGiCertRef] = useState(''); const [giCertRef, setGiCertRef] = useState('');
// Step 2 state const handleSupportingPhotoUpload = async (index: number, e: React.ChangeEvent<HTMLInputElement>) => {
const [image, setImage] = useState(''); const file = e.target.files?.[0];
if (file) {
setUploadingSupporting(prev => {
const n = [...prev];
n[index] = true;
return n;
});
// 1. Local preview first
const localUrl = URL.createObjectURL(file);
setSupportingPreviews(prev => {
const n = [...prev];
n[index] = localUrl;
return n;
});
const reader = new FileReader();
reader.onloadend = () => {
setSupportingImages(prev => {
const next = [...prev];
if (!next[index]) next[index] = reader.result as string;
return next;
});
simulateAutoSave();
};
reader.readAsDataURL(file);
// 2. S3 direct upload
try {
const presignedData = await api.getPresignedUrl(`gallery_${index + 1}`, file.type || 'image/jpeg', sku);
if (presignedData && presignedData.presigned_url) {
await api.uploadToS3(presignedData.presigned_url, file);
if (presignedData.s3_key) {
const s3Url = `https://betasupplierdocumentstorage.s3.ap-south-2.amazonaws.com/${presignedData.s3_key}`;
setSupportingImages(prev => {
const next = [...prev];
next[index] = s3Url;
return next;
});
}
}
} catch (err) {
console.warn('Supporting photo S3 upload fallback:', err);
} finally {
setUploadingSupporting(prev => {
const n = [...prev];
n[index] = false;
return n;
});
}
}
};
const handleVideoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setUploadingVideo(true);
setVideoName(file.name);
const localUrl = URL.createObjectURL(file);
setVideoPreview(localUrl);
setVideoUrl(localUrl);
simulateAutoSave();
try {
const presignedData = await api.getPresignedUrl('video', file.type || 'video/mp4', sku);
if (presignedData && presignedData.presigned_url) {
await api.uploadToS3(presignedData.presigned_url, file);
if (presignedData.s3_key) {
const s3Url = `https://betasupplierdocumentstorage.s3.ap-south-2.amazonaws.com/${presignedData.s3_key}`;
setVideoUrl(s3Url);
}
}
} catch (err) {
console.warn('Video S3 upload fallback:', err);
} finally {
setUploadingVideo(false);
}
}
};
// Step 3 state // Step 3 state
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
@ -115,22 +246,50 @@ export const AddProductPage: React.FC = () => {
}; };
const handleFinish = async () => { const handleFinish = async () => {
if (!title.trim()) {
setErrorMessage('Please provide a Product Title in Step 3 before publishing.');
setStep(3);
return;
}
if (!price || Number(price) <= 0) {
setErrorMessage('Please enter a valid Selling Price in Step 4.');
setStep(4);
return;
}
setIsSubmitting(true);
setErrorMessage(null);
try { try {
const activeSupportingImages = supportingImages.filter(img => img && img.trim().length > 0);
await addProduct({ await addProduct({
sku: sku || `TRDX-${Date.now().toString().slice(-5)}`, sku: sku || `TRDX-${Date.now().toString().slice(-5)}`,
title: title || 'Authentic Handcrafted Item', title: title || 'Authentic Handcrafted Item',
category: category || 'Handicrafts & Art', category: category || 'Handicrafts & Art',
description: story || `${title} - Handcrafted ${category}`,
craftTechnique: craftType || craftTechnique || 'Traditional', craftTechnique: craftType || craftTechnique || 'Traditional',
giTagged, giTagged,
price: Number(price) || 1000, price: Number(price),
mrp: Number(mrp) || 1500, mrp: Number(mrp) || Math.round(Number(price) * 1.2),
compare_at_price: Number(mrp) || Math.round(Number(price) * 1.2),
stock: madeToOrder ? 0 : (Number(stock) || 10), stock: madeToOrder ? 0 : (Number(stock) || 10),
status: madeToOrder ? 'active' : (Number(stock) === 0 ? 'out_of_stock' : Number(stock) < 5 ? 'low_stock' : 'active'), status: 'pending' as any,
image: image || '/terracotta_vases.jpg', image: image || '/terracotta_vases.jpg',
additional_images: activeSupportingImages,
video_url: videoUrl || '',
package_weight: Number(weightKg) || 1.0,
shipping_profile: fragile ? 'Fragile Handcrafted' : 'Standard Fragile',
dimensions: dimensions || undefined,
materials: materials || undefined,
care_instructions: careInstructions || undefined,
tags: tagsList
}); });
navigate('/dashboard/inventory'); navigate('/dashboard/inventory');
} catch (e) { } catch (e: any) {
console.error('Failed to add product:', e); console.error('Failed to add product:', e);
setErrorMessage(e.message || 'Failed to publish product. Please check required fields.');
} finally {
setIsSubmitting(false);
} }
}; };
@ -251,6 +410,13 @@ export const AddProductPage: React.FC = () => {
{/* ── Step Content Card ── */} {/* ── Step Content Card ── */}
<div style={{ backgroundColor: '#FFFFFF', border: '1px solid #E5E7EB', borderRadius: '20px', padding: '2.5rem', boxShadow: '0 4px 12px rgba(0,0,0,0.02)' }}> <div style={{ backgroundColor: '#FFFFFF', border: '1px solid #E5E7EB', borderRadius: '20px', padding: '2.5rem', boxShadow: '0 4px 12px rgba(0,0,0,0.02)' }}>
{errorMessage && (
<div style={{ backgroundColor: '#FEF2F2', border: '1px solid #F87171', borderRadius: '12px', padding: '12px 16px', marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '10px', color: '#991B1B', fontSize: '0.85rem', fontWeight: 600 }}>
<AlertTriangle size={18} color="#DC2626" />
<span>{errorMessage}</span>
</div>
)}
{/* ━━━━ STEP 1: Category ━━━━ */} {/* ━━━━ STEP 1: Category ━━━━ */}
{step === 1 && ( {step === 1 && (
<div> <div>
@ -313,14 +479,56 @@ export const AddProductPage: React.FC = () => {
<label style={{ fontSize: '0.84rem', fontWeight: 700, color: '#111827' }}>Primary Cover Photo <span style={{ color: '#DC2626' }}>*</span></label> <label style={{ fontSize: '0.84rem', fontWeight: 700, color: '#111827' }}>Primary Cover Photo <span style={{ color: '#DC2626' }}>*</span></label>
<span style={{ fontSize: '0.75rem', color: '#6B7280' }}>Shown on search cards &amp; storefront hero</span> <span style={{ fontSize: '0.75rem', color: '#6B7280' }}>Shown on search cards &amp; storefront hero</span>
</div> </div>
<div style={{ border: '2px dashed #D1D5DB', borderRadius: '16px', padding: '3rem 2rem', textAlign: 'center', backgroundColor: '#FAFAFA', cursor: 'pointer', transition: 'background 0.2s ease' }} onMouseOver={e => e.currentTarget.style.backgroundColor = '#F3F4F6'} onMouseOut={e => e.currentTarget.style.backgroundColor = '#FAFAFA'}> <div
<div style={{ width: '48px', height: '48px', borderRadius: '50%', backgroundColor: '#FEE2E2', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 12px' }}> style={{ border: '2px dashed #D1D5DB', borderRadius: '16px', padding: image ? '1.5rem' : '3rem 2rem', textAlign: 'center', backgroundColor: '#FAFAFA', position: 'relative', cursor: 'pointer', transition: 'background 0.2s ease' }}
<ImageIcon size={20} color="#991B1B" /> onMouseOver={e => e.currentTarget.style.backgroundColor = '#F3F4F6'}
</div> onMouseOut={e => e.currentTarget.style.backgroundColor = '#FAFAFA'}
<p style={{ fontSize: '1rem', fontWeight: 700, color: '#111827' }}> >
Drag and drop high-res cover image, or <span style={{ color: '#701A29', textDecoration: 'underline' }}>Browse Files</span> <input
</p> type="file"
<p style={{ fontSize: '0.78rem', color: '#6B7280', marginTop: '6px' }}>Recommended 2000×2000px · Clean lighting · Up to 15MB (JPG, PNG, WebP)</p> accept="image/*"
onChange={handleFileChange}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer', zIndex: 10 }}
/>
{coverPreview || image ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
<img
src={coverPreview || image}
alt="Product Cover Preview"
style={{ maxHeight: '220px', borderRadius: '12px', objectFit: 'contain', boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
onError={(e) => {
console.warn('Cover preview failed to load directly, resetting fallback');
}}
/>
{uploadingCover ? (
<p style={{ fontSize: '0.8rem', color: '#701A29', fontWeight: 700 }}>Uploading cover to secure cloud storage...</p>
) : (
<p style={{ fontSize: '0.75rem', color: '#166534', fontWeight: 600 }}>✓ Cover photo ready</p>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setImage('');
setCoverPreview('');
}}
style={{ fontSize: '0.8rem', fontWeight: 700, color: '#DC2626', background: '#FEE2E2', border: 'none', borderRadius: '8px', padding: '6px 16px', cursor: 'pointer', zIndex: 20 }}
>
Remove / Change Cover Photo
</button>
</div>
) : (
<>
<div style={{ width: '48px', height: '48px', borderRadius: '50%', backgroundColor: '#FEE2E2', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 12px' }}>
<ImageIcon size={20} color="#991B1B" />
</div>
<p style={{ fontSize: '1rem', fontWeight: 700, color: '#111827' }}>
{uploadingCover ? 'Uploading cover photo...' : 'Drag and drop high-res cover image, or '}
{!uploadingCover && <span style={{ color: '#701A29', textDecoration: 'underline' }}>Browse Files</span>}
</p>
<p style={{ fontSize: '0.78rem', color: '#6B7280', marginTop: '6px' }}>Recommended 2000x2000px | Clean lighting | Up to 15MB (JPG, PNG, WebP)</p>
</>
)}
</div> </div>
</div> </div>
@ -332,41 +540,110 @@ export const AddProductPage: React.FC = () => {
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '12px' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '12px' }}>
{[ {[
{ title: 'Detail / Texture', desc: 'Slot 2 · Glaze & Carving' }, { title: 'Detail / Texture', desc: 'Slot 2 - Glaze & Carving' },
{ title: 'Scale & Context', desc: 'Slot 3 · In-Hand or Room' }, { title: 'Scale & Context', desc: 'Slot 3 - In-Hand or Room' },
{ title: 'Artisan Making', desc: 'Slot 4 · Workshop Craft' }, { title: 'Artisan Making', desc: 'Slot 4 - Workshop Craft' },
{ title: 'Packaging & GI Tag', desc: 'Slot 5 · Safe Box & Seal' }, { title: 'Packaging & GI Tag', desc: 'Slot 5 - Safe Box & Seal' },
].map((slot, i) => ( ].map((slot, i) => {
<div key={i} style={{ border: '1px solid #E5E7EB', borderRadius: '16px', padding: '1.5rem 1rem', textAlign: 'center', backgroundColor: '#FFFFFF', cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '160px' }}> const slotPreview = supportingPreviews[i] || supportingImages[i];
<div style={{ width: '32px', height: '32px', borderRadius: '50%', backgroundColor: '#FFF1F2', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '12px' }}> const isUploading = uploadingSupporting[i];
<Plus size={16} color="#991B1B" /> return (
<div
key={i}
style={{
border: '1px solid #E5E7EB', borderRadius: '16px', padding: slotPreview ? '0.75rem' : '1.5rem 1rem',
textAlign: 'center', backgroundColor: '#FFFFFF', position: 'relative', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '160px'
}}
>
<input
type="file"
accept="image/*"
onChange={(e) => handleSupportingPhotoUpload(i, e)}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer', zIndex: 10 }}
/>
{slotPreview ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px', zIndex: 15, width: '100%' }}>
<img src={slotPreview} alt={slot.title} style={{ width: '100%', height: '90px', objectFit: 'cover', borderRadius: '10px' }} />
{isUploading ? (
<span style={{ fontSize: '0.68rem', fontWeight: 700, color: '#701A29' }}>Uploading...</span>
) : (
<span style={{ fontSize: '0.68rem', fontWeight: 600, color: '#166534' }}>✓ Attached</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSupportingImages(prev => {
const next = [...prev];
next[i] = '';
return next;
});
setSupportingPreviews(prev => {
const next = [...prev];
next[i] = '';
return next;
});
}}
style={{ fontSize: '0.68rem', fontWeight: 700, color: '#DC2626', background: '#FEE2E2', border: 'none', borderRadius: '6px', padding: '4px 10px', cursor: 'pointer', zIndex: 20 }}
>
Remove
</button>
</div>
) : (
<>
<div style={{ width: '32px', height: '32px', borderRadius: '50%', backgroundColor: '#FFF1F2', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '12px' }}>
<Plus size={16} color="#991B1B" />
</div>
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: '#111827' }}>
{isUploading ? 'Uploading...' : slot.title}
</div>
<div style={{ fontSize: '0.7rem', color: '#6B7280', marginTop: '2px' }}>{slot.desc}</div>
</>
)}
</div> </div>
<div style={{ fontSize: '0.78rem', fontWeight: 700, color: '#111827' }}>{slot.title}</div> );
<div style={{ fontSize: '0.7rem', color: '#6B7280', marginTop: '2px' }}>{slot.desc}</div> })}
</div>
))}
</div> </div>
</div> </div>
{/* Artisan Process Video */} {/* Artisan Process Video */}
<div style={{ border: '1px solid #E5E7EB', borderRadius: '16px', padding: '1.5rem', backgroundColor: '#FAFAFA', display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '2rem' }}> <div style={{ border: '1px solid #E5E7EB', borderRadius: '16px', padding: '1.5rem', backgroundColor: '#FAFAFA', display: 'flex', flexDirection: 'column', gap: '1rem', marginBottom: '2rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<div style={{ width: '48px', height: '48px', borderRadius: '50%', backgroundColor: '#FEE2E2', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}> <div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<Video size={20} color="#991B1B" /> <div style={{ width: '48px', height: '48px', borderRadius: '50%', backgroundColor: '#FEE2E2', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Video size={20} color="#991B1B" />
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ fontSize: '0.94rem', fontWeight: 800, color: '#111827' }}>Artisan Process Video (Short Reel)</div>
<span style={{ backgroundColor: '#DCFCE7', color: '#166534', fontSize: '0.65rem', fontWeight: 700, padding: '2px 8px', borderRadius: '9999px' }}>+70% Buyer Trust</span>
</div>
<div style={{ fontSize: '0.78rem', color: '#4B5563', marginTop: '4px', maxWidth: '420px', lineHeight: 1.4 }}>
Upload a 10-30s video showing how this piece was handcrafted. Highlight raw natural materials, wheel shaping, or fine brush detailing.
</div>
</div>
</div> </div>
<div> <div style={{ position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}> <input
<div style={{ fontSize: '0.94rem', fontWeight: 800, color: '#111827' }}>Artisan Process Video (Short Reel)</div> type="file"
<span style={{ backgroundColor: '#DCFCE7', color: '#166534', fontSize: '0.65rem', fontWeight: 700, padding: '2px 8px', borderRadius: '9999px' }}>+70% Buyer Trust</span> accept="video/*"
</div> onChange={handleVideoUpload}
<div style={{ fontSize: '0.78rem', color: '#4B5563', marginTop: '4px', maxWidth: '420px', lineHeight: 1.4 }}> style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', opacity: 0, cursor: 'pointer', zIndex: 10 }}
Upload a 10-30s video showing how this piece was handcrafted. Highlight raw natural materials, wheel shaping, or fine brush detailing. />
</div> <button type="button" style={{ backgroundColor: videoName ? '#DCFCE7' : '#FFFFFF', border: `1px solid ${videoName ? '#166534' : '#D1D5DB'}`, borderRadius: '9999px', padding: '8px 16px', fontSize: '0.82rem', fontWeight: 600, color: videoName ? '#166534' : '#374151', display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
<UploadCloud size={14} /> {uploadingVideo ? 'Uploading Video...' : videoName ? 'Change Video' : 'Upload Video'}
</button>
</div> </div>
</div> </div>
<button style={{ backgroundColor: '#FFFFFF', border: '1px solid #D1D5DB', borderRadius: '9999px', padding: '8px 16px', fontSize: '0.82rem', fontWeight: 600, color: '#374151', display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}> {(videoPreview || videoUrl) && (
<UploadCloud size={14} /> Upload Video <div style={{ borderTop: '1px solid #E5E7EB', paddingTop: '12px', display: 'flex', alignItems: 'center', gap: '16px' }}>
</button> <video src={videoPreview || videoUrl} controls style={{ maxHeight: '180px', borderRadius: '12px', backgroundColor: '#000' }} />
<div style={{ fontSize: '0.78rem', color: '#166534', fontWeight: 600 }}>
✓ Video attached: {videoName || 'Product Video Preview'}
</div>
</div>
)}
</div> </div>
{/* Heritage Media Guidelines */} {/* Heritage Media Guidelines */}
@ -645,9 +922,9 @@ export const AddProductPage: React.FC = () => {
<ArrowRight size={16} /> <ArrowRight size={16} />
</button> </button>
) : ( ) : (
<button type="button" onClick={handleFinish} <button type="button" onClick={handleFinish} disabled={isSubmitting}
style={{ fontSize: '0.9rem', fontWeight: 700, color: '#FFFFFF', backgroundColor: '#701A29', border: 'none', borderRadius: '9999px', padding: '10px 28px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', boxShadow: '0 4px 12px rgba(112,26,41,0.2)' }}> style={{ fontSize: '0.9rem', fontWeight: 700, color: '#FFFFFF', backgroundColor: isSubmitting ? '#9CA3AF' : '#701A29', border: 'none', borderRadius: '9999px', padding: '10px 28px', cursor: isSubmitting ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', gap: '8px', boxShadow: isSubmitting ? 'none' : '0 4px 12px rgba(112,26,41,0.2)' }}>
Publish Handcrafted Product 🚀 {isSubmitting ? 'Publishing Product...' : 'Publish Handcrafted Product 🚀'}
</button> </button>
)} )}
</div> </div>

File diff suppressed because it is too large Load diff

View file

@ -1,22 +1,23 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useSeller } from '../../context/SellerContext'; import { useSeller } from '../../context/SellerContext';
import { api } from '../../services/api';
import { import {
Search, Search,
Filter,
Trash2, Trash2,
UploadCloud, UploadCloud,
Plus, Plus,
Minus, Minus,
Award, Award,
AlertCircle,
AlertTriangle, AlertTriangle,
Package, Package,
IndianRupee, IndianRupee,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Edit3, Edit3,
ShieldCheck ShieldCheck,
CheckCircle2,
FileSpreadsheet
} from 'lucide-react'; } from 'lucide-react';
const HEALTH_CONFIG: Record<string, { label: string; bg: string; color: string; dot: string }> = { const HEALTH_CONFIG: Record<string, { label: string; bg: string; color: string; dot: string }> = {
@ -27,8 +28,8 @@ const HEALTH_CONFIG: Record<string, { label: string; bg: string; color: string;
}; };
function healthFromStock(stock: number): string { function healthFromStock(stock: number): string {
if (stock <= 4) return 'Critical'; if (stock === 0) return 'Critical';
if (stock <= 10) return 'Low'; if (stock <= 4) return 'Low';
if (stock <= 20) return 'Adequate'; if (stock <= 20) return 'Adequate';
return 'Healthy'; return 'Healthy';
} }
@ -36,38 +37,82 @@ function healthFromStock(stock: number): string {
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
export const InventoryPage: React.FC = () => { export const InventoryPage: React.FC = () => {
const { inventory, updateStock, deleteProduct } = useSeller(); const { inventory, updateStock, deleteProduct, fetchData } = useSeller();
useEffect(() => {
fetchData();
}, []);
const getItemSourceInfo = (item: any) => {
const isBulk = item.uploadSource === 'bulk' ||
item.sku?.startsWith('TRDX-') ||
item.sku?.startsWith('BATCH-') ||
item.sku?.includes('CSV');
if (isBulk) {
return {
type: 'bulk',
label: 'Bulk CSV',
icon: FileSpreadsheet,
bg: '#EFF6FF',
color: '#1D4ED8',
border: '#BFDBFE'
};
}
return {
type: 'individual',
label: 'Single Craft',
icon: Plus,
bg: '#F0FDF4',
color: '#15803D',
border: '#BBF7D0'
};
};
const allSkus = inventory.map(inv => ({ const allSkus = inventory.map(inv => ({
...inv, ...inv,
subtitle: inv.craftTechnique || inv.category, subtitle: inv.craftTechnique || inv.category,
health: healthFromStock(inv.stock) health: healthFromStock(inv.stock),
sourceInfo: getItemSourceInfo(inv)
})); }));
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [stockFilter, setStockFilter] = useState<'all' | 'in_stock' | 'low_stock'>('all'); const [stockFilter, setStockFilter] = useState<'all' | 'in_stock' | 'low_stock'>('all');
const [sourceFilter, setSourceFilter] = useState<'all' | 'bulk' | 'individual'>('all');
const [categoryFilter, setCategoryFilter] = useState('All'); const [categoryFilter, setCategoryFilter] = useState('All');
const [stockValues, setStockValues] = useState<Record<string, number>>( const [stockValues, setStockValues] = useState<Record<string, number>>({});
Object.fromEntries(allSkus.map(s => [s.id, s.stock])) const [currentPage, setCurrentPage] = useState(1);
); const [editingItem, setEditingItem] = useState<any>(null);
const [currentPage, setCurrentPage] = useState(1); const [editPrice, setEditPrice] = useState<number | ''>('');
const [editingItem, setEditingItem] = useState<any>(null); const [isSavingEdit, setIsSavingEdit] = useState(false);
const categories = ['All', 'Pottery & Ceramics', 'Brass & Metal Craft', 'Woodcraft & Carvings', 'Handloom & Textiles', 'Jewellery & Ornaments']; useEffect(() => {
setStockValues(prev => {
const next = { ...prev };
inventory.forEach(inv => {
next[inv.id] = inv.stock;
});
return next;
});
}, [inventory]);
const categories = ['All', ...Array.from(new Set(allSkus.map(s => s.category || 'General')))];
const filtered = allSkus.filter(item => { const filtered = allSkus.filter(item => {
const q = searchTerm.toLowerCase(); const q = searchTerm.toLowerCase();
const matchSearch = !q || item.title.toLowerCase().includes(q) || item.sku.toLowerCase().includes(q) || item.category.toLowerCase().includes(q); const matchSearch = !q || item.title.toLowerCase().includes(q) || item.sku.toLowerCase().includes(q) || item.category.toLowerCase().includes(q);
const stk = stockValues[item.id] ?? item.stock; const stk = stockValues[item.id] ?? item.stock;
const matchStock = stockFilter === 'all' ? true : stockFilter === 'in_stock' ? stk > 4 : stk <= 4; const matchStock = stockFilter === 'all' ? true : stockFilter === 'in_stock' ? stk > 0 : stk <= 4;
const matchSource = sourceFilter === 'all' ? true : sourceFilter === item.sourceInfo.type;
const matchCat = categoryFilter === 'All' || item.category === categoryFilter; const matchCat = categoryFilter === 'All' || item.category === categoryFilter;
return matchSearch && matchStock && matchCat; return matchSearch && matchStock && matchSource && matchCat;
}); });
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginated = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); const paginated = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
const totalListed = allSkus.length; const totalListed = allSkus.length;
const bulkCount = allSkus.filter(s => s.sourceInfo.type === 'bulk').length;
const singleCount = allSkus.filter(s => s.sourceInfo.type === 'individual').length;
const lowStockCount = allSkus.filter(s => (stockValues[s.id] ?? s.stock) <= 4).length; const lowStockCount = allSkus.filter(s => (stockValues[s.id] ?? s.stock) <= 4).length;
const catalogValue = allSkus.reduce((sum, s) => sum + (stockValues[s.id] ?? s.stock) * s.price, 0); const catalogValue = allSkus.reduce((sum, s) => sum + (stockValues[s.id] ?? s.stock) * s.price, 0);
const saleableValue = allSkus.filter(s => (stockValues[s.id] ?? s.stock) > 0) const saleableValue = allSkus.filter(s => (stockValues[s.id] ?? s.stock) > 0)
@ -75,7 +120,8 @@ export const InventoryPage: React.FC = () => {
const handleStepStock = (id: string, delta: number) => { const handleStepStock = (id: string, delta: number) => {
setStockValues(prev => { setStockValues(prev => {
const next = Math.max(0, (prev[id] ?? 0) + delta); const current = prev[id] ?? (inventory.find(i => i.id === id)?.stock ?? 0);
const next = Math.max(0, current + delta);
updateStock(id, next); updateStock(id, next);
return { ...prev, [id]: next }; return { ...prev, [id]: next };
}); });
@ -87,6 +133,39 @@ export const InventoryPage: React.FC = () => {
updateStock(id, n); updateStock(id, n);
}; };
const handleDeleteProduct = async (id: string, title: string) => {
if (window.confirm(`Are you sure you want to delete "${title}" from your inventory catalog?`)) {
try {
await deleteProduct(id);
await fetchData();
} catch (err: any) {
alert(err.message || 'Could not delete product');
}
}
};
const handleSaveEdit = async () => {
if (!editingItem) return;
setIsSavingEdit(true);
const currentStock = stockValues[editingItem.id] ?? editingItem.stock;
const patchPayload: any = { stock: currentStock };
if (editPrice !== '' && !isNaN(Number(editPrice))) {
patchPayload.price = Number(editPrice);
}
try {
if (editingItem.id && !String(editingItem.id).startsWith('prod-')) {
await api.patchProduct(editingItem.id, patchPayload);
}
await updateStock(editingItem.id, currentStock);
await fetchData();
} catch (err) {
console.warn('Failed to update product via quick edit drawer:', err);
} finally {
setIsSavingEdit(false);
setEditingItem(null);
}
};
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
@ -97,7 +176,7 @@ export const InventoryPage: React.FC = () => {
Catalog &amp; Inventory Catalog &amp; Inventory
</h1> </h1>
<p style={{ fontSize: '0.82rem', color: '#6B7280', marginTop: '4px' }}> <p style={{ fontSize: '0.82rem', color: '#6B7280', marginTop: '4px' }}>
Manage your artisan SKUs, stock levels, and pricing across all craft disciplines. Manage your artisan SKUs (both single and bulk uploaded items), stock levels, and pricing.
</p> </p>
</div> </div>
<div className="is-flex" style={{ gap: '0.6rem', flexWrap: 'wrap' }}> <div className="is-flex" style={{ gap: '0.6rem', flexWrap: 'wrap' }}>
@ -122,10 +201,11 @@ export const InventoryPage: React.FC = () => {
<Package size={14} color="#BE123C" /> <Package size={14} color="#BE123C" />
</div> </div>
</div> </div>
<div style={{ fontSize: '2rem', fontWeight: 800, color: '#111827', lineHeight: 1.1 }}>{totalListed} <span style={{ fontSize: '1rem', fontWeight: 600, color: '#6B7280' }}>SKUs</span></div> <div style={{ fontSize: '1.8rem', fontWeight: 800, color: '#111827', lineHeight: 1.1 }}>{totalListed} <span style={{ fontSize: '0.9rem', fontWeight: 600, color: '#6B7280' }}>SKUs</span></div>
<div className="is-flex is-align-items-center mt-2" style={{ gap: '6px', fontSize: '0.74rem' }}> <div className="is-flex is-align-items-center mt-2" style={{ gap: '6px', fontSize: '0.74rem' }}>
<span style={{ color: '#16A34A', fontWeight: 700 }}>↑ +6 this week</span> <span style={{ color: '#2563EB', fontWeight: 700 }}>{bulkCount} Bulk</span>
<span style={{ color: '#6B7280' }}>· {allSkus.filter(s => (stockValues[s.id] ?? s.stock) > 0).length} active</span> <span style={{ color: '#6B7280' }}>·</span>
<span style={{ color: '#16A34A', fontWeight: 700 }}>{singleCount} Single</span>
</div> </div>
<div style={{ width: '36px', height: '3px', backgroundColor: '#701A29', borderRadius: '2px', marginTop: '8px' }} /> <div style={{ width: '36px', height: '3px', backgroundColor: '#701A29', borderRadius: '2px', marginTop: '8px' }} />
</div> </div>
@ -139,8 +219,8 @@ export const InventoryPage: React.FC = () => {
<AlertTriangle size={14} color="#DC2626" /> <AlertTriangle size={14} color="#DC2626" />
</div> </div>
</div> </div>
<div style={{ fontSize: '2rem', fontWeight: 800, color: lowStockCount > 0 ? '#DC2626' : '#16A34A', lineHeight: 1.1 }}> <div style={{ fontSize: '1.8rem', fontWeight: 800, color: lowStockCount > 0 ? '#DC2626' : '#16A34A', lineHeight: 1.1 }}>
{lowStockCount} <span style={{ fontSize: '1rem', fontWeight: 600, color: '#6B7280' }}>Crafts</span> {lowStockCount} <span style={{ fontSize: '0.9rem', fontWeight: 600, color: '#6B7280' }}>Crafts</span>
</div> </div>
<div style={{ fontSize: '0.74rem', color: '#6B7280', marginTop: '6px' }}> <div style={{ fontSize: '0.74rem', color: '#6B7280', marginTop: '6px' }}>
{lowStockCount > 0 ? 'Restock urgently to avoid cancellations' : 'All SKUs adequately stocked'} {lowStockCount > 0 ? 'Restock urgently to avoid cancellations' : 'All SKUs adequately stocked'}
@ -171,19 +251,43 @@ export const InventoryPage: React.FC = () => {
{/* Filter Controls */} {/* Filter Controls */}
<div className="p-4" style={{ backgroundColor: '#FFFFFF', border: '1px solid #E5E7EB', borderRadius: '16px', boxShadow: '0 1px 3px rgba(0,0,0,0.02)' }}> <div className="p-4" style={{ backgroundColor: '#FFFFFF', border: '1px solid #E5E7EB', borderRadius: '16px', boxShadow: '0 1px 3px rgba(0,0,0,0.02)' }}>
<div className="is-flex is-justify-content-space-between is-align-items-center is-flex-wrap-wrap" style={{ gap: '0.75rem' }}> <div className="is-flex is-justify-content-space-between is-align-items-center is-flex-wrap-wrap" style={{ gap: '0.75rem' }}>
<div className="is-flex" style={{ gap: '6px', flexWrap: 'wrap' }}> <div className="is-flex" style={{ gap: '6px', flexWrap: 'wrap' }}>
{([ <button
{ key: 'all', label: `All (${allSkus.length})` }, type="button"
{ key: 'in_stock', label: `In Stock (${allSkus.filter(s => (stockValues[s.id] ?? s.stock) > 4).length})` }, onClick={() => { setSourceFilter('all'); setStockFilter('all'); setCurrentPage(1); }}
{ key: 'low_stock', label: `Low Stock (${lowStockCount})` }, className="button is-small"
] as const).map(f => ( style={{ borderRadius: '9999px', backgroundColor: (sourceFilter === 'all' && stockFilter === 'all') ? '#701A29' : '#F9FAFB', borderColor: (sourceFilter === 'all' && stockFilter === 'all') ? '#701A29' : '#E5E7EB', color: (sourceFilter === 'all' && stockFilter === 'all') ? '#FFFFFF' : '#374151', fontWeight: 600, fontSize: '0.78rem', height: '32px', padding: '0 14px' }}
<button key={f.key} type="button" onClick={() => { setStockFilter(f.key); setCurrentPage(1); }} >
className="button is-small" All ({allSkus.length})
style={{ borderRadius: '9999px', backgroundColor: stockFilter === f.key ? '#701A29' : '#F9FAFB', borderColor: stockFilter === f.key ? '#701A29' : '#E5E7EB', color: stockFilter === f.key ? '#FFFFFF' : '#374151', fontWeight: 600, fontSize: '0.78rem', height: '32px', padding: '0 14px', transition: 'all 0.15s ease' }} </button>
>
{f.label} <button
</button> type="button"
))} onClick={() => { setSourceFilter('bulk'); setCurrentPage(1); }}
className="button is-small"
style={{ borderRadius: '9999px', backgroundColor: sourceFilter === 'bulk' ? '#2563EB' : '#F9FAFB', borderColor: sourceFilter === 'bulk' ? '#2563EB' : '#E5E7EB', color: sourceFilter === 'bulk' ? '#FFFFFF' : '#374151', fontWeight: 600, fontSize: '0.78rem', height: '32px', padding: '0 14px' }}
>
Bulk Uploaded ({bulkCount})
</button>
<button
type="button"
onClick={() => { setSourceFilter('individual'); setCurrentPage(1); }}
className="button is-small"
style={{ borderRadius: '9999px', backgroundColor: sourceFilter === 'individual' ? '#16A34A' : '#F9FAFB', borderColor: sourceFilter === 'individual' ? '#16A34A' : '#E5E7EB', color: sourceFilter === 'individual' ? '#FFFFFF' : '#374151', fontWeight: 600, fontSize: '0.78rem', height: '32px', padding: '0 14px' }}
>
Single Uploaded ({singleCount})
</button>
<button
type="button"
onClick={() => { setStockFilter('low_stock'); setCurrentPage(1); }}
className="button is-small"
style={{ borderRadius: '9999px', backgroundColor: stockFilter === 'low_stock' ? '#DC2626' : '#F9FAFB', borderColor: stockFilter === 'low_stock' ? '#DC2626' : '#E5E7EB', color: stockFilter === 'low_stock' ? '#FFFFFF' : '#374151', fontWeight: 600, fontSize: '0.78rem', height: '32px', padding: '0 14px' }}
>
Low Stock ({lowStockCount})
</button>
</div> </div>
<div className="is-flex is-align-items-center" style={{ gap: '0.6rem', flexWrap: 'wrap' }}> <div className="is-flex is-align-items-center" style={{ gap: '0.6rem', flexWrap: 'wrap' }}>
@ -222,7 +326,7 @@ export const InventoryPage: React.FC = () => {
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead> <thead>
<tr style={{ backgroundColor: '#FAFAFA', borderBottom: '1px solid #E5E7EB' }}> <tr style={{ backgroundColor: '#FAFAFA', borderBottom: '1px solid #E5E7EB' }}>
{['Craft Product & SKU', 'Category & GI Tag', 'Pricing', 'Stock Qty', 'Health', 'Actions'].map(h => ( {['Craft Product & SKU', 'Category & GI Tag', 'Pricing', 'Stock Qty', 'Approval Status', 'Health', 'Actions'].map(h => (
<th key={h} style={{ padding: '10px 16px', fontSize: '0.68rem', fontWeight: 700, color: '#6B7280', letterSpacing: '0.04em', textTransform: 'uppercase', textAlign: 'left', whiteSpace: 'nowrap' }}>{h}</th> <th key={h} style={{ padding: '10px 16px', fontSize: '0.68rem', fontWeight: 700, color: '#6B7280', letterSpacing: '0.04em', textTransform: 'uppercase', textAlign: 'left', whiteSpace: 'nowrap' }}>{h}</th>
))} ))}
</tr> </tr>
@ -232,6 +336,8 @@ export const InventoryPage: React.FC = () => {
const stk = stockValues[item.id] ?? item.stock; const stk = stockValues[item.id] ?? item.stock;
const health = (item as { health?: string }).health || healthFromStock(stk); const health = (item as { health?: string }).health || healthFromStock(stk);
const hConf = HEALTH_CONFIG[health] || HEALTH_CONFIG['Healthy']; const hConf = HEALTH_CONFIG[health] || HEALTH_CONFIG['Healthy'];
const SrcIcon = item.sourceInfo.icon;
return ( return (
<tr key={item.id} style={{ borderBottom: idx < paginated.length - 1 ? '1px solid #F3F4F6' : 'none' }} <tr key={item.id} style={{ borderBottom: idx < paginated.length - 1 ? '1px solid #F3F4F6' : 'none' }}
onMouseEnter={e => (e.currentTarget.style.backgroundColor = '#FAFAFA')} onMouseEnter={e => (e.currentTarget.style.backgroundColor = '#FAFAFA')}
@ -239,16 +345,33 @@ export const InventoryPage: React.FC = () => {
> >
<td style={{ padding: '14px 16px', verticalAlign: 'middle', minWidth: '260px' }}> <td style={{ padding: '14px 16px', verticalAlign: 'middle', minWidth: '260px' }}>
<div className="is-flex is-align-items-center" style={{ gap: '12px' }}> <div className="is-flex is-align-items-center" style={{ gap: '12px' }}>
<img src={item.image} alt={item.title} <img src={item.image || '/images/placeholder.jpg'} alt={item.title}
style={{ width: '48px', height: '48px', borderRadius: '10px', objectFit: 'cover', border: '1px solid #E5E7EB', flexShrink: 0 }} style={{ width: '48px', height: '48px', borderRadius: '10px', objectFit: 'cover', border: '1px solid #E5E7EB', flexShrink: 0 }}
onError={e => { (e.target as HTMLImageElement).src = '/terracotta_vases.jpg'; }} onError={e => { (e.target as HTMLImageElement).src = 'https://images.unsplash.com/photo-1544022613-e87ca75a784a?auto=format&fit=crop&q=80&w=200'; }}
/> />
<div> <div>
<div style={{ fontSize: '0.86rem', fontWeight: 700, color: '#111827', lineHeight: 1.3 }}>{item.title}</div> <div style={{ fontSize: '0.86rem', fontWeight: 700, color: '#111827', lineHeight: 1.3 }}>{item.title}</div>
<div style={{ fontSize: '0.72rem', color: '#6B7280', marginTop: '2px' }}> <div style={{ fontSize: '0.72rem', color: '#6B7280', marginTop: '2px' }}>
{(item as { subtitle?: string }).subtitle || item.craftTechnique} {(item as { subtitle?: string }).subtitle || item.craftTechnique}
</div> </div>
<div style={{ fontSize: '0.68rem', fontWeight: 700, color: '#701A29', marginTop: '3px' }}>{item.sku}</div> <div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '3px' }}>
<span style={{ fontSize: '0.68rem', fontWeight: 700, color: '#701A29' }}>{item.sku}</span>
<span style={{
fontSize: '0.62rem',
fontWeight: 700,
backgroundColor: item.sourceInfo.bg,
color: item.sourceInfo.color,
border: `1px solid ${item.sourceInfo.border}`,
borderRadius: '4px',
padding: '1px 5px',
display: 'inline-flex',
alignItems: 'center',
gap: '3px'
}}>
<SrcIcon size={9} />
{item.sourceInfo.label}
</span>
</div>
</div> </div>
</div> </div>
</td> </td>
@ -265,8 +388,12 @@ export const InventoryPage: React.FC = () => {
<td style={{ padding: '14px 16px', verticalAlign: 'middle', minWidth: '120px' }}> <td style={{ padding: '14px 16px', verticalAlign: 'middle', minWidth: '120px' }}>
<div style={{ fontSize: '1rem', fontWeight: 800, color: '#111827' }}>₹{item.price.toLocaleString('en-IN')}</div> <div style={{ fontSize: '1rem', fontWeight: 800, color: '#111827' }}>₹{item.price.toLocaleString('en-IN')}</div>
<div style={{ fontSize: '0.72rem', color: '#9CA3AF', textDecoration: 'line-through', marginTop: '2px' }}>MRP ₹{item.mrp.toLocaleString('en-IN')}</div> {item.mrp && item.mrp > item.price && (
<div style={{ fontSize: '0.68rem', color: '#16A34A', fontWeight: 700, marginTop: '2px' }}>{Math.round(((item.mrp - item.price) / item.mrp) * 100)}% off</div> <div style={{ fontSize: '0.72rem', color: '#9CA3AF', textDecoration: 'line-through', marginTop: '2px' }}>MRP ₹{item.mrp.toLocaleString('en-IN')}</div>
)}
{item.mrp && item.mrp > item.price && (
<div style={{ fontSize: '0.68rem', color: '#16A34A', fontWeight: 700, marginTop: '2px' }}>{Math.round(((item.mrp - item.price) / item.mrp) * 100)}% off</div>
)}
</td> </td>
<td style={{ padding: '14px 16px', verticalAlign: 'middle', minWidth: '140px' }}> <td style={{ padding: '14px 16px', verticalAlign: 'middle', minWidth: '140px' }}>
@ -282,41 +409,61 @@ export const InventoryPage: React.FC = () => {
<Plus size={12} color="#374151" /> <Plus size={12} color="#374151" />
</button> </button>
</div> </div>
<div style={{ fontSize: '0.68rem', color: '#9CA3AF', marginTop: '4px' }}>{item.salesCount} sold all-time</div>
</td> </td>
<td style={{ padding: '14px 16px', verticalAlign: 'middle', minWidth: '120px' }}> <td style={{ padding: '14px 16px', verticalAlign: 'middle', minWidth: '130px' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '5px', backgroundColor: hConf.bg, color: hConf.color, fontSize: '0.72rem', fontWeight: 700, borderRadius: '9999px', padding: '4px 10px' }}> <span className="is-inline-flex is-align-items-center" style={{
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: hConf.dot, flexShrink: 0 }} /> fontSize: '0.7rem',
{hConf.label} fontWeight: 700,
padding: '4px 10px',
borderRadius: '9999px',
backgroundColor: item.approvalStatus === 'approved' ? '#DCFCE7' : item.approvalStatus === 'rejected' ? '#FEE2E2' : '#FEF3C7',
color: item.approvalStatus === 'approved' ? '#166534' : item.approvalStatus === 'rejected' ? '#991B1B' : '#92400E',
gap: '4px'
}}>
<CheckCircle2 size={12} />
<span>{item.approvalStatus === 'approved' ? 'Approved' : item.approvalStatus === 'rejected' ? 'Rejected' : 'Pending'}</span>
</span> </span>
</td> </td>
<td style={{ padding: '14px 16px', verticalAlign: 'middle', minWidth: '80px' }}> <td style={{ padding: '14px 16px', verticalAlign: 'middle', minWidth: '120px' }}>
<div className="is-flex" style={{ gap: '4px' }}> <span className="is-inline-flex is-align-items-center" style={{
<button type="button" title="Quick Edit SKU" onClick={() => setEditingItem(item)} fontSize: '0.7rem',
style={{ width: '30px', height: '30px', borderRadius: '8px', border: '1px solid #E5E7EB', backgroundColor: '#FFFFFF', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 0.2s' }} fontWeight: 700,
onMouseOver={e => e.currentTarget.style.backgroundColor = '#F3F4F6'} backgroundColor: hConf.bg,
onMouseOut={e => e.currentTarget.style.backgroundColor = '#FFFFFF'} color: hConf.color,
padding: '4px 10px',
borderRadius: '9999px',
gap: '5px'
}}>
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: hConf.dot }}></span>
<span>{hConf.label}</span>
</span>
</td>
<td style={{ padding: '14px 16px', verticalAlign: 'middle', textAlign: 'right' }}>
<div className="is-flex is-align-items-center is-justify-content-end" style={{ gap: '6px' }}>
<button type="button" onClick={() => { setEditingItem(item); setEditPrice(item.price); }}
className="button is-small" style={{ borderRadius: '6px', height: '28px', padding: '0 8px', border: '1px solid #D1D5DB', backgroundColor: '#FFFFFF', color: '#374151' }}
> >
<Edit3 size={13} color="#6B7280" /> <Edit3 size={12} />
</button> </button>
<button type="button" onClick={() => deleteProduct(item.id)} title="Delete SKU" <button type="button" onClick={() => handleDeleteProduct(item.id, item.title)}
style={{ width: '30px', height: '30px', borderRadius: '8px', border: '1px solid #FFE4E6', backgroundColor: '#FFF5F5', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'all 0.2s' }} className="button is-small" style={{ borderRadius: '6px', height: '28px', padding: '0 8px', border: '1px solid #FCA5A5', backgroundColor: '#FFFFFF', color: '#DC2626' }}
onMouseOver={e => e.currentTarget.style.backgroundColor = '#FEE2E2'}
onMouseOut={e => e.currentTarget.style.backgroundColor = '#FFF5F5'}
> >
<Trash2 size={13} color="#DC2626" /> <Trash2 size={12} />
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>
); );
}) : ( }) : (
<tr> <tr>
<td colSpan={6} style={{ padding: '3rem', textAlign: 'center' }}> <td colSpan={7} style={{ padding: '48px 24px', textAlign: 'center', color: '#6B7280' }}>
<AlertCircle size={32} color="#D1D5DB" style={{ marginBottom: '8px' }} /> <AlertTriangle size={24} color="#9CA3AF" style={{ margin: '0 auto 8px', display: 'block' }} />
<p style={{ fontSize: '0.88rem', color: '#6B7280' }}>No matching craft SKUs found.</p> <p style={{ fontSize: '0.86rem', fontWeight: 600, color: '#374151' }}>No matching craft SKUs found.</p>
<p style={{ fontSize: '0.78rem', color: '#9CA3AF', marginTop: '4px' }}>Try resetting your search query or filter settings above.</p>
</td> </td>
</tr> </tr>
)} )}
@ -324,117 +471,32 @@ export const InventoryPage: React.FC = () => {
</table> </table>
</div> </div>
{/* Pagination Strip */} {/* Pagination Footer */}
<div className="px-5 py-3 is-flex is-align-items-center is-justify-content-space-between" style={{ borderTop: '1px solid #F3F4F6', backgroundColor: '#FAFAFA', flexWrap: 'wrap', gap: '0.5rem' }}> {filtered.length > 0 && (
<span style={{ fontSize: '0.76rem', color: '#6B7280' }}> <div className="px-5 py-3 is-flex is-align-items-center is-justify-content-space-between" style={{ borderTop: '1px solid #F3F4F6', fontSize: '0.78rem', color: '#6B7280' }}>
Showing <strong style={{ color: '#111827' }}>{Math.min(filtered.length, currentPage * PAGE_SIZE)}</strong> of{' '} <div>
<strong style={{ color: '#111827' }}>{filtered.length > 0 ? filtered.length : allSkus.length} Handcrafted SKUs</strong> · All GI Tags Verified Showing <strong style={{ color: '#111827' }}>{paginated.length}</strong> of <strong style={{ color: '#111827' }}>{filtered.length}</strong> Handcrafted SKUs
</span> </div>
<div className="is-flex is-align-items-center" style={{ gap: '4px' }}> <div className="is-flex is-align-items-center" style={{ gap: '6px' }}>
<button type="button" onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} <button type="button" onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1}
style={{ width: '28px', height: '28px', borderRadius: '6px', border: '1px solid #E5E7EB', backgroundColor: '#FFFFFF', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: currentPage === 1 ? 'not-allowed' : 'pointer', opacity: currentPage === 1 ? 0.4 : 1 }}> className="button is-small" style={{ borderRadius: '6px', height: '28px', width: '28px', padding: 0, opacity: currentPage === 1 ? 0.4 : 1 }}
<ChevronLeft size={14} color="#374151" /> >
</button> <ChevronLeft size={14} />
{Array.from({ length: totalPages }, (_, i) => i + 1).map(pg => (
<button key={pg} type="button" onClick={() => setCurrentPage(pg)}
style={{ minWidth: '28px', height: '28px', borderRadius: '6px', border: `1px solid ${pg === currentPage ? '#701A29' : '#E5E7EB'}`, backgroundColor: pg === currentPage ? '#701A29' : '#FFFFFF', color: pg === currentPage ? '#FFFFFF' : '#374151', fontSize: '0.78rem', fontWeight: 600, cursor: 'pointer', padding: '0 6px' }}>
{pg}
</button> </button>
))} <span style={{ fontWeight: 700, color: '#701A29', backgroundColor: '#FFF0F2', width: '28px', height: '28px', display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '6px' }}>
<button type="button" onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} {currentPage}
style={{ width: '28px', height: '28px', borderRadius: '6px', border: '1px solid #E5E7EB', backgroundColor: '#FFFFFF', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: currentPage === totalPages ? 'not-allowed' : 'pointer', opacity: currentPage === totalPages ? 0.4 : 1 }}> </span>
<ChevronRight size={14} color="#374151" /> <button type="button" onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages}
</button> className="button is-small" style={{ borderRadius: '6px', height: '28px', width: '28px', padding: 0, opacity: currentPage === totalPages ? 0.4 : 1 }}
>
<ChevronRight size={14} />
</button>
</div>
</div> </div>
</div> )}
</div> </div>
{/* Quick Edit Drawer */}
{editingItem && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, zIndex: 100, display: 'flex', justifyContent: 'flex-end' }}>
{/* Backdrop */}
<div
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(255, 255, 255, 0.7)', backdropFilter: 'blur(3px)' }}
onClick={() => setEditingItem(null)}
/>
{/* Drawer Panel */}
<div style={{ position: 'relative', width: '440px', backgroundColor: '#FFFFFF', height: '100%', display: 'flex', flexDirection: 'column', boxShadow: '-4px 0 24px rgba(0,0,0,0.06)' }}>
{/* Drawer Header */}
<div style={{ padding: '24px 32px', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<div>
<h2 style={{ fontSize: '1.25rem', fontWeight: 800, color: '#111827' }}>Quick Edit SKU</h2>
<div style={{ fontSize: '0.8rem', color: '#6B7280', marginTop: '2px' }}>SKU: {editingItem.sku}</div>
</div>
<button type="button" onClick={() => setEditingItem(null)} style={{ width: '32px', height: '32px', borderRadius: '50%', backgroundColor: '#F3F4F6', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#374151" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
{/* Drawer Content Scrollable */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 32px 32px 32px', display: 'flex', flexDirection: 'column', gap: '20px' }}>
{/* Product Info Card */}
<div style={{ backgroundColor: '#FAF7F7', borderRadius: '12px', padding: '16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<img src={editingItem.image} alt="Product" style={{ width: '48px', height: '48px', borderRadius: '8px', objectFit: 'cover' }} onError={e => { (e.target as HTMLImageElement).src = '/terracotta_vases.jpg'; }} />
<div>
<div style={{ fontSize: '0.9rem', fontWeight: 700, color: '#111827', lineHeight: 1.2 }}>{editingItem.title}</div>
<div style={{ fontSize: '0.75rem', color: '#6B7280', marginTop: '2px' }}>{editingItem.category} · Handcrafted</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '4px' }}>
{/* Toggle Switch */}
<div style={{ width: '40px', height: '22px', backgroundColor: '#701A29', borderRadius: '11px', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', padding: '2px', cursor: 'pointer' }}>
<div style={{ width: '18px', height: '18px', backgroundColor: '#FFFFFF', borderRadius: '50%' }} />
</div>
<div style={{ fontSize: '0.65rem', fontWeight: 700, color: '#166534' }}>Live on Storefront</div>
</div>
</div>
{/* Selling Price Card */}
<div style={{ backgroundColor: '#FAF7F7', borderRadius: '12px', padding: '16px' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, color: '#374151', marginBottom: '8px' }}>Selling Price</label>
<div style={{ position: 'relative' }}>
<span style={{ position: 'absolute', left: '16px', top: '50%', transform: 'translateY(-50%)', fontWeight: 600, color: '#374151' }}>₹</span>
<input type="number" defaultValue={editingItem.price} style={{ width: '100%', border: 'none', borderRadius: '4px', padding: '12px 16px 12px 32px', fontSize: '1rem', fontWeight: 700, color: '#111827', outline: 'none' }} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '12px', fontSize: '0.75rem' }}>
<span style={{ color: '#6B7280' }}>Artisan Payout (100% retained)</span>
<span style={{ fontWeight: 700, color: '#701A29', fontSize: '0.9rem' }}>₹{editingItem.price}</span>
</div>
</div>
{/* Stock on Hand Card */}
<div style={{ backgroundColor: '#FAF7F7', borderRadius: '12px', padding: '16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: '#374151' }}>Stock on Hand</div>
<div style={{ fontSize: '0.75rem', color: '#6B7280', marginTop: '2px' }}>Finished sets ready for dispatch</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', backgroundColor: '#FFFFFF', borderRadius: '4px', border: 'none', overflow: 'hidden' }}>
<button type="button" onClick={() => handleStepStock(editingItem.id, -1)} style={{ width: '32px', height: '36px', display: 'flex', alignItems: 'center', justifyContent: 'center', border: 'none', backgroundColor: '#FFFFFF', cursor: 'pointer', fontSize: '1.2rem', color: '#374151' }}>−</button>
<div style={{ width: '36px', height: '36px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.9rem', fontWeight: 700, color: '#111827' }}>{(stockValues[editingItem.id] ?? editingItem.stock)}</div>
<button type="button" onClick={() => handleStepStock(editingItem.id, 1)} style={{ width: '32px', height: '36px', display: 'flex', alignItems: 'center', justifyContent: 'center', border: 'none', backgroundColor: '#FFFFFF', cursor: 'pointer', fontSize: '1.2rem', color: '#374151' }}>+</button>
</div>
</div>
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: '#374151', paddingLeft: '4px' }}>Lead Time / Dispatch Readiness</div>
</div>
{/* Drawer Footer */}
<div style={{ padding: '24px 32px', display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<button type="button" onClick={() => setEditingItem(null)} style={{ padding: '10px 24px', borderRadius: '9999px', border: 'none', backgroundColor: '#F3F4F6', color: '#374151', fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer' }}>Cancel</button>
<button type="button" onClick={() => setEditingItem(null)} style={{ padding: '10px 24px', borderRadius: '9999px', border: 'none', backgroundColor: '#701A29', color: '#FFFFFF', fontSize: '0.85rem', fontWeight: 700, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
Save Changes
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };

View file

@ -1,4 +1,4 @@
const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api'; const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://betapartner-api.tradhox.com/api';
const getAuthHeaders = (isMultipart = false) => { const getAuthHeaders = (isMultipart = false) => {
const userStr = localStorage.getItem('tradhox_user_auth'); const userStr = localStorage.getItem('tradhox_user_auth');
@ -20,12 +20,41 @@ const getAuthHeaders = (isMultipart = false) => {
}; };
export const api = { export const api = {
// S3 Presigned Uploads
getPresignedUrl: async (fileType: string, contentType: string = 'image/jpeg', productId?: string) => {
const res = await fetch(`${BASE_URL}/profile/presigned-url/`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ file_type: fileType, content_type: contentType, product_id: productId })
});
if (!res.ok) throw new Error('Failed to get presigned upload URL');
return res.json();
},
uploadToS3: async (uploadUrl: string, file: File) => {
const res = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type || 'image/jpeg' },
body: file
});
if (!res.ok) throw new Error('Failed to upload file to S3');
return res;
},
// Auth // Auth
login: async (username: string, password?: string) => { login: async (username: string, password?: string, otpCode?: string, otpType?: string) => {
const payload: any = {
username,
otp_code: otpCode,
otp_type: otpType
};
if (password) {
payload.password = password;
}
const res = await fetch(`${BASE_URL}/auth/login/`, { const res = await fetch(`${BASE_URL}/auth/login/`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password: password || 'Password123!' }) body: JSON.stringify(payload)
}); });
if (!res.ok) { if (!res.ok) {
const errData = await res.json().catch(() => ({})); const errData = await res.json().catch(() => ({}));
@ -45,6 +74,7 @@ export const api = {
const errData = await res.json().catch(() => ({})); const errData = await res.json().catch(() => ({}));
let msg = 'Registration failed'; let msg = 'Registration failed';
if (errData.detail) msg = errData.detail; if (errData.detail) msg = errData.detail;
else if (errData.error) msg = errData.error;
else if (errData.message) msg = errData.message; else if (errData.message) msg = errData.message;
else if (typeof errData === 'object') { else if (typeof errData === 'object') {
const firstKey = Object.keys(errData)[0]; const firstKey = Object.keys(errData)[0];
@ -58,6 +88,37 @@ export const api = {
return await res.json(); return await res.json();
}, },
sendEmailOtp: async (email: string, otpCode?: string, templateName = 'verification') => {
const url = `${BASE_URL}/auth/send-email-otp/`;
try {
const payload: any = { email, template_name: templateName };
if (otpCode) payload.otp_code = otpCode;
let res = await fetch(url, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(payload)
});
if (res.status === 401) {
res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || data.detail || 'Your account status is inactive. Please contact Tradhox support for assistance.');
}
return data;
} catch (err) {
console.warn('Email service call notice:', err);
throw err;
}
},
sendWhatsAppOtp: async (mobileNumber: string, otpCode: string, templateName = 'mobile_number_verification') => { sendWhatsAppOtp: async (mobileNumber: string, otpCode: string, templateName = 'mobile_number_verification') => {
const cleanDigits = mobileNumber.replace(/\D/g, ''); const cleanDigits = mobileNumber.replace(/\D/g, '');
const formattedMobile = cleanDigits.startsWith('91') ? cleanDigits : `91${cleanDigits}`; const formattedMobile = cleanDigits.startsWith('91') ? cleanDigits : `91${cleanDigits}`;
@ -72,14 +133,14 @@ export const api = {
template_name: templateName template_name: templateName
}) })
}); });
const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
const errData = await res.json().catch(() => ({})); throw new Error(data.error || data.detail || data.message || 'Failed to dispatch OTP. Please try again.');
console.warn('WhatsApp API response notice:', errData);
} }
return await res.json().catch(() => ({ status: 'success' })); return data;
} catch (err) { } catch (err) {
console.warn('WhatsApp service network boundary notice:', err); console.warn('WhatsApp service call notice:', err);
return { status: 'success', message: 'WhatsApp message dispatched' }; throw err;
} }
}, },
@ -92,18 +153,49 @@ export const api = {
return res.json(); return res.json();
}, },
verifyOtp: async (type: string, otp: string) => { verifyOtp: async (type: string, otp: string, phoneOrEmail?: string) => {
const res = await fetch(`${BASE_URL}/auth/verify-otp/`, { const url = `${BASE_URL}/auth/verify-otp/`;
const payload = {
type,
otp,
phone: type === 'phone' ? phoneOrEmail : undefined,
email: type === 'email' ? phoneOrEmail : undefined
};
let res = await fetch(url, {
method: 'POST', method: 'POST',
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify({ type, otp }) body: JSON.stringify(payload)
}); });
if (res.status === 401) {
localStorage.removeItem('tradhox_user_auth');
res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
if (!res.ok) { if (!res.ok) {
const errData = await res.json().catch(() => ({})); const errData = await res.json().catch(() => ({}));
const msg = errData.error || errData.message || errData.detail || 'Failed to verify OTP'; const msg = errData.error || errData.message || errData.detail || 'Failed to verify OTP';
throw new Error(msg); throw new Error(msg);
} }
return res.json(); const data = await res.json();
if (data.access_token && data.user) {
localStorage.setItem('tradhox_user_auth', JSON.stringify(data));
localStorage.setItem('tradhox_user', JSON.stringify({
id: String(data.user.id),
name: data.user.first_name ? `${data.user.first_name} ${data.user.last_name || ''}`.trim() : data.user.username,
email: data.user.email,
phone: data.user.profile?.phone || (type === 'phone' ? phoneOrEmail : ''),
role: 'Seller',
avatar: data.user.profile?.logo_url || '/images/avatars/seller-main.jpg',
storeName: data.user.profile?.store_name || 'My Store'
}));
}
return data;
}, },
// Profile & Settings // Profile & Settings
@ -149,7 +241,11 @@ export const api = {
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify(productData) body: JSON.stringify(productData)
}); });
if (!res.ok) throw new Error('Failed to create product'); if (!res.ok) {
const errData = await res.json().catch(() => ({}));
const msg = errData.detail || errData.error || errData.message || `Failed to create product (${res.status})`;
throw new Error(msg);
}
return res.json(); return res.json();
}, },
@ -163,6 +259,19 @@ export const api = {
return res.json(); return res.json();
}, },
patchProduct: async (id: string | number, productData: any) => {
const res = await fetch(`${BASE_URL}/products/${id}/`, {
method: 'PATCH',
headers: getAuthHeaders(),
body: JSON.stringify(productData)
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.detail || errData.error || 'Failed to update product');
}
return res.json();
},
deleteProduct: async (id: string | number) => { deleteProduct: async (id: string | number) => {
const res = await fetch(`${BASE_URL}/products/${id}/`, { const res = await fetch(`${BASE_URL}/products/${id}/`, {
method: 'DELETE', method: 'DELETE',
@ -181,11 +290,29 @@ export const api = {
}); });
if (!res.ok) { if (!res.ok) {
const errData = await res.json().catch(() => ({})); const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || errData.message || 'Bulk upload failed'); const err: any = new Error(errData.error || errData.message || 'Bulk upload failed');
err.response = errData;
throw err;
} }
return res.json(); return res.json();
}, },
downloadBulkUploadErrorReport: async (logId: string) => {
const res = await fetch(`${BASE_URL}/products/bulk-upload/error-report/?log_id=${encodeURIComponent(logId)}`, {
headers: getAuthHeaders()
});
if (!res.ok) throw new Error('Failed to download error report');
return res.blob();
},
getBulkUploadLogs: async () => {
const res = await fetch(`${BASE_URL}/products/bulk-upload/logs/`, {
headers: getAuthHeaders()
});
if (!res.ok) throw new Error('Failed to fetch bulk upload logs');
return res.json();
},
// Orders & Forward Shipping // Orders & Forward Shipping
getOrders: async () => { getOrders: async () => {
const res = await fetch(`${BASE_URL}/orders/`, { headers: getAuthHeaders() }); const res = await fetch(`${BASE_URL}/orders/`, { headers: getAuthHeaders() });
@ -341,4 +468,4 @@ export const api = {
} }
return res.json(); return res.json();
} }
}; };

View file

@ -14,6 +14,7 @@ export interface OnboardingData {
email: string; email: string;
phone: string; phone: string;
isOtpVerified: boolean; isOtpVerified: boolean;
isEmailVerified?: boolean;
// Step 2: GST / Enrolment ID // Step 2: GST / Enrolment ID
taxMode: 'gstin' | 'enrolment'; taxMode: 'gstin' | 'enrolment';
@ -69,8 +70,19 @@ export interface InventoryItem {
mrp: number; mrp: number;
stock: number; stock: number;
status: 'active' | 'low_stock' | 'out_of_stock'; status: 'active' | 'low_stock' | 'out_of_stock';
approvalStatus?: 'approved' | 'pending' | 'rejected';
image: string; image: string;
salesCount: number; salesCount: number;
description?: string;
additional_images?: string[];
video_url?: string;
compare_at_price?: number;
package_weight?: number;
shipping_profile?: string;
dimensions?: string;
materials?: string;
care_instructions?: string;
tags?: string[];
} }
export interface OrderItem { export interface OrderItem {

27
src/utils/date.ts Normal file
View file

@ -0,0 +1,27 @@
/**
* Utility functions for formatting dates in Indian Standard Time (IST)
*/
export const formatISTDate = (date?: string | Date | number): string => {
if (!date) return '';
const d = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date;
return d.toLocaleDateString('en-IN', {
timeZone: 'Asia/Kolkata',
day: '2-digit',
month: 'short',
year: 'numeric'
});
};
export const formatISTDateTime = (date?: string | Date | number): string => {
if (!date) return '';
const d = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date;
return d.toLocaleString('en-IN', {
timeZone: 'Asia/Kolkata',
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
});
};