refactor: Improve CreateChannelFromURL and CreateShopFromURL components for better readability and maintainability

- Enhanced conditional checks and removed unnecessary console logs.
- Updated capitalization for "Openfront" in various components.
- Improved handling of URL parameters after successful channel/shop creation.
- Refactored badge components to use a consistent style across PlatformTabs, StatusTabs, and MatchesTabs.
- Added delete functionality with confirmation dialog in OrderDetailsComponent.
- Enhanced logging in createShop action for better debugging.
This commit is contained in:
Junaid
2025-09-01 13:05:59 -07:00
parent 39202b2192
commit d0757a25ac
23 changed files with 1857 additions and 659 deletions
+534 -217
View File
@@ -150,7 +150,7 @@ async function getProductFunction({
console.log("OpenFront Channel getProductFunction called with:", { platform: platform.domain, productId, variantId });
const openFrontClient = await createOpenFrontClient(platform);
const gqlQuery = import_graphql_request.gql`
query GetChannelProduct($productId: ID!, $variantId: ID) {
query GetChannelProduct($productId: ID!, $variantWhere: ProductVariantWhereInput) {
product(where: { id: $productId }) {
id
title
@@ -161,7 +161,7 @@ async function getProductFunction({
}
imagePath
}
productVariants(where: $variantId ? { id: { equals: $variantId } } : {}) {
productVariants(where: $variantWhere) {
id
title
sku
@@ -180,7 +180,7 @@ async function getProductFunction({
`;
const { product } = await openFrontClient.request(gqlQuery, {
productId,
variantId
variantWhere: variantId ? { id: { equals: variantId } } : {}
});
if (!product || product.status !== "published") {
throw new Error("Product not available for fulfillment from OpenFront");
@@ -211,91 +211,161 @@ async function createPurchaseFunction({
}) {
console.log(`\u{1F6D2} OpenFront Channel: Creating purchase with ${cartItems.length} items`);
console.log(`\u{1F69A} OpenFront Channel: Ship to: ${shipping?.firstName} ${shipping?.lastName}`);
console.log(`\u{1F4E6} OpenFront Channel: Full shipping data:`, JSON.stringify(shipping, null, 2));
console.log(`\u{1F4E6} OpenFront Channel: Platform data:`, JSON.stringify(platform, null, 2));
console.log(`\u{1F4E6} OpenFront Channel: Cart items:`, JSON.stringify(cartItems, null, 2));
const openFrontClient = await createOpenFrontClient(platform);
const purchaseId = `PO-OF-${Date.now()}`;
const orderNumber = `#${purchaseId}`;
const totalPrice = cartItems.reduce((sum, item) => {
return sum + parseFloat(item.price) * item.quantity;
}, 0);
const createOrderMutation = import_graphql_request.gql`
mutation CreateFulfillmentOrder($data: OrderCreateInput!) {
createOrder(data: $data) {
id
orderNumber
total
status
orderLineItems {
try {
const currencyCode = (shipping?.currency || "USD").toLowerCase();
const getRegionQuery = import_graphql_request.gql`
query GetRegion($currencyCode: String!) {
regions(where: { currency: { code: { equals: $currencyCode } } }) {
id
title
quantity
unitPrice
productVariant {
taxRate
currency {
id
title
sku
product {
code
}
}
}
`;
const { regions } = await openFrontClient.request(getRegionQuery, {
currencyCode
});
const region = regions[0];
if (!region) {
throw new Error(`No region found for currency: ${currencyCode}`);
}
console.log("\u{1F6D2} [OpenFront Channel] Creating cart with region:", region.id);
const { createCart: cart } = await openFrontClient.request(import_graphql_request.gql`
mutation CreateCart($data: CartCreateInput!) {
createCart(data: $data) {
id
region {
id
currency {
id
title
code
}
}
}
}
}
`;
try {
const orderData = {
orderNumber,
customerEmail: shipping?.email || "fulfillment@openship.org",
firstName: shipping?.firstName || "Fulfillment",
lastName: shipping?.lastName || "Order",
address1: shipping?.streetAddress1 || "",
address2: shipping?.streetAddress2 || "",
city: shipping?.city || "",
state: shipping?.state || "",
postalCode: shipping?.zip || "",
countryCode: shipping?.country || "US",
phone: shipping?.phone || "",
total: Math.round(totalPrice * 100),
// Convert to cents
subtotal: Math.round(totalPrice * 100),
status: "pending_fulfillment",
orderLineItems: {
create: cartItems.map((item) => ({
title: item.name || `Product ${item.variantId}`,
quantity: item.quantity,
unitPrice: Math.round(parseFloat(item.price) * 100),
// Convert to cents
productVariant: { connect: { id: item.variantId } }
}))
`, {
data: {
region: { connect: { id: region.id } },
email: shipping?.email || `order-${Date.now()}@openship.generated`
}
};
const result = await openFrontClient.request(createOrderMutation, {
data: orderData
});
const order = result.createOrder;
console.log(`\u{1F4E7} OpenFront Channel: Fulfillment Order Created: ${orderNumber}`);
console.log(`\u{1F4B0} OpenFront Channel: Total: $${totalPrice.toFixed(2)}`);
const processedLineItems = order.orderLineItems.map((item) => ({
id: item.id,
title: item.title,
console.log("\u2705 [OpenFront Channel] Cart created with ID:", cart.id);
const lineItemsToCreate = [];
for (const item of cartItems) {
lineItemsToCreate.push({
productVariant: { connect: { id: item.variantId } },
quantity: item.quantity
});
}
console.log("\u{1F4E6} [OpenFront Channel] Adding", lineItemsToCreate.length, "line items to cart");
await openFrontClient.request(import_graphql_request.gql`
mutation AddLineItemsToCart($cartId: ID!, $data: CartUpdateInput!) {
updateActiveCart(cartId: $cartId, data: $data) {
id
}
}
`, {
cartId: cart.id,
data: {
lineItems: {
create: lineItemsToCreate
}
}
});
console.log("\u2705 [OpenFront Channel] Line items added to cart");
console.log("\u{1F4CD} [OpenFront Channel] Creating shipping address");
const { createAddress: shippingAddr } = await openFrontClient.request(import_graphql_request.gql`
mutation CreateAddress($data: AddressCreateInput!) {
createAddress(data: $data) {
id
}
}
`, {
data: {
firstName: shipping?.firstName || "Guest",
lastName: shipping?.lastName || "Customer",
address1: shipping?.address1 || "123 Default St",
city: shipping?.city || "Default City",
province: shipping?.state || "NY",
postalCode: shipping?.zip || "10001",
phone: shipping?.phone || "",
country: {
connect: {
iso2: (shipping?.country || "US").toLowerCase()
}
}
}
});
console.log("\u2705 [OpenFront Channel] Shipping address created:", shippingAddr.id);
await openFrontClient.request(import_graphql_request.gql`
mutation UpdateCartAddresses($cartId: ID!, $data: CartUpdateInput!) {
updateActiveCart(cartId: $cartId, data: $data) {
id
}
}
`, {
cartId: cart.id,
data: {
shippingAddress: { connect: { id: shippingAddr.id } },
billingAddress: { connect: { id: shippingAddr.id } }
}
});
console.log("\u2705 [OpenFront Channel] Cart updated with addresses");
console.log("\u{1F3AF} [OpenFront Channel] Completing cart to create order");
console.log("\u{1F3AF} [OpenFront Channel] Calling completeActiveCart for cart:", cart.id);
const completeResult = await openFrontClient.request(import_graphql_request.gql`
mutation CompleteActiveCart($cartId: ID!) {
completeActiveCart(cartId: $cartId)
}
`, {
cartId: cart.id
});
console.log("\u{1F50D} [OpenFront Channel] completeActiveCart result:", JSON.stringify(completeResult, null, 2));
const order = completeResult.completeActiveCart;
if (!order?.id) {
throw new Error("Failed to complete cart - no order created");
}
console.log("\u{1F50D} [OpenFront Channel] COMPLETE ORDER RESULT:", JSON.stringify(order, null, 2));
console.log("\u{1F389} [OpenFront Channel] Order created successfully:", order.id);
const processedLineItems = cartItems.map((item) => ({
id: item.variantId,
title: item.name || `Product ${item.variantId}`,
quantity: item.quantity,
variantId: item.productVariant.id,
productId: item.productVariant.product.id
variantId: item.variantId
}));
return {
purchaseId: order.id,
orderNumber: order.orderNumber,
totalPrice: (order.total / 100).toFixed(2),
orderNumber: `#${order.displayId}`,
totalPrice: order.total,
invoiceUrl: `https://${platform.domain}/admin/orders/${order.id}`,
lineItems: processedLineItems,
status: "processing"
status: "pending"
};
} catch (error) {
console.error("OpenFront Channel: Purchase creation failed:", error);
console.error("\u{1F6A8} OpenFront Channel: Purchase creation failed:", error);
if (error.response?.errors) {
console.error("\u{1F6A8} GraphQL Errors:");
error.response.errors.forEach((err, i) => {
console.error(` ${i + 1}. ${err.message}`);
if (err.path) {
console.error(` Path: ${err.path.join(" -> ")}`);
}
if (err.extensions) {
console.error(` Extensions:`, err.extensions);
}
});
}
return {
purchaseId,
orderNumber,
totalPrice: totalPrice.toFixed(2),
purchaseId: null,
orderNumber: null,
totalPrice: "0.00",
invoiceUrl: null,
lineItems: cartItems.map((item) => ({
id: `error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
@@ -314,51 +384,123 @@ async function createWebhookFunction({
endpoint,
events
}) {
const openFrontClient = await createOpenFrontClient(platform);
const createWebhookMutation = import_graphql_request.gql`
mutation CreateChannelWebhookEndpoint($data: WebhookEndpointCreateInput!) {
createWebhookEndpoint(data: $data) {
id
url
events
isActive
secret
console.log("\u{1FA9D} [OpenFront Channel] createWebhookFunction called");
console.log("\u{1FA9D} [OpenFront Channel] Platform domain:", platform.domain);
console.log("\u{1FA9D} [OpenFront Channel] Endpoint URL:", endpoint);
console.log("\u{1FA9D} [OpenFront Channel] Events received:", events);
console.log("\u{1FA9D} [OpenFront Channel] Events type:", typeof events, "Array?", Array.isArray(events));
console.log("\u{1FA9D} [OpenFront Channel] Platform data:", JSON.stringify(platform, null, 2));
const eventMap = {
ORDER_CREATED: "order.created",
ORDER_CANCELLED: "order.cancelled",
TRACKING_CREATED: "fulfillment.created"
};
const openFrontEvents = events.map((event) => {
const mapped = eventMap[event] || event;
console.log(`\u{1FA9D} [OpenFront Channel] Mapping: ${event} -> ${mapped}`);
return mapped;
});
console.log("\u{1FA9D} [OpenFront Channel] Final mapped events array:", openFrontEvents);
console.log("\u{1FA9D} [OpenFront Channel] Joined events string:", openFrontEvents.join(", "));
try {
console.log("\u{1FA9D} [OpenFront Channel] Creating OpenFront GraphQL client...");
const openFrontClient = await createOpenFrontClient(platform);
console.log("\u2705 [OpenFront Channel] GraphQL client created successfully");
console.log("\u{1FA9D} [OpenFront Channel] Fetching current user...");
const getUserQuery = import_graphql_request.gql`
query GetCurrentUser {
authenticatedItem {
... on User {
id
email
orderWebhookUrl
}
}
}
`;
console.log("\u{1FA9D} [OpenFront Channel] Executing getUserQuery...");
const { authenticatedItem: user } = await openFrontClient.request(getUserQuery);
console.log("\u{1FA9D} [OpenFront Channel] getUserQuery result:", JSON.stringify(user, null, 2));
if (!user) {
console.error("\u{1F6A8} [OpenFront Channel] User not authenticated - authenticatedItem is null/undefined");
throw new Error("User not authenticated");
}
console.log("\u2705 [OpenFront Channel] User authenticated successfully");
console.log("\u{1FA9D} [OpenFront Channel] User ID:", user.id);
console.log("\u{1FA9D} [OpenFront Channel] User email:", user.email);
console.log("\u{1FA9D} [OpenFront Channel] Current webhook URL:", user.orderWebhookUrl);
console.log("\u{1FA9D} [OpenFront Channel] Updating user webhook URL...");
const updateUserMutation = import_graphql_request.gql`
mutation UpdateActiveUserWebhookUrl($data: UserUpdateProfileInput!) {
updateActiveUser(data: $data) {
id
orderWebhookUrl
}
}
`;
console.log("\u{1FA9D} [OpenFront Channel] Update mutation variables:");
console.log("\u{1FA9D} [OpenFront Channel] - User ID:", user.id);
console.log("\u{1FA9D} [OpenFront Channel] - New webhook URL:", endpoint);
const result = await openFrontClient.request(updateUserMutation, {
data: { orderWebhookUrl: endpoint }
});
console.log("\u{1FA9D} [OpenFront Channel] Update mutation result:", JSON.stringify(result, null, 2));
const updatedUser = result.updateActiveUser;
console.log("\u2705 [OpenFront Channel] User webhook URL updated successfully");
console.log("\u{1FA9D} [OpenFront Channel] Updated user ID:", updatedUser.id);
console.log("\u{1FA9D} [OpenFront Channel] Updated webhook URL:", updatedUser.orderWebhookUrl);
const webhookResponse = {
webhooks: [{
id: `user-${updatedUser.id}`,
callbackUrl: updatedUser.orderWebhookUrl,
topic: openFrontEvents.join(", "),
// Join array into comma-separated string
format: "JSON",
createdAt: (/* @__PURE__ */ new Date()).toISOString()
}],
webhookId: `user-${updatedUser.id}`
};
console.log("\u{1FA9D} [OpenFront Channel] Final webhook response:", JSON.stringify(webhookResponse, null, 2));
return webhookResponse;
} catch (error) {
console.error("\u{1F6A8} [OpenFront Channel] createWebhookFunction failed:", error);
console.error("\u{1F6A8} [OpenFront Channel] Error message:", error.message);
console.error("\u{1F6A8} [OpenFront Channel] Error stack:", error.stack);
if (error.response) {
console.error("\u{1F6A8} [OpenFront Channel] GraphQL response:", JSON.stringify(error.response, null, 2));
if (error.response.errors) {
console.error("\u{1F6A8} [OpenFront Channel] GraphQL errors:");
error.response.errors.forEach((err, i) => {
console.error(`\u{1F6A8} [OpenFront Channel] ${i + 1}. ${err.message}`);
if (err.path) {
console.error(`\u{1F6A8} [OpenFront Channel] Path: ${err.path.join(" -> ")}`);
}
if (err.extensions) {
console.error(`\u{1F6A8} [OpenFront Channel] Extensions:`, err.extensions);
}
});
}
}
`;
const eventMap = {
PURCHASE_CREATED: "order.created",
PURCHASE_SHIPPED: "fulfillment.shipped",
INVENTORY_UPDATED: "inventory.updated"
};
const openFrontEvents = events.map((event) => eventMap[event] || event);
const result = await openFrontClient.request(createWebhookMutation, {
data: {
url: endpoint,
events: openFrontEvents,
isActive: true
}
});
const webhook = result.createWebhookEndpoint;
return {
webhooks: [webhook],
webhookId: webhook.id
};
throw error;
}
}
async function deleteWebhookFunction({
platform,
webhookId
}) {
const openFrontClient = await createOpenFrontClient(platform);
const deleteWebhookMutation = import_graphql_request.gql`
mutation DeleteChannelWebhookEndpoint($where: WebhookEndpointWhereUniqueInput!) {
deleteWebhookEndpoint(where: $where) {
const userId = webhookId.replace("user-", "");
const updateUserMutation = import_graphql_request.gql`
mutation ClearUserWebhookUrl($where: UserWhereUniqueInput!, $data: UserUpdateInput!) {
updateUser(where: $where, data: $data) {
id
orderWebhookUrl
}
}
`;
const result = await openFrontClient.request(deleteWebhookMutation, {
where: { id: webhookId }
const result = await openFrontClient.request(updateUserMutation, {
where: { id: userId },
data: { orderWebhookUrl: null }
});
return result;
}
@@ -366,30 +508,34 @@ async function getWebhooksFunction({
platform
}) {
const openFrontClient = await createOpenFrontClient(platform);
const query = import_graphql_request.gql`
query GetChannelWebhookEndpoints {
webhookEndpoints(where: { isActive: { equals: true } }) {
id
url
events
isActive
createdAt
const eventMap = {
"order.created": "PURCHASE_CREATED",
"fulfillment.created": "PURCHASE_SHIPPED"
};
const getUserQuery = import_graphql_request.gql`
query GetCurrentUser {
authenticatedItem {
... on User {
id
email
orderWebhookUrl
createdAt
}
}
}
`;
const { webhookEndpoints } = await openFrontClient.request(query);
const eventMap = {
"order.created": "PURCHASE_CREATED",
"fulfillment.shipped": "PURCHASE_SHIPPED",
"inventory.updated": "INVENTORY_UPDATED"
};
const webhooks = webhookEndpoints.map((webhook) => ({
id: webhook.id,
callbackUrl: webhook.url,
topic: webhook.events.map((event) => eventMap[event] || event),
const { authenticatedItem: user } = await openFrontClient.request(getUserQuery);
if (!user || !user.orderWebhookUrl) {
return { webhooks: [] };
}
const webhooks = [{
id: `user-${user.id}`,
callbackUrl: user.orderWebhookUrl,
topic: ["PURCHASE_CREATED", "PURCHASE_SHIPPED"],
// These are the channel events we support
format: "JSON",
createdAt: webhook.createdAt
}));
createdAt: user.createdAt
}];
return { webhooks };
}
async function addTrackingFunction({
@@ -501,41 +647,119 @@ async function oAuthCallbackFunction({
console.error("OpenFront OAuth error:", errorText);
throw new Error(`Failed to exchange OAuth code for access token: ${response.statusText}`);
}
const { access_token } = await response.json();
return access_token;
const { access_token, refresh_token, expires_in } = await response.json();
return {
accessToken: access_token,
refreshToken: refresh_token,
expiresIn: expires_in,
tokenExpiresAt: new Date(Date.now() + expires_in * 1e3).toISOString()
};
}
function scopes() {
return REQUIRED_SCOPES;
}
var import_graphql_request, getFreshAccessToken, createOpenFrontClient, getProductImageUrl, REQUIRED_SCOPES;
var import_graphql_request, import_context, getFreshAccessToken, createOpenFrontClient, getProductImageUrl, REQUIRED_SCOPES;
var init_openfront = __esm({
"features/integrations/channel/openfront.ts"() {
"use strict";
import_graphql_request = require("graphql-request");
import_context = require("@/features/keystone/context");
getFreshAccessToken = async (platform) => {
if (platform.tokenExpiresAt && platform.refreshToken) {
const expiresAt = typeof platform.tokenExpiresAt === "string" ? new Date(platform.tokenExpiresAt) : platform.tokenExpiresAt;
if (expiresAt > /* @__PURE__ */ new Date()) {
console.log("\u{1F504} [OpenFront Channel] getFreshAccessToken called");
console.log("\u{1F504} [OpenFront Channel] Platform domain:", platform.domain);
console.log("\u{1F504} [OpenFront Channel] Actual access token:", platform.accessToken);
const channels = await import_context.keystoneContext.sudo().query.Channel.findMany({
where: {
domain: { equals: platform.domain },
accessToken: { equals: platform.accessToken }
},
query: "id refreshToken tokenExpiresAt platform { appKey appSecret }"
});
if (!channels || channels.length === 0) {
console.log("\u26A0\uFE0F [OpenFront Channel] No matching channel found in database");
return platform.accessToken;
}
const channel = channels[0];
console.log("\u{1F504} [OpenFront Channel] Found channel:", channel.id);
console.log("\u{1F504} [OpenFront Channel] Has refresh token:", !!channel.refreshToken);
console.log("\u{1F504} [OpenFront Channel] Token expires at:", channel.tokenExpiresAt);
console.log("\u{1F504} [OpenFront Channel] Has appKey:", !!channel.platform?.appKey);
console.log("\u{1F504} [OpenFront Channel] Has appSecret:", !!channel.platform?.appSecret);
console.log("\u{1F504} [OpenFront Channel] Actual refresh token:", channel.refreshToken);
if (channel.refreshToken) {
console.log("\u{1F504} [OpenFront Channel] Refresh token found, checking if refresh needed");
let shouldRefresh = false;
if (channel.tokenExpiresAt) {
const expiresAt = typeof channel.tokenExpiresAt === "string" ? new Date(channel.tokenExpiresAt) : channel.tokenExpiresAt;
const now = /* @__PURE__ */ new Date();
shouldRefresh = expiresAt <= now;
console.log("\u{1F504} [OpenFront Channel] Token expiry check:");
console.log("\u{1F504} [OpenFront Channel] - Expires at:", expiresAt.toISOString());
console.log("\u{1F504} [OpenFront Channel] - Current time:", now.toISOString());
console.log("\u{1F504} [OpenFront Channel] - Should refresh:", shouldRefresh);
} else {
shouldRefresh = true;
console.log("\u{1F504} [OpenFront Channel] No expiry info found, assuming refresh needed");
}
if (shouldRefresh) {
console.log("\u{1F504} [OpenFront Channel] Starting token refresh process...");
const tokenUrl = `${platform.domain}/api/oauth/token`;
console.log("\u{1F504} [OpenFront Channel] Token URL:", tokenUrl);
const formData = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: channel.refreshToken,
client_id: channel.platform?.appKey || "",
client_secret: channel.platform?.appSecret || ""
});
console.log("\u{1F504} [OpenFront Channel] Refresh request params:");
console.log("\u{1F504} [OpenFront Channel] - grant_type: refresh_token");
console.log("\u{1F504} [OpenFront Channel] - client_id:", channel.platform?.appKey || "NOT_SET");
console.log("\u{1F504} [OpenFront Channel] - client_secret:", channel.platform?.appSecret ? "SET" : "NOT_SET");
console.log("\u{1F504} [OpenFront Channel] - refresh_token:", channel.refreshToken ? "SET" : "NOT_SET");
console.log("\u{1F504} [OpenFront Channel] Making refresh token request...");
const response = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData
});
console.log("\u{1F504} [OpenFront Channel] Refresh response status:", response.status);
console.log("\u{1F504} [OpenFront Channel] Refresh response ok:", response.ok);
if (!response.ok) {
const errorText = await response.text();
console.error("\u{1F6A8} [OpenFront Channel] Token refresh failed:", errorText);
console.error("\u{1F6A8} [OpenFront Channel] Response status:", response.status);
console.error("\u{1F6A8} [OpenFront Channel] Response statusText:", response.statusText);
throw new Error(`Failed to refresh access token: ${response.statusText} - ${errorText}`);
}
const tokenData = await response.json();
console.log("\u{1F504} [OpenFront Channel] Token refresh response received");
console.log("\u{1F504} [OpenFront Channel] - Has access_token:", !!tokenData.access_token);
console.log("\u{1F504} [OpenFront Channel] - Has refresh_token:", !!tokenData.refresh_token);
console.log("\u{1F504} [OpenFront Channel] - Expires in:", tokenData.expires_in, "seconds");
const { access_token, refresh_token, expires_in } = tokenData;
console.log("\u{1F504} [OpenFront Channel] Updating tokens in database...");
try {
console.log("\u{1F504} [OpenFront Channel] Updating channel:", channel.id);
await import_context.keystoneContext.sudo().query.Channel.updateOne({
where: { id: channel.id },
data: {
accessToken: access_token,
...refresh_token && { refreshToken: refresh_token },
...expires_in && { tokenExpiresAt: new Date(Date.now() + expires_in * 1e3) }
}
});
console.log("\u2705 [OpenFront Channel] Channel updated with new tokens:", channel.id);
} catch (error) {
console.error("\u{1F6A8} [OpenFront Channel] Failed to update channel tokens in database:", error);
}
console.log("\u2705 [OpenFront Channel] Returning fresh access token");
return access_token;
} else {
console.log("\u2705 [OpenFront Channel] Token still valid, using existing access token");
return platform.accessToken;
}
const tokenUrl = `${platform.domain}/api/oauth/token`;
const formData = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: platform.refreshToken
});
const response = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData
});
if (!response.ok) {
const errorText = await response.text();
console.error("Token refresh failed:", errorText);
throw new Error(`Failed to refresh access token: ${response.statusText} - ${errorText}`);
}
const { access_token } = await response.json();
return access_token;
}
console.log("\u26A0\uFE0F [OpenFront Channel] No refresh token available, using existing access token");
return platform.accessToken;
};
createOpenFrontClient = async (platform) => {
@@ -1430,6 +1654,7 @@ async function searchOrdersFunction({
searchEntry,
after
}) {
console.log("fuckkk");
const openFrontClient = await createOpenFrontClient2(platform);
const gqlQuery = import_graphql_request3.gql`
query SearchOrders($where: OrderWhereInput, $take: Int, $skip: Int) {
@@ -1511,6 +1736,7 @@ async function searchOrdersFunction({
take,
skip
});
console.log("Orders from OpenFront:", orders);
const transformedOrders = orders.map((order) => {
const shippingAddress = order.shippingAddress || {};
return {
@@ -1531,22 +1757,28 @@ async function searchOrdersFunction({
financialStatus: order.status,
totalPrice: order.rawTotal ? (order.rawTotal / 100).toFixed(2) : "0.00",
currency: order.currency?.code || "USD",
lineItems: (order.lineItems || []).map((lineItem) => ({
lineItemId: lineItem.id,
name: lineItem.title,
quantity: lineItem.quantity,
image: getProductImageUrl2({ imagePath: lineItem.thumbnail, image: { url: lineItem.productVariant?.product?.thumbnail } }, platform.domain) || "",
price: lineItem.moneyAmount ? (lineItem.moneyAmount.amount / 100).toFixed(2) : "0.00",
variantId: lineItem.productVariant?.id || "",
productId: lineItem.productVariant?.product?.id || "",
sku: lineItem.sku || lineItem.productVariant?.sku || ""
})),
lineItems: (order.lineItems || []).map((lineItem) => {
const productTitle = lineItem.productVariant?.product?.title || "";
const variantTitle = lineItem.productVariant?.title || "";
const combinedTitle = productTitle && variantTitle ? `${productTitle} - ${variantTitle}` : lineItem.title;
return {
lineItemId: lineItem.id,
name: combinedTitle,
quantity: lineItem.quantity,
image: getProductImageUrl2({ imagePath: lineItem.thumbnail, image: { url: lineItem.productVariant?.product?.thumbnail } }, platform.domain) || "",
price: lineItem.moneyAmount ? (lineItem.moneyAmount.amount / 100).toFixed(2) : "0.00",
variantId: lineItem.productVariant?.id || "",
productId: lineItem.productVariant?.product?.id || "",
sku: lineItem.sku || lineItem.productVariant?.sku || ""
};
}),
cartItems: [],
fulfillments: [],
note: "",
cursor: Buffer.from((skip + orders.indexOf(order) + 1).toString()).toString("base64")
};
});
console.log("Transformed orders:", transformedOrders);
const hasNextPage = skip + take < ordersCount;
const endCursor = hasNextPage ? Buffer.from((skip + take).toString()).toString("base64") : null;
return {
@@ -1719,10 +1951,10 @@ async function oAuthCallbackFunction3({
}
const { access_token, refresh_token, expires_in } = await response.json();
return {
access_token,
refresh_token,
expires_in,
expires_at: new Date(Date.now() + expires_in * 1e3)
accessToken: access_token,
refreshToken: refresh_token,
expiresIn: expires_in,
tokenExpiresAt: new Date(Date.now() + expires_in * 1e3).toISOString()
};
}
async function createOrderWebhookHandler({
@@ -1730,41 +1962,51 @@ async function createOrderWebhookHandler({
event,
headers
}) {
const signature = headers["x-openfront-webhook-signature"];
const signature = headers["x-openfront-webhook-signature"] || headers["X-OpenFront-Webhook-Signature"];
if (!signature) {
console.error("Missing webhook signature. Available headers:", Object.keys(headers));
throw new Error("Missing webhook signature");
}
const lineItemsOutput = event.data?.orderLineItems?.map((item) => ({
name: item.title,
image: getProductImageUrl2(item.productVariant?.product?.productImages?.[0], platform.domain),
price: item.unitPrice ? item.unitPrice / 100 : 0,
// Convert from cents, handle null/undefined
quantity: item.quantity || 0,
productId: item.productVariant?.product?.id,
variantId: item.productVariant?.id,
sku: item.productVariant?.sku || "",
lineItemId: item.id
})) || [];
const lineItemsOutput = event.data?.lineItems?.map((item) => {
const productTitle = item.productVariant?.product?.title || "";
const variantTitle = item.productVariant?.title || "";
const combinedTitle = productTitle && variantTitle ? `${productTitle} - ${variantTitle}` : item.title;
return {
name: combinedTitle,
image: getProductImageUrl2(item.productVariant?.product?.productImages?.[0], platform.domain) || item.thumbnail,
price: item.moneyAmount?.amount ? item.moneyAmount.amount / 100 : 0,
// Convert from cents to float
quantity: item.quantity || 0,
productId: item.productVariant?.product?.id?.toString(),
variantId: item.productVariant?.id?.toString(),
sku: item.productVariant?.sku || item.sku || "",
lineItemId: item.id?.toString()
};
}) || [];
const orderData = event.data;
const shippingAddress = orderData.shippingAddress || {};
return {
orderId: orderData.id,
orderName: orderData.orderNumber,
email: orderData.customerEmail,
firstName: orderData.firstName,
lastName: orderData.lastName,
streetAddress1: orderData.address1,
streetAddress2: orderData.address2,
city: orderData.city,
state: orderData.state,
zip: orderData.postalCode,
country: orderData.countryCode,
phone: orderData.phone,
currency: orderData.currency?.code || "USD",
totalPrice: orderData.total ? orderData.total / 100 : 0,
// Convert from cents, handle null/undefined
subTotalPrice: orderData.subtotal || orderData.total ? (orderData.subtotal || orderData.total) / 100 : 0,
totalDiscounts: orderData.totalDiscounts ? orderData.totalDiscounts / 100 : 0,
totalTax: orderData.totalTax ? orderData.totalTax / 100 : 0,
orderId: orderData.id?.toString(),
orderName: orderData.displayId ? `#${orderData.displayId}` : "",
email: orderData.email || "",
firstName: shippingAddress.firstName || "",
lastName: shippingAddress.lastName || "",
streetAddress1: shippingAddress.address1 || "",
streetAddress2: shippingAddress.address2 || "",
city: shippingAddress.city || "",
state: shippingAddress.province || "",
zip: shippingAddress.postalCode || "",
country: shippingAddress.country?.iso2?.toUpperCase() || "",
phone: shippingAddress.phone || "",
currency: orderData.currency?.code?.toUpperCase() || "USD",
totalPrice: orderData.rawTotal ? orderData.rawTotal / 100 : 0,
// rawTotal is in cents, convert to float
subTotalPrice: parseFloat(orderData.subtotal?.replace(/[$,]/g, "") || "0"),
// Parse formatted string to float
totalDiscounts: parseFloat(orderData.discount?.replace(/[$,]/g, "") || "0"),
// Parse formatted string to float
totalTax: parseFloat(orderData.tax?.replace(/[$,]/g, "") || "0"),
// Parse formatted string to float
status: "INPROCESS",
linkOrder: true,
matchOrder: true,
@@ -1822,36 +2064,109 @@ async function addTrackingFunction2({
});
return result;
}
var import_graphql_request3, import_getBaseUrl, getFreshAccessToken2, createOpenFrontClient2, getProductImageUrl2, REQUIRED_SCOPES3;
var import_graphql_request3, import_getBaseUrl, import_context2, getFreshAccessToken2, createOpenFrontClient2, getProductImageUrl2, REQUIRED_SCOPES3;
var init_openfront2 = __esm({
"features/integrations/shop/openfront.ts"() {
"use strict";
import_graphql_request3 = require("graphql-request");
import_getBaseUrl = require("@/features/dashboard/lib/getBaseUrl");
import_context2 = require("@/features/keystone/context");
getFreshAccessToken2 = async (platform) => {
if (platform.tokenExpiresAt && platform.refreshToken) {
const expiresAt = typeof platform.tokenExpiresAt === "string" ? new Date(platform.tokenExpiresAt) : platform.tokenExpiresAt;
if (expiresAt > /* @__PURE__ */ new Date()) {
console.log("\u{1F504} [OpenFront Shop] getFreshAccessToken called");
console.log("\u{1F504} [OpenFront Shop] Platform domain:", platform.domain);
console.log("\u{1F504} [OpenFront Shop] Actual access token:", platform.accessToken);
const shops = await import_context2.keystoneContext.sudo().query.Shop.findMany({
where: {
domain: { equals: platform.domain },
accessToken: { equals: platform.accessToken }
},
query: "id refreshToken tokenExpiresAt platform { appKey appSecret }"
});
if (!shops || shops.length === 0) {
console.log("\u26A0\uFE0F [OpenFront Shop] No matching shop found in database");
return platform.accessToken;
}
const shop = shops[0];
console.log("\u{1F504} [OpenFront Shop] Found shop:", shop.id);
console.log("\u{1F504} [OpenFront Shop] Has refresh token:", !!shop.refreshToken);
console.log("\u{1F504} [OpenFront Shop] Token expires at:", shop.tokenExpiresAt);
console.log("\u{1F504} [OpenFront Shop] Has appKey:", !!shop.platform?.appKey);
console.log("\u{1F504} [OpenFront Shop] Has appSecret:", !!shop.platform?.appSecret);
console.log("\u{1F504} [OpenFront Shop] Actual refresh token:", shop.refreshToken);
if (shop.refreshToken) {
console.log("\u{1F504} [OpenFront Shop] Refresh token found, checking if refresh needed");
let shouldRefresh = false;
if (shop.tokenExpiresAt) {
const expiresAt = typeof shop.tokenExpiresAt === "string" ? new Date(shop.tokenExpiresAt) : shop.tokenExpiresAt;
const now = /* @__PURE__ */ new Date();
shouldRefresh = expiresAt <= now;
console.log("\u{1F504} [OpenFront Shop] Token expiry check:");
console.log("\u{1F504} [OpenFront Shop] - Expires at:", expiresAt.toISOString());
console.log("\u{1F504} [OpenFront Shop] - Current time:", now.toISOString());
console.log("\u{1F504} [OpenFront Shop] - Should refresh:", shouldRefresh);
} else {
shouldRefresh = true;
console.log("\u{1F504} [OpenFront Shop] No expiry info found, assuming refresh needed");
}
if (shouldRefresh) {
console.log("\u{1F504} [OpenFront Shop] Starting token refresh process...");
const tokenUrl = `${platform.domain}/api/oauth/token`;
console.log("\u{1F504} [OpenFront Shop] Token URL:", tokenUrl);
const formData = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: shop.refreshToken,
client_id: shop.platform?.appKey || "",
client_secret: shop.platform?.appSecret || ""
});
console.log("\u{1F504} [OpenFront Shop] Refresh request params:");
console.log("\u{1F504} [OpenFront Shop] - grant_type: refresh_token");
console.log("\u{1F504} [OpenFront Shop] - client_id:", shop.platform?.appKey || "NOT_SET");
console.log("\u{1F504} [OpenFront Shop] - client_secret:", shop.platform?.appSecret ? "SET" : "NOT_SET");
console.log("\u{1F504} [OpenFront Shop] - refresh_token:", shop.refreshToken ? "SET" : "NOT_SET");
console.log("\u{1F504} [OpenFront Shop] Making refresh token request...");
const response = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData
});
console.log("\u{1F504} [OpenFront Shop] Refresh response status:", response.status);
console.log("\u{1F504} [OpenFront Shop] Refresh response ok:", response.ok);
if (!response.ok) {
const errorText = await response.text();
console.error("\u{1F6A8} [OpenFront Shop] Token refresh failed:", errorText);
console.error("\u{1F6A8} [OpenFront Shop] Response status:", response.status);
console.error("\u{1F6A8} [OpenFront Shop] Response statusText:", response.statusText);
throw new Error(`Failed to refresh access token: ${response.statusText} - ${errorText}`);
}
const tokenData = await response.json();
console.log("\u{1F504} [OpenFront Shop] Token refresh response received");
console.log("\u{1F504} [OpenFront Shop] - Has access_token:", !!tokenData.access_token);
console.log("\u{1F504} [OpenFront Shop] - Has refresh_token:", !!tokenData.refresh_token);
console.log("\u{1F504} [OpenFront Shop] - Expires in:", tokenData.expires_in, "seconds");
const { access_token, refresh_token, expires_in } = tokenData;
console.log("\u{1F504} [OpenFront Shop] Updating tokens in database...");
try {
console.log("\u{1F504} [OpenFront Shop] Updating shop:", shop.id);
await import_context2.keystoneContext.sudo().query.Shop.updateOne({
where: { id: shop.id },
data: {
accessToken: access_token,
...refresh_token && { refreshToken: refresh_token },
...expires_in && { tokenExpiresAt: new Date(Date.now() + expires_in * 1e3) }
}
});
console.log("\u2705 [OpenFront Shop] Shop updated with new tokens:", shop.id);
} catch (error) {
console.error("\u{1F6A8} [OpenFront Shop] Failed to update shop tokens in database:", error);
}
console.log("\u2705 [OpenFront Shop] Returning fresh access token");
return access_token;
} else {
console.log("\u2705 [OpenFront Shop] Token still valid, using existing access token");
return platform.accessToken;
}
const tokenUrl = `${platform.domain}/api/oauth/token`;
const formData = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: platform.refreshToken
});
const response = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData
});
if (!response.ok) {
const errorText = await response.text();
console.error("Token refresh failed:", errorText);
throw new Error(`Failed to refresh access token: ${response.statusText} - ${errorText}`);
}
const { access_token } = await response.json();
return access_token;
}
console.log("\u26A0\uFE0F [OpenFront Shop] No refresh token available, using existing access token");
return platform.accessToken;
};
createOpenFrontClient2 = async (platform) => {
@@ -3721,6 +4036,7 @@ async function placeMultipleOrders({ ids, query }) {
zip,
country,
phone,
currency,
user,
shop,
orderId: shopOrderId,
@@ -3738,7 +4054,8 @@ async function placeMultipleOrders({ ids, query }) {
state,
zip,
country,
phone
phone,
currency
shop {
domain
accessToken
@@ -3826,7 +4143,8 @@ async function placeMultipleOrders({ ids, query }) {
zip,
country,
phone,
email: user.email
email: user.email,
currency
},
notes: ""
});
@@ -6103,8 +6421,8 @@ var ShopPlatform = (0, import_core18.list)({
label: "App Credentials",
description: "Adding these fields will enable this platform to be installed as an app by users",
fields: {
appKey: (0, import_fields17.text)({ validation: { isRequired: true } }),
appSecret: (0, import_fields17.text)({ validation: { isRequired: true } }),
appKey: (0, import_fields17.text)(),
appSecret: (0, import_fields17.text)(),
callbackUrl: (0, import_fields17.virtual)({
field: import_core18.graphql.field({
type: import_core18.graphql.String,
@@ -6204,8 +6522,8 @@ var ChannelPlatform = (0, import_core19.list)({
label: "App Credentials",
description: "Adding these fields will enable this platform to be installed as an app by users.",
fields: {
appKey: (0, import_fields18.text)({ validation: { isRequired: true } }),
appSecret: (0, import_fields18.text)({ validation: { isRequired: true } }),
appKey: (0, import_fields18.text)(),
appSecret: (0, import_fields18.text)(),
callbackUrl: (0, import_fields18.virtual)({
field: import_core19.graphql.field({
type: import_core19.graphql.String,
@@ -7363,7 +7681,6 @@ function statelessSessions({
async get({ context }) {
if (!context?.req) return;
const authHeader = context.req.headers.authorization;
console.log("\u{1F511} AUTH HEADER:", authHeader);
if (authHeader?.startsWith("Bearer ")) {
const accessToken = authHeader.replace("Bearer ", "");
console.log("\u{1F511} ACCESS TOKEN:", accessToken);
File diff suppressed because one or more lines are too long
+3 -12
View File
@@ -153,7 +153,7 @@ openship/
## Documentation
For comprehensive technical documentation, see [docs.openship.org/openship/ecommerce](https://docs.openship.org/openship/ecommerce) which covers:
For comprehensive technical documentation, see [docs.openship.org/docs/openship/ecommerce](https://docs.openship.org/docs/openship/ecommerce) which covers:
- Complete integration guides
- API reference and operations
- Custom shop and channel development
@@ -176,15 +176,6 @@ Openship is production-ready and can be deployed to:
- Webhook reliability and retry mechanisms
- Monitoring and alerting for failed orders
## Foundation
This project is built on [next-keystone-starter](https://github.com/junaid33/next-keystone-starter), which provides:
- Modern full-stack architecture
- KeystoneJS 6 integration with Next.js 15
- Authentication and session management
- Database setup with Prisma
- Development tooling and configuration
## Contributing
We welcome contributions! Please see our contributing guidelines for details on:
@@ -199,7 +190,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
## Support
- **Documentation**: Check our comprehensive documentation at [docs.openship.org/openship/ecommerce](https://docs.openship.org/openship/ecommerce)
- **Documentation**: Check our comprehensive documentation at [docs.openship.org/docs/openship/ecommerce](https://docs.openship.org/docs/openship/ecommerce)
- **Issues**: Report bugs and feature requests on GitHub Issues
- **Community**: Join our community discussions
- **Enterprise**: Contact us for enterprise support and custom integrations
@@ -207,4 +198,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
---
**Openship** - Order Routing Platform
Built with Next.js 15 and KeystoneJS 6 on [next-keystone-starter](https://github.com/junaid33/next-keystone-starter)
Built with Next.js 15 and KeystoneJS 6
+70 -8
View File
@@ -38,6 +38,9 @@ async function generateOAuthState(platformId: string, type: 'shop' | 'channel'):
export async function GET(request: NextRequest) {
try {
console.log('🚀 OAuth callback endpoint called');
console.log('🔍 Request URL:', request.url);
const { searchParams } = new URL(request.url);
// Get OAuth parameters
@@ -51,6 +54,16 @@ export async function GET(request: NextRequest) {
const autoCreate = searchParams.get('auto_create') === 'true';
const appName = searchParams.get('app_name');
const clientId = searchParams.get('client_id');
console.log('🔍 OAuth callback parameters:');
console.log(' - code:', code ? `${code.substring(0, 10)}...` : 'MISSING');
console.log(' - state:', state ? `${state.substring(0, 20)}...` : 'MISSING');
console.log(' - shop:', shop);
console.log(' - error:', error);
console.log(' - errorDescription:', errorDescription);
console.log(' - autoCreate:', autoCreate);
console.log(' - appName:', appName);
console.log(' - clientId:', clientId);
// Handle OAuth errors
if (error) {
@@ -168,14 +181,24 @@ export async function GET(request: NextRequest) {
redirectUri: `${await getBaseUrl()}/api/oauth/callback`
});
console.log('🔍 Token exchange result type:', typeof tokenResult);
console.log('🔍 Token exchange result:', typeof tokenResult === 'string' ? `${tokenResult.substring(0, 10)}...` : tokenResult);
// Handle both old string format and new object format
let accessToken, refreshToken, tokenExpiresAt;
if (typeof tokenResult === 'string') {
console.log('📝 Using legacy string token format');
accessToken = tokenResult;
} else {
accessToken = tokenResult.access_token;
refreshToken = tokenResult.refresh_token;
tokenExpiresAt = tokenResult.expires_at;
console.log('📝 Using new object token format');
// OpenFront returns camelCase field names
accessToken = tokenResult.accessToken;
refreshToken = tokenResult.refreshToken;
tokenExpiresAt = tokenResult.tokenExpiresAt;
console.log('🔑 Token details:');
console.log(' - accessToken present:', !!accessToken);
console.log(' - refreshToken present:', !!refreshToken);
console.log(' - tokenExpiresAt present:', !!tokenExpiresAt);
}
const baseUrl = await getBaseUrl();
@@ -185,6 +208,12 @@ export async function GET(request: NextRequest) {
const endpoint = redirectAppType === 'channel' ? 'channels' : 'shops';
const createParam = redirectAppType === 'channel' ? 'showCreateChannel' : 'showCreateShop';
console.log('🎯 Building redirect URL:');
console.log(' - baseUrl:', baseUrl);
console.log(' - redirectAppType:', redirectAppType);
console.log(' - endpoint:', endpoint);
console.log(' - createParam:', createParam);
const redirectUrl = new URL(`${baseUrl}/dashboard/platform/${endpoint}`);
redirectUrl.searchParams.set(createParam, 'true');
redirectUrl.searchParams.set('domain', shop ?? '');
@@ -193,13 +222,24 @@ export async function GET(request: NextRequest) {
redirectUrl.searchParams.set('client_secret', stateData.client_secret);
redirectUrl.searchParams.set('app_name', stateData.app_name);
redirectUrl.searchParams.set('adapter_slug', stateData.adapter_slug);
console.log('🔑 Adding optional tokens to redirect:');
if (refreshToken) {
console.log(' - Adding refreshToken to URL params');
redirectUrl.searchParams.set('refreshToken', refreshToken);
} else {
console.log(' - No refreshToken to add');
}
if (tokenExpiresAt) {
redirectUrl.searchParams.set('tokenExpiresAt', tokenExpiresAt.toISOString());
console.log(' - Adding tokenExpiresAt to URL params:', tokenExpiresAt);
// tokenExpiresAt is already a string from OpenFront adapter
redirectUrl.searchParams.set('tokenExpiresAt', tokenExpiresAt);
} else {
console.log(' - No tokenExpiresAt to add');
}
console.log('🎯 Final redirect URL:', redirectUrl.toString());
return NextResponse.redirect(redirectUrl.toString());
}
@@ -254,7 +294,7 @@ export async function GET(request: NextRequest) {
accessToken = tokenResult;
} else {
// New format - object with both tokens
accessToken = tokenResult.access_token;
accessToken = tokenResult.accessToken;
}
// Redirect to shops page with params
@@ -264,8 +304,12 @@ export async function GET(request: NextRequest) {
redirectUrl.searchParams.set('accessToken', accessToken);
if (typeof tokenResult === 'object') {
// Include additional token data for new implementations
redirectUrl.searchParams.set('refreshToken', tokenResult.refresh_token);
redirectUrl.searchParams.set('tokenExpiresAt', tokenResult.expires_at.toISOString());
if (tokenResult.refreshToken) {
redirectUrl.searchParams.set('refreshToken', tokenResult.refreshToken);
}
if (tokenResult.tokenExpiresAt) {
redirectUrl.searchParams.set('tokenExpiresAt', tokenResult.tokenExpiresAt);
}
}
redirectUrl.searchParams.set('domain', shop ?? '');
@@ -288,7 +332,7 @@ export async function GET(request: NextRequest) {
}
// Exchange code for access token
accessToken = await handleChannelOAuthCallback({
const channelTokenResult = await handleChannelOAuthCallback({
platform,
code,
shop: shop || undefined,
@@ -298,11 +342,29 @@ export async function GET(request: NextRequest) {
redirectUri: `${baseUrl}/api/oauth/callback`, // Single callback URL
});
// Handle both old string format and new object format for backward compatibility
if (typeof channelTokenResult === 'string') {
// Legacy format - just access token
accessToken = channelTokenResult;
} else {
// New format - object with both tokens
accessToken = channelTokenResult.accessToken;
}
// Redirect to channels page with params
const redirectUrl = new URL(`${baseUrl}/dashboard/platform/channels`);
redirectUrl.searchParams.set('showCreateChannel', 'true');
redirectUrl.searchParams.set('platform', platformId);
redirectUrl.searchParams.set('accessToken', accessToken);
if (typeof channelTokenResult === 'object') {
// Include additional token data for new implementations
if (channelTokenResult.refreshToken) {
redirectUrl.searchParams.set('refreshToken', channelTokenResult.refreshToken);
}
if (channelTokenResult.tokenExpiresAt) {
redirectUrl.searchParams.set('tokenExpiresAt', channelTokenResult.tokenExpiresAt);
}
}
redirectUrl.searchParams.set('domain', shop ?? '');
return NextResponse.redirect(redirectUrl.toString());
+134
View File
@@ -0,0 +1,134 @@
"use client";
import * as React from "react";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface DateTimePickerProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
}
export function DateTimePicker({
value,
onChange,
placeholder = "Pick a date and time",
className,
disabled = false,
}: DateTimePickerProps) {
const [date, setDate] = React.useState<Date | undefined>(
value ? new Date(value) : undefined
);
const [time, setTime] = React.useState<string>(
value ? format(new Date(value), "HH:mm") : ""
);
const [open, setOpen] = React.useState(false);
const handleDateSelect = (selectedDate: Date | undefined) => {
if (selectedDate) {
const newDate = new Date(selectedDate);
if (time) {
const [hours, minutes] = time.split(":");
newDate.setHours(parseInt(hours), parseInt(minutes));
}
setDate(newDate);
const isoString = newDate.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm format
onChange?.(isoString);
}
};
const handleTimeChange = (newTime: string) => {
setTime(newTime);
if (date && newTime) {
const [hours, minutes] = newTime.split(":");
const newDate = new Date(date);
newDate.setHours(parseInt(hours), parseInt(minutes));
setDate(newDate);
const isoString = newDate.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm format
onChange?.(isoString);
}
};
const handleClear = () => {
setDate(undefined);
setTime("");
onChange?.("");
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!date && "text-muted-foreground",
className
)}
disabled={disabled}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? (
format(date, "PPP 'at' HH:mm")
) : (
<span>{placeholder}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="p-3 space-y-3">
<Calendar
mode="single"
selected={date}
onSelect={handleDateSelect}
initialFocus
/>
<div className="space-y-2">
<Label htmlFor="time-input" className="text-sm font-medium">
Time
</Label>
<Input
id="time-input"
type="time"
value={time}
onChange={(e) => handleTimeChange(e.target.value)}
className="w-full"
/>
</div>
<div className="flex gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={handleClear}
className="flex-1"
>
Clear
</Button>
<Button
size="sm"
onClick={() => setOpen(false)}
className="flex-1"
>
Done
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
+2 -2
View File
@@ -444,7 +444,7 @@ const MultipleSelector = ({
inputRef?.current?.focus()
}}
>
<div className="flex flex-wrap gap-1">
<div className="flex flex-wrap gap-1 max-h-20 overflow-y-auto">
{selected.map((option) => {
return (
<div
@@ -545,7 +545,7 @@ const MultipleSelector = ({
>
{open && (
<CommandList
className="bg-popover text-popover-foreground shadow-lg outline-hidden"
className="bg-popover text-popover-foreground shadow-lg outline-hidden max-h-48 overflow-auto"
onMouseLeave={() => {
setOnScrollbar(false)
}}
+437 -160
View File
@@ -1,4 +1,5 @@
import { GraphQLClient, gql } from "graphql-request";
import { keystoneContext } from '@/features/keystone/context';
interface OpenFrontPlatform {
domain: string;
@@ -36,46 +37,132 @@ interface DeleteWebhookArgs {
// Helper function to get fresh access token with proper OAuth 2.0 flow
const getFreshAccessToken = async (platform: OpenFrontPlatform) => {
// Check if we have local access token expiry information
if (platform.tokenExpiresAt && platform.refreshToken) {
const expiresAt = typeof platform.tokenExpiresAt === 'string'
? new Date(platform.tokenExpiresAt)
: platform.tokenExpiresAt;
console.log('🔄 [OpenFront Channel] getFreshAccessToken called');
console.log('🔄 [OpenFront Channel] Platform domain:', platform.domain);
console.log('🔄 [OpenFront Channel] Actual access token:', platform.accessToken);
// Get channel with OAuth credentials from database
const channels = await keystoneContext.sudo().query.Channel.findMany({
where: {
domain: { equals: platform.domain },
accessToken: { equals: platform.accessToken }
},
query: 'id refreshToken tokenExpiresAt platform { appKey appSecret }'
});
if (!channels || channels.length === 0) {
console.log('⚠️ [OpenFront Channel] No matching channel found in database');
return platform.accessToken;
}
const channel = channels[0];
console.log('🔄 [OpenFront Channel] Found channel:', channel.id);
console.log('🔄 [OpenFront Channel] Has refresh token:', !!channel.refreshToken);
console.log('🔄 [OpenFront Channel] Token expires at:', channel.tokenExpiresAt);
console.log('🔄 [OpenFront Channel] Has appKey:', !!channel.platform?.appKey);
console.log('🔄 [OpenFront Channel] Has appSecret:', !!channel.platform?.appSecret);
console.log('🔄 [OpenFront Channel] Actual refresh token:', channel.refreshToken);
// If we have a refresh token, check if we need to refresh
if (channel.refreshToken) {
console.log('🔄 [OpenFront Channel] Refresh token found, checking if refresh needed');
// If access token hasn't expired yet, use it
if (expiresAt > new Date()) {
return platform.accessToken;
// Check if access token has expired (if we have expiry info)
let shouldRefresh = false;
if (channel.tokenExpiresAt) {
const expiresAt = typeof channel.tokenExpiresAt === 'string'
? new Date(channel.tokenExpiresAt)
: channel.tokenExpiresAt;
const now = new Date();
shouldRefresh = expiresAt <= now;
console.log('🔄 [OpenFront Channel] Token expiry check:');
console.log('🔄 [OpenFront Channel] - Expires at:', expiresAt.toISOString());
console.log('🔄 [OpenFront Channel] - Current time:', now.toISOString());
console.log('🔄 [OpenFront Channel] - Should refresh:', shouldRefresh);
} else {
// If no expiry info, assume token needs refresh
shouldRefresh = true;
console.log('🔄 [OpenFront Channel] No expiry info found, assuming refresh needed');
}
// Use refresh token to get new access token
if (shouldRefresh) {
console.log('🔄 [OpenFront Channel] Starting token refresh process...');
// Use refresh token to get new access token
const tokenUrl = `${platform.domain}/api/oauth/token`;
console.log('🔄 [OpenFront Channel] Token URL:', tokenUrl);
const formData = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: platform.refreshToken,
refresh_token: channel.refreshToken,
client_id: channel.platform?.appKey || "",
client_secret: channel.platform?.appSecret || "",
});
console.log('🔄 [OpenFront Channel] Refresh request params:');
console.log('🔄 [OpenFront Channel] - grant_type: refresh_token');
console.log('🔄 [OpenFront Channel] - client_id:', channel.platform?.appKey || "NOT_SET");
console.log('🔄 [OpenFront Channel] - client_secret:', channel.platform?.appSecret ? "SET" : "NOT_SET");
console.log('🔄 [OpenFront Channel] - refresh_token:', channel.refreshToken ? "SET" : "NOT_SET");
console.log('🔄 [OpenFront Channel] Making refresh token request...');
const response = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData,
});
console.log('🔄 [OpenFront Channel] Refresh response status:', response.status);
console.log('🔄 [OpenFront Channel] Refresh response ok:', response.ok);
if (!response.ok) {
const errorText = await response.text();
console.error('Token refresh failed:', errorText);
console.error('🚨 [OpenFront Channel] Token refresh failed:', errorText);
console.error('🚨 [OpenFront Channel] Response status:', response.status);
console.error('🚨 [OpenFront Channel] Response statusText:', response.statusText);
throw new Error(`Failed to refresh access token: ${response.statusText} - ${errorText}`);
}
const { access_token } = await response.json();
const tokenData = await response.json();
console.log('🔄 [OpenFront Channel] Token refresh response received');
console.log('🔄 [OpenFront Channel] - Has access_token:', !!tokenData.access_token);
console.log('🔄 [OpenFront Channel] - Has refresh_token:', !!tokenData.refresh_token);
console.log('🔄 [OpenFront Channel] - Expires in:', tokenData.expires_in, 'seconds');
// TODO: Update stored access token and expiry in database
// This would require updating the shop/channel record with new tokens
const { access_token, refresh_token, expires_in } = tokenData;
// Update stored access token and expiry in database
console.log('🔄 [OpenFront Channel] Updating tokens in database...');
try {
console.log('🔄 [OpenFront Channel] Updating channel:', channel.id);
await keystoneContext.sudo().query.Channel.updateOne({
where: { id: channel.id },
data: {
accessToken: access_token,
...(refresh_token && { refreshToken: refresh_token }),
...(expires_in && { tokenExpiresAt: new Date(Date.now() + (expires_in * 1000)) })
}
});
console.log('✅ [OpenFront Channel] Channel updated with new tokens:', channel.id);
} catch (error) {
console.error('🚨 [OpenFront Channel] Failed to update channel tokens in database:', error);
// Continue with the request even if database update fails
}
console.log('✅ [OpenFront Channel] Returning fresh access token');
return access_token;
} else {
// Token hasn't expired yet, use existing one
console.log('✅ [OpenFront Channel] Token still valid, using existing access token');
return platform.accessToken;
}
}
// If no refresh capability, just use the access token as-is
// If no refresh token, just use the access token as-is
console.log('⚠️ [OpenFront Channel] No refresh token available, using existing access token');
return platform.accessToken;
};
@@ -229,7 +316,7 @@ export async function getProductFunction({
const openFrontClient = await createOpenFrontClient(platform);
const gqlQuery = gql`
query GetChannelProduct($productId: ID!, $variantId: ID) {
query GetChannelProduct($productId: ID!, $variantWhere: ProductVariantWhereInput) {
product(where: { id: $productId }) {
id
title
@@ -240,7 +327,7 @@ export async function getProductFunction({
}
imagePath
}
productVariants(where: $variantId ? { id: { equals: $variantId } } : {}) {
productVariants(where: $variantWhere) {
id
title
sku
@@ -260,7 +347,7 @@ export async function getProductFunction({
const { product } = await openFrontClient.request(gqlQuery, {
productId,
variantId,
variantWhere: variantId ? { id: { equals: variantId } } : {},
}) as any;
if (!product || product.status !== "published") {
@@ -306,108 +393,197 @@ export async function createPurchaseFunction({
}) {
console.log(`🛒 OpenFront Channel: Creating purchase with ${cartItems.length} items`);
console.log(`🚚 OpenFront Channel: Ship to: ${shipping?.firstName} ${shipping?.lastName}`);
console.log(`📦 OpenFront Channel: Full shipping data:`, JSON.stringify(shipping, null, 2));
console.log(`📦 OpenFront Channel: Platform data:`, JSON.stringify(platform, null, 2));
console.log(`📦 OpenFront Channel: Cart items:`, JSON.stringify(cartItems, null, 2));
const openFrontClient = await createOpenFrontClient(platform);
// Generate unique purchase ID
const purchaseId = `PO-OF-${Date.now()}`;
const orderNumber = `#${purchaseId}`;
// Calculate total price
const totalPrice = cartItems.reduce((sum: number, item: any) => {
return sum + (parseFloat(item.price) * item.quantity);
}, 0);
// Create fulfillment order in OpenFront
const createOrderMutation = gql`
mutation CreateFulfillmentOrder($data: OrderCreateInput!) {
createOrder(data: $data) {
id
orderNumber
total
status
orderLineItems {
try {
// Step 1: Get region data with currency ID and taxRate (like storefront does)
const currencyCode = (shipping?.currency || "USD").toLowerCase();
const getRegionQuery = gql`
query GetRegion($currencyCode: String!) {
regions(where: { currency: { code: { equals: $currencyCode } } }) {
id
title
quantity
unitPrice
productVariant {
taxRate
currency {
id
title
sku
product {
code
}
}
}
`;
const { regions } = await openFrontClient.request(getRegionQuery, {
currencyCode: currencyCode,
}) as any;
const region = regions[0];
if (!region) {
throw new Error(`No region found for currency: ${currencyCode}`);
}
// Step 2: Create cart (following storefront flow)
console.log("🛒 [OpenFront Channel] Creating cart with region:", region.id);
const { createCart: cart } = await openFrontClient.request(gql`
mutation CreateCart($data: CartCreateInput!) {
createCart(data: $data) {
id
region {
id
currency {
id
title
code
}
}
}
}
}
`;
try {
// Prepare order data
const orderData = {
orderNumber,
customerEmail: shipping?.email || "fulfillment@openship.org",
firstName: shipping?.firstName || "Fulfillment",
lastName: shipping?.lastName || "Order",
address1: shipping?.streetAddress1 || "",
address2: shipping?.streetAddress2 || "",
city: shipping?.city || "",
state: shipping?.state || "",
postalCode: shipping?.zip || "",
countryCode: shipping?.country || "US",
phone: shipping?.phone || "",
total: Math.round(totalPrice * 100), // Convert to cents
subtotal: Math.round(totalPrice * 100),
status: "pending_fulfillment",
orderLineItems: {
create: cartItems.map((item: any) => ({
title: item.name || `Product ${item.variantId}`,
quantity: item.quantity,
unitPrice: Math.round(parseFloat(item.price) * 100), // Convert to cents
productVariant: { connect: { id: item.variantId } }
}))
`, {
data: {
region: { connect: { id: region.id } },
email: shipping?.email || `order-${Date.now()}@openship.generated`
}
};
const result = await openFrontClient.request(createOrderMutation, {
data: orderData,
}) as any;
const order = result.createOrder;
console.log("✅ [OpenFront Channel] Cart created with ID:", cart.id);
console.log(`📧 OpenFront Channel: Fulfillment Order Created: ${orderNumber}`);
console.log(`💰 OpenFront Channel: Total: $${totalPrice.toFixed(2)}`);
// Step 3: Add line items to cart (this computes unitPrice/total automatically)
const lineItemsToCreate = [];
for (const item of cartItems) {
lineItemsToCreate.push({
productVariant: { connect: { id: item.variantId } },
quantity: item.quantity
});
}
// Process line items for response
const processedLineItems = order.orderLineItems.map((item: any) => ({
id: item.id,
title: item.title,
console.log("📦 [OpenFront Channel] Adding", lineItemsToCreate.length, "line items to cart");
await openFrontClient.request(gql`
mutation AddLineItemsToCart($cartId: ID!, $data: CartUpdateInput!) {
updateActiveCart(cartId: $cartId, data: $data) {
id
}
}
`, {
cartId: cart.id,
data: {
lineItems: {
create: lineItemsToCreate
}
}
}) as any;
console.log("✅ [OpenFront Channel] Line items added to cart");
// Step 4: Create addresses and add to cart
console.log("📍 [OpenFront Channel] Creating shipping address");
const { createAddress: shippingAddr } = await openFrontClient.request(gql`
mutation CreateAddress($data: AddressCreateInput!) {
createAddress(data: $data) {
id
}
}
`, {
data: {
firstName: shipping?.firstName || "Guest",
lastName: shipping?.lastName || "Customer",
address1: shipping?.address1 || "123 Default St",
city: shipping?.city || "Default City",
province: shipping?.state || "NY",
postalCode: shipping?.zip || "10001",
phone: shipping?.phone || "",
country: {
connect: {
iso2: (shipping?.country || "US").toLowerCase()
}
}
}
}) as any;
console.log("✅ [OpenFront Channel] Shipping address created:", shippingAddr.id);
// Step 5: Update cart with addresses
await openFrontClient.request(gql`
mutation UpdateCartAddresses($cartId: ID!, $data: CartUpdateInput!) {
updateActiveCart(cartId: $cartId, data: $data) {
id
}
}
`, {
cartId: cart.id,
data: {
shippingAddress: { connect: { id: shippingAddr.id } },
billingAddress: { connect: { id: shippingAddr.id } }
}
}) as any;
console.log("✅ [OpenFront Channel] Cart updated with addresses");
// Step 6: Complete cart to create order (like storefront placeOrder)
console.log("🎯 [OpenFront Channel] Completing cart to create order");
console.log("🎯 [OpenFront Channel] Calling completeActiveCart for cart:", cart.id);
const completeResult = await openFrontClient.request(gql`
mutation CompleteActiveCart($cartId: ID!) {
completeActiveCart(cartId: $cartId)
}
`, {
cartId: cart.id
}) as any;
console.log("🔍 [OpenFront Channel] completeActiveCart result:", JSON.stringify(completeResult, null, 2));
const order = completeResult.completeActiveCart;
if (!order?.id) {
throw new Error("Failed to complete cart - no order created");
}
console.log("🔍 [OpenFront Channel] COMPLETE ORDER RESULT:", JSON.stringify(order, null, 2));
console.log("🎉 [OpenFront Channel] Order created successfully:", order.id);
// Process line items from cart items (since order doesn't include line items)
const processedLineItems = cartItems.map((item: any) => ({
id: item.variantId,
title: item.name || `Product ${item.variantId}`,
quantity: item.quantity,
variantId: item.productVariant.id,
productId: item.productVariant.product.id
variantId: item.variantId,
}));
// Return success response
// Return in the same format as Shopify channel
return {
purchaseId: order.id,
orderNumber: order.orderNumber,
totalPrice: (order.total / 100).toFixed(2),
orderNumber: `#${order.displayId}`,
totalPrice: order.total,
invoiceUrl: `https://${platform.domain}/admin/orders/${order.id}`,
lineItems: processedLineItems,
status: "processing"
status: "pending",
};
} catch (error) {
console.error('OpenFront Channel: Purchase creation failed:', error);
} catch (error: any) {
console.error('🚨 OpenFront Channel: Purchase creation failed:', error);
// Return error response
if (error.response?.errors) {
console.error('🚨 GraphQL Errors:');
error.response.errors.forEach((err: any, i: number) => {
console.error(` ${i + 1}. ${err.message}`);
if (err.path) {
console.error(` Path: ${err.path.join(' -> ')}`);
}
if (err.extensions) {
console.error(` Extensions:`, err.extensions);
}
});
}
// Return error response with detailed error info
return {
purchaseId,
orderNumber,
totalPrice: totalPrice.toFixed(2),
purchaseId: null,
orderNumber: null,
totalPrice: "0.00",
invoiceUrl: null,
lineItems: cartItems.map((item: any) => ({
id: `error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
@@ -422,7 +598,7 @@ export async function createPurchaseFunction({
}
}
// Function to create webhook for channel events
// Function to create webhook for channel events - simplified to update user's webhook URL
export async function createWebhookFunction({
platform,
endpoint,
@@ -432,46 +608,128 @@ export async function createWebhookFunction({
endpoint: string;
events: string[];
}) {
const openFrontClient = await createOpenFrontClient(platform);
console.log('🪝 [OpenFront Channel] createWebhookFunction called');
console.log('🪝 [OpenFront Channel] Platform domain:', platform.domain);
console.log('🪝 [OpenFront Channel] Endpoint URL:', endpoint);
console.log('🪝 [OpenFront Channel] Events received:', events);
console.log('🪝 [OpenFront Channel] Events type:', typeof events, 'Array?', Array.isArray(events));
console.log('🪝 [OpenFront Channel] Platform data:', JSON.stringify(platform, null, 2));
const createWebhookMutation = gql`
mutation CreateChannelWebhookEndpoint($data: WebhookEndpointCreateInput!) {
createWebhookEndpoint(data: $data) {
id
url
events
isActive
secret
// Map Openship channel events to OpenFront events
const eventMap: Record<string, string> = {
ORDER_CREATED: "order.created",
ORDER_CANCELLED: "order.cancelled",
TRACKING_CREATED: "fulfillment.created",
};
const openFrontEvents = events.map(event => {
const mapped = eventMap[event] || event;
console.log(`🪝 [OpenFront Channel] Mapping: ${event} -> ${mapped}`);
return mapped;
});
console.log('🪝 [OpenFront Channel] Final mapped events array:', openFrontEvents);
console.log('🪝 [OpenFront Channel] Joined events string:', openFrontEvents.join(", "));
try {
console.log('🪝 [OpenFront Channel] Creating OpenFront GraphQL client...');
const openFrontClient = await createOpenFrontClient(platform);
console.log('✅ [OpenFront Channel] GraphQL client created successfully');
// Get current user from the access token
console.log('🪝 [OpenFront Channel] Fetching current user...');
const getUserQuery = gql`
query GetCurrentUser {
authenticatedItem {
... on User {
id
email
orderWebhookUrl
}
}
}
`;
console.log('🪝 [OpenFront Channel] Executing getUserQuery...');
const { authenticatedItem: user } = await openFrontClient.request(getUserQuery) as any;
console.log('🪝 [OpenFront Channel] getUserQuery result:', JSON.stringify(user, null, 2));
if (!user) {
console.error('🚨 [OpenFront Channel] User not authenticated - authenticatedItem is null/undefined');
throw new Error('User not authenticated');
}
console.log('✅ [OpenFront Channel] User authenticated successfully');
console.log('🪝 [OpenFront Channel] User ID:', user.id);
console.log('🪝 [OpenFront Channel] User email:', user.email);
console.log('🪝 [OpenFront Channel] Current webhook URL:', user.orderWebhookUrl);
// Update the user's webhook URL using updateActiveUser (bypasses access restrictions)
console.log('🪝 [OpenFront Channel] Updating user webhook URL...');
const updateUserMutation = gql`
mutation UpdateActiveUserWebhookUrl($data: UserUpdateProfileInput!) {
updateActiveUser(data: $data) {
id
orderWebhookUrl
}
}
`;
console.log('🪝 [OpenFront Channel] Update mutation variables:');
console.log('🪝 [OpenFront Channel] - User ID:', user.id);
console.log('🪝 [OpenFront Channel] - New webhook URL:', endpoint);
const result = await openFrontClient.request(updateUserMutation, {
data: { orderWebhookUrl: endpoint }
}) as any;
console.log('🪝 [OpenFront Channel] Update mutation result:', JSON.stringify(result, null, 2));
const updatedUser = result.updateActiveUser;
console.log('✅ [OpenFront Channel] User webhook URL updated successfully');
console.log('🪝 [OpenFront Channel] Updated user ID:', updatedUser.id);
console.log('🪝 [OpenFront Channel] Updated webhook URL:', updatedUser.orderWebhookUrl);
const webhookResponse = {
webhooks: [{
id: `user-${updatedUser.id}`,
callbackUrl: updatedUser.orderWebhookUrl,
topic: openFrontEvents.join(", "), // Join array into comma-separated string
format: "JSON",
createdAt: new Date().toISOString()
}],
webhookId: `user-${updatedUser.id}`
};
console.log('🪝 [OpenFront Channel] Final webhook response:', JSON.stringify(webhookResponse, null, 2));
return webhookResponse;
} catch (error: any) {
console.error('🚨 [OpenFront Channel] createWebhookFunction failed:', error);
console.error('🚨 [OpenFront Channel] Error message:', error.message);
console.error('🚨 [OpenFront Channel] Error stack:', error.stack);
if (error.response) {
console.error('🚨 [OpenFront Channel] GraphQL response:', JSON.stringify(error.response, null, 2));
if (error.response.errors) {
console.error('🚨 [OpenFront Channel] GraphQL errors:');
error.response.errors.forEach((err: any, i: number) => {
console.error(`🚨 [OpenFront Channel] ${i + 1}. ${err.message}`);
if (err.path) {
console.error(`🚨 [OpenFront Channel] Path: ${err.path.join(' -> ')}`);
}
if (err.extensions) {
console.error(`🚨 [OpenFront Channel] Extensions:`, err.extensions);
}
});
}
}
`;
// Map channel events to OpenFront events
const eventMap: Record<string, string> = {
PURCHASE_CREATED: "order.created",
PURCHASE_SHIPPED: "fulfillment.shipped",
INVENTORY_UPDATED: "inventory.updated",
};
const openFrontEvents = events.map(event => eventMap[event] || event);
const result = await openFrontClient.request(createWebhookMutation, {
data: {
url: endpoint,
events: openFrontEvents,
isActive: true,
},
}) as any;
const webhook = result.createWebhookEndpoint;
return {
webhooks: [webhook],
webhookId: webhook.id
};
throw error;
}
}
// Function to delete webhook
// Function to delete webhook - clear user's webhook URL
export async function deleteWebhookFunction({
platform,
webhookId,
@@ -481,22 +739,28 @@ export async function deleteWebhookFunction({
}) {
const openFrontClient = await createOpenFrontClient(platform);
const deleteWebhookMutation = gql`
mutation DeleteChannelWebhookEndpoint($where: WebhookEndpointWhereUniqueInput!) {
deleteWebhookEndpoint(where: $where) {
// Extract user ID from webhookId (format: "user-{userId}")
const userId = webhookId.replace('user-', '');
// Clear the user's webhook URL
const updateUserMutation = gql`
mutation ClearUserWebhookUrl($where: UserWhereUniqueInput!, $data: UserUpdateInput!) {
updateUser(where: $where, data: $data) {
id
orderWebhookUrl
}
}
`;
const result = await openFrontClient.request(deleteWebhookMutation, {
where: { id: webhookId },
const result = await openFrontClient.request(updateUserMutation, {
where: { id: userId },
data: { orderWebhookUrl: null }
});
return result;
}
// Function to get webhooks
// Function to get webhooks - get user's webhook URL
export async function getWebhooksFunction({
platform,
}: {
@@ -504,34 +768,41 @@ export async function getWebhooksFunction({
}) {
const openFrontClient = await createOpenFrontClient(platform);
const query = gql`
query GetChannelWebhookEndpoints {
webhookEndpoints(where: { isActive: { equals: true } }) {
id
url
events
isActive
createdAt
// Map OpenFront events back to Openship channel events
const eventMap: Record<string, string> = {
"order.created": "PURCHASE_CREATED",
"fulfillment.created": "PURCHASE_SHIPPED",
};
// Get current user's webhook URL
const getUserQuery = gql`
query GetCurrentUser {
authenticatedItem {
... on User {
id
email
orderWebhookUrl
createdAt
}
}
}
`;
const { webhookEndpoints } = await openFrontClient.request(query) as any;
const { authenticatedItem: user } = await openFrontClient.request(getUserQuery) as any;
if (!user || !user.orderWebhookUrl) {
return { webhooks: [] };
}
// Map OpenFront events back to channel events
const eventMap: Record<string, string> = {
"order.created": "PURCHASE_CREATED",
"fulfillment.shipped": "PURCHASE_SHIPPED",
"inventory.updated": "INVENTORY_UPDATED",
};
const webhooks = webhookEndpoints.map((webhook: any) => ({
id: webhook.id,
callbackUrl: webhook.url,
topic: webhook.events.map((event: string) => eventMap[event] || event),
// For OpenFront channels, we support both PURCHASE_CREATED and PURCHASE_SHIPPED
// Since we store a single webhook URL on the user, we return both events as supported
const webhooks = [{
id: `user-${user.id}`,
callbackUrl: user.orderWebhookUrl,
topic: ["PURCHASE_CREATED", "PURCHASE_SHIPPED"], // These are the channel events we support
format: "JSON",
createdAt: webhook.createdAt,
}));
createdAt: user.createdAt
}];
return { webhooks };
}
@@ -702,9 +973,15 @@ export async function oAuthCallbackFunction({
throw new Error(`Failed to exchange OAuth code for access token: ${response.statusText}`);
}
const { access_token } = await response.json();
const { access_token, refresh_token, expires_in } = await response.json();
return access_token; // Return just the token, as expected by OpenShip
// Return OAuth response with refresh token support
return {
accessToken: access_token,
refreshToken: refresh_token,
expiresIn: expires_in,
tokenExpiresAt: new Date(Date.now() + (expires_in * 1000)).toISOString()
};
}
// Required OAuth scopes for OpenFront channel integration
+168 -64
View File
@@ -1,5 +1,6 @@
import { GraphQLClient, gql } from "graphql-request";
import { getBaseUrl } from '@/features/dashboard/lib/getBaseUrl';
import { keystoneContext } from '@/features/keystone/context';
interface OpenFrontPlatform {
domain: string;
@@ -58,48 +59,132 @@ interface WebhookEventArgs {
// Helper function to get fresh access token with proper OAuth 2.0 flow
const getFreshAccessToken = async (platform: OpenFrontPlatform) => {
// Check if we have local access token expiry information
if (platform.tokenExpiresAt && platform.refreshToken) {
const expiresAt = typeof platform.tokenExpiresAt === 'string'
? new Date(platform.tokenExpiresAt)
: platform.tokenExpiresAt;
// If access token hasn't expired yet, use it
if (expiresAt > new Date()) {
return platform.accessToken;
}
// Use refresh token to get new access token
const tokenUrl = `${platform.domain}/api/oauth/token`;
const formData = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: platform.refreshToken,
});
const response = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData,
});
if (!response.ok) {
const errorText = await response.text();
console.error('Token refresh failed:', errorText);
throw new Error(`Failed to refresh access token: ${response.statusText} - ${errorText}`);
}
const { access_token } = await response.json();
// TODO: Update stored access token and expiry in database
// This would require updating the shop/channel record with new tokens
return access_token;
console.log('🔄 [OpenFront Shop] getFreshAccessToken called');
console.log('🔄 [OpenFront Shop] Platform domain:', platform.domain);
console.log('🔄 [OpenFront Shop] Actual access token:', platform.accessToken);
// Get shop with OAuth credentials from database
const shops = await keystoneContext.sudo().query.Shop.findMany({
where: {
domain: { equals: platform.domain },
accessToken: { equals: platform.accessToken }
},
query: 'id refreshToken tokenExpiresAt platform { appKey appSecret }'
});
if (!shops || shops.length === 0) {
console.log('⚠️ [OpenFront Shop] No matching shop found in database');
return platform.accessToken;
}
const shop = shops[0];
console.log('🔄 [OpenFront Shop] Found shop:', shop.id);
console.log('🔄 [OpenFront Shop] Has refresh token:', !!shop.refreshToken);
console.log('🔄 [OpenFront Shop] Token expires at:', shop.tokenExpiresAt);
console.log('🔄 [OpenFront Shop] Has appKey:', !!shop.platform?.appKey);
console.log('🔄 [OpenFront Shop] Has appSecret:', !!shop.platform?.appSecret);
console.log('🔄 [OpenFront Shop] Actual refresh token:', shop.refreshToken);
// If no refresh capability, just use the access token as-is
// If we have a refresh token, check if we need to refresh
if (shop.refreshToken) {
console.log('🔄 [OpenFront Shop] Refresh token found, checking if refresh needed');
// Check if access token has expired (if we have expiry info)
let shouldRefresh = false;
if (shop.tokenExpiresAt) {
const expiresAt = typeof shop.tokenExpiresAt === 'string'
? new Date(shop.tokenExpiresAt)
: shop.tokenExpiresAt;
const now = new Date();
shouldRefresh = expiresAt <= now;
console.log('🔄 [OpenFront Shop] Token expiry check:');
console.log('🔄 [OpenFront Shop] - Expires at:', expiresAt.toISOString());
console.log('🔄 [OpenFront Shop] - Current time:', now.toISOString());
console.log('🔄 [OpenFront Shop] - Should refresh:', shouldRefresh);
} else {
// If no expiry info, assume token needs refresh
shouldRefresh = true;
console.log('🔄 [OpenFront Shop] No expiry info found, assuming refresh needed');
}
if (shouldRefresh) {
console.log('🔄 [OpenFront Shop] Starting token refresh process...');
// Use refresh token to get new access token
const tokenUrl = `${platform.domain}/api/oauth/token`;
console.log('🔄 [OpenFront Shop] Token URL:', tokenUrl);
const formData = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: shop.refreshToken,
client_id: shop.platform?.appKey || "",
client_secret: shop.platform?.appSecret || "",
});
console.log('🔄 [OpenFront Shop] Refresh request params:');
console.log('🔄 [OpenFront Shop] - grant_type: refresh_token');
console.log('🔄 [OpenFront Shop] - client_id:', shop.platform?.appKey || "NOT_SET");
console.log('🔄 [OpenFront Shop] - client_secret:', shop.platform?.appSecret ? "SET" : "NOT_SET");
console.log('🔄 [OpenFront Shop] - refresh_token:', shop.refreshToken ? "SET" : "NOT_SET");
console.log('🔄 [OpenFront Shop] Making refresh token request...');
const response = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData,
});
console.log('🔄 [OpenFront Shop] Refresh response status:', response.status);
console.log('🔄 [OpenFront Shop] Refresh response ok:', response.ok);
if (!response.ok) {
const errorText = await response.text();
console.error('🚨 [OpenFront Shop] Token refresh failed:', errorText);
console.error('🚨 [OpenFront Shop] Response status:', response.status);
console.error('🚨 [OpenFront Shop] Response statusText:', response.statusText);
throw new Error(`Failed to refresh access token: ${response.statusText} - ${errorText}`);
}
const tokenData = await response.json();
console.log('🔄 [OpenFront Shop] Token refresh response received');
console.log('🔄 [OpenFront Shop] - Has access_token:', !!tokenData.access_token);
console.log('🔄 [OpenFront Shop] - Has refresh_token:', !!tokenData.refresh_token);
console.log('🔄 [OpenFront Shop] - Expires in:', tokenData.expires_in, 'seconds');
const { access_token, refresh_token, expires_in } = tokenData;
// Update stored access token and expiry in database
console.log('🔄 [OpenFront Shop] Updating tokens in database...');
try {
console.log('🔄 [OpenFront Shop] Updating shop:', shop.id);
await keystoneContext.sudo().query.Shop.updateOne({
where: { id: shop.id },
data: {
accessToken: access_token,
...(refresh_token && { refreshToken: refresh_token }),
...(expires_in && { tokenExpiresAt: new Date(Date.now() + (expires_in * 1000)) })
}
});
console.log('✅ [OpenFront Shop] Shop updated with new tokens:', shop.id);
} catch (error) {
console.error('🚨 [OpenFront Shop] Failed to update shop tokens in database:', error);
// Continue with the request even if database update fails
}
console.log('✅ [OpenFront Shop] Returning fresh access token');
return access_token;
} else {
// Token hasn't expired yet, use existing one
console.log('✅ [OpenFront Shop] Token still valid, using existing access token');
return platform.accessToken;
}
}
// If no refresh token, just use the access token as-is
console.log('⚠️ [OpenFront Shop] No refresh token available, using existing access token');
return platform.accessToken;
};
@@ -370,6 +455,7 @@ export async function searchOrdersFunction({
searchEntry: string;
after?: string;
}) {
console.log("fuckkk")
const openFrontClient = await createOpenFrontClient(platform);
const gqlQuery = gql`
@@ -459,6 +545,8 @@ export async function searchOrdersFunction({
skip,
}) as any;
console.log("Orders from OpenFront:", orders);
// Transform orders to Openship format
const transformedOrders = orders.map((order: any) => {
const shippingAddress = order.shippingAddress || {};
@@ -481,16 +569,23 @@ export async function searchOrdersFunction({
financialStatus: order.status,
totalPrice: order.rawTotal ? (order.rawTotal / 100).toFixed(2) : "0.00",
currency: order.currency?.code || "USD",
lineItems: (order.lineItems || []).map((lineItem: any) => ({
lineItemId: lineItem.id,
name: lineItem.title,
quantity: lineItem.quantity,
image: getProductImageUrl({ imagePath: lineItem.thumbnail, image: { url: lineItem.productVariant?.product?.thumbnail } }, platform.domain) || "",
price: lineItem.moneyAmount ? (lineItem.moneyAmount.amount / 100).toFixed(2) : "0.00",
variantId: lineItem.productVariant?.id || "",
productId: lineItem.productVariant?.product?.id || "",
sku: lineItem.sku || lineItem.productVariant?.sku || "",
})),
lineItems: (order.lineItems || []).map((lineItem: any) => {
// Combine product title and variant title like in channel search
const productTitle = lineItem.productVariant?.product?.title || '';
const variantTitle = lineItem.productVariant?.title || '';
const combinedTitle = productTitle && variantTitle ? `${productTitle} - ${variantTitle}` : lineItem.title;
return {
lineItemId: lineItem.id,
name: combinedTitle,
quantity: lineItem.quantity,
image: getProductImageUrl({ imagePath: lineItem.thumbnail, image: { url: lineItem.productVariant?.product?.thumbnail } }, platform.domain) || "",
price: lineItem.moneyAmount ? (lineItem.moneyAmount.amount / 100).toFixed(2) : "0.00",
variantId: lineItem.productVariant?.id || "",
productId: lineItem.productVariant?.product?.id || "",
sku: lineItem.sku || lineItem.productVariant?.sku || "",
};
}),
cartItems: [],
fulfillments: [],
note: "",
@@ -498,6 +593,8 @@ export async function searchOrdersFunction({
};
});
console.log("Transformed orders:", transformedOrders);
const hasNextPage = skip + take < ordersCount;
const endCursor = hasNextPage ? Buffer.from((skip + take).toString()).toString('base64') : null;
@@ -750,12 +847,12 @@ export async function oAuthCallbackFunction({
const { access_token, refresh_token, expires_in } = await response.json();
// Return OAuth token data for proper storage
// Return OAuth response with refresh token support
return {
access_token,
refresh_token,
expires_in,
expires_at: new Date(Date.now() + (expires_in * 1000))
accessToken: access_token,
refreshToken: refresh_token,
expiresIn: expires_in,
tokenExpiresAt: new Date(Date.now() + (expires_in * 1000)).toISOString()
};
}
@@ -776,16 +873,23 @@ export async function createOrderWebhookHandler({
}
// Transform OpenFront order to Openship format
const lineItemsOutput = event.data?.lineItems?.map((item: any) => ({
name: item.title,
image: getProductImageUrl(item.productVariant?.product?.productImages?.[0], platform.domain) || item.thumbnail,
price: item.moneyAmount?.amount ? (item.moneyAmount.amount / 100) : 0, // Convert from cents to float
quantity: item.quantity || 0,
productId: item.productVariant?.product?.id?.toString(),
variantId: item.productVariant?.id?.toString(),
sku: item.productVariant?.sku || item.sku || "",
lineItemId: item.id?.toString(),
})) || [];
const lineItemsOutput = event.data?.lineItems?.map((item: any) => {
// Combine product title and variant title like in channel search
const productTitle = item.productVariant?.product?.title || '';
const variantTitle = item.productVariant?.title || '';
const combinedTitle = productTitle && variantTitle ? `${productTitle} - ${variantTitle}` : item.title;
return {
name: combinedTitle,
image: getProductImageUrl(item.productVariant?.product?.productImages?.[0], platform.domain) || item.thumbnail,
price: item.moneyAmount?.amount ? (item.moneyAmount.amount / 100) : 0, // Convert from cents to float
quantity: item.quantity || 0,
productId: item.productVariant?.product?.id?.toString(),
variantId: item.productVariant?.id?.toString(),
sku: item.productVariant?.sku || item.sku || "",
lineItemId: item.id?.toString(),
};
}) || [];
// Return Keystone-ready order data
const orderData = event.data;
-1
View File
@@ -52,7 +52,6 @@ export function statelessSessions({
// Check for OAuth Bearer token authentication
const authHeader = context.req.headers.authorization;
console.log('🔑 AUTH HEADER:', authHeader);
if (authHeader?.startsWith("Bearer ")) {
const accessToken = authHeader.replace("Bearer ", "");
+4 -1
View File
@@ -44,6 +44,7 @@ export async function placeMultipleOrders({ ids, query }: { ids: string[]; query
zip,
country,
phone,
currency,
user,
shop,
orderId: shopOrderId,
@@ -61,7 +62,8 @@ export async function placeMultipleOrders({ ids, query }: { ids: string[]; query
state,
zip,
country,
phone
phone,
currency
shop {
domain
accessToken
@@ -155,6 +157,7 @@ export async function placeMultipleOrders({ ids, query }: { ids: string[]; query
country,
phone,
email: user.email,
currency,
},
notes: "",
});
+2 -2
View File
@@ -24,8 +24,8 @@ export const ChannelPlatform = list({
description:
"Adding these fields will enable this platform to be installed as an app by users.",
fields: {
appKey: text({ validation: { isRequired: true } }),
appSecret: text({ validation: { isRequired: true } }),
appKey: text(),
appSecret: text(),
callbackUrl: virtual({
field: graphql.field({
type: graphql.String,
+2 -2
View File
@@ -24,8 +24,8 @@ export const ShopPlatform = list({
description:
"Adding these fields will enable this platform to be installed as an app by users",
fields: {
appKey: text({ validation: { isRequired: true } }),
appSecret: text({ validation: { isRequired: true } }),
appKey: text(),
appSecret: text(),
callbackUrl: virtual({
field: graphql.field({
type: graphql.String,
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Plus, Copy } from "lucide-react";
import {
@@ -15,7 +16,9 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import MultipleSelector, { Option } from "@/components/ui/multiselect";
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@/components/ui/command";
import { XIcon, ChevronDown } from "lucide-react";
import { DateTimePicker } from "@/components/ui/datetime-picker";
import { createApiKey } from "../actions/getApiKeys";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
@@ -37,7 +40,12 @@ function generateApiKeyToken(): string {
return prefix + result;
}
// Define API key scopes as options for MultipleSelector
export interface Option {
value: string;
label: string;
}
// Define API key scopes as options
const scopeOptions: Option[] = [
{ value: "read_orders", label: "read_orders" },
{ value: "write_orders", label: "write_orders" },
@@ -60,6 +68,169 @@ const scopeOptions: Option[] = [
{ value: "write_users", label: "write_users" },
];
function ScopesMultiSelect({
value = [],
onChange,
options = [],
placeholder = "Select scopes for this API key",
isDisabled = false,
}: {
value?: Option[];
onChange?: (options: Option[]) => void;
options?: Option[];
placeholder?: string;
isDisabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const commandRef = useRef<HTMLDivElement>(null);
// Handle click outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
commandRef.current &&
!commandRef.current.contains(event.target as Node)
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSelect = useCallback(
(option: Option) => {
if (!value.some((v) => v.value === option.value)) {
const newValue = [...value, option];
onChange?.(newValue);
}
setInputValue("");
},
[value, onChange]
);
const handleRemove = useCallback(
(optionToRemove: Option) => {
const newValue = value.filter((v) => v.value !== optionToRemove.value);
onChange?.(newValue);
},
[value, onChange]
);
const handleClearAll = useCallback(() => {
onChange?.([]);
}, [onChange]);
// Filter out selected options and by input value
const filteredOptions = options.filter(
(option) => !value.some((v) => v.value === option.value) &&
option.label.toLowerCase().includes(inputValue.toLowerCase())
);
return (
<div className="relative" ref={commandRef}>
<div
className={cn(
"border-input ring-offset-background focus-within:ring-ring flex min-h-11 w-full items-center justify-between rounded-md border bg-transparent px-4 py-2 focus-within:ring-2 focus-within:ring-offset-2 text-left",
isDisabled && "cursor-not-allowed opacity-50"
)}
onClick={() => {
if (!isDisabled) {
setOpen(true);
inputRef.current?.focus();
}
}}
>
<div className="flex flex-wrap gap-1 max-h-20 overflow-y-auto">
{value.map((option) => (
<div
key={option.value}
className="animate-fadeIn bg-background text-secondary-foreground relative inline-flex h-7 cursor-default items-center rounded-md border ps-2 pe-7 text-xs font-medium transition-all disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 max-w-full"
>
<span className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[200px]">{option.label}</span>
{!isDisabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemove(option);
}}
className="text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute -inset-y-px -end-px flex size-7 items-center justify-center rounded-e-md border border-transparent p-0 outline-hidden transition-[color,box-shadow] outline-none focus-visible:ring-[3px]"
>
<XIcon className="h-3 w-3" />
</button>
)}
</div>
))}
<input
ref={inputRef}
disabled={isDisabled}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
className={cn(
"placeholder:text-muted-foreground flex-1 bg-transparent outline-none",
value.length === 0 ? "w-full" : "w-20"
)}
placeholder={value.length === 0 ? placeholder : ""}
/>
</div>
<div className="flex items-center gap-1">
{value.length > 0 && !isDisabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleClearAll();
}}
className="text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex size-7 items-center justify-center rounded-md border border-transparent transition-[color,box-shadow] outline-none focus-visible:ring-[3px]"
>
<XIcon className="h-4 w-4" />
</button>
)}
<ChevronDown className="h-4 w-4 opacity-50" />
</div>
</div>
{open && (
<div className="absolute z-10 mt-1 w-full max-w-[400px]">
<Command className="border-input rounded-md border bg-popover shadow-md">
<CommandList className="max-h-48">
<>
{filteredOptions.length === 0 ? (
<CommandEmpty className="py-6 text-center text-sm text-muted-foreground">
{inputValue.length > 0 ? (
<>No results found for "{inputValue}"</>
) : (
<>All options have been selected</>
)}
</CommandEmpty>
) : (
<CommandGroup>
{filteredOptions.map((option) => (
<div className="px-1 py-0.5" key={option.value}>
<div
onClick={() => handleSelect(option)}
className="relative cursor-pointer rounded-sm px-2 py-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground"
>
<div className="flex-1 whitespace-normal break-words overflow-visible min-w-0 pr-2">
{option.label}
</div>
</div>
</div>
))}
</CommandGroup>
)}
</>
</CommandList>
</Command>
</div>
)}
</div>
);
}
export function CreateApiKey() {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
@@ -136,10 +307,62 @@ export function CreateApiKey() {
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(createdToken);
toast.success("API key copied to clipboard!");
// Modern browsers with clipboard API support
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(createdToken);
toast.success("API key copied to clipboard!");
return;
}
// Fallback for older browsers or non-secure contexts
const textArea = document.createElement("textarea");
textArea.value = createdToken;
textArea.style.position = "fixed";
textArea.style.left = "-999999px";
textArea.style.top = "-999999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
toast.success("API key copied to clipboard!");
} else {
throw new Error("Copy command failed");
}
} catch (error) {
toast.error("Failed to copy API key");
console.error("Copy failed:", error);
// Final fallback - detect mobile and provide instructions
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (isMobile) {
// Mobile-specific fallback: create a temporary input and select it
try {
const input = document.createElement("input");
input.value = createdToken;
input.style.position = "fixed";
input.style.left = "-999999px";
input.style.top = "-999999px";
document.body.appendChild(input);
input.focus();
input.setSelectionRange(0, input.value.length);
// Show instructions to user
toast.error("Please manually copy the selected text above");
// Clean up after a delay
setTimeout(() => {
document.body.removeChild(input);
}, 5000);
} catch (mobileError) {
toast.error("Copy failed. Please manually select and copy the API key");
}
} else {
toast.error("Copy failed. Please manually select and copy the API key");
}
}
};
@@ -194,18 +417,11 @@ export function CreateApiKey() {
<Label>
Scopes <span className="text-red-500">*</span>
</Label>
<MultipleSelector
commandProps={{
label: "Select API key scopes",
}}
<ScopesMultiSelect
value={formData.scopes}
defaultOptions={scopeOptions}
placeholder="Select scopes for this API key"
hideClearAllButton={false}
hidePlaceholderWhenSelected={true}
emptyIndicator={<p className="text-center text-sm">No scopes found</p>}
options={scopeOptions}
onChange={handleScopeChange}
className="text-base"
placeholder="Select scopes for this API key"
/>
<p className="text-muted-foreground mt-2 text-xs">
Select the minimum permissions needed for this API key
@@ -214,11 +430,10 @@ export function CreateApiKey() {
<div className="grid gap-2">
<Label htmlFor="expiresAt">Expiration (optional)</Label>
<Input
id="expiresAt"
type="datetime-local"
<DateTimePicker
value={formData.expiresAt}
onChange={(e) => setFormData(prev => ({ ...prev, expiresAt: e.target.value }))}
onChange={(value) => setFormData(prev => ({ ...prev, expiresAt: value }))}
placeholder="Set expiration date and time"
/>
</div>
</div>
@@ -251,7 +466,7 @@ export function CreateApiKey() {
</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-mono truncate">
<div className="text-sm font-mono truncate max-w-[200px]">
{createdToken}
</div>
</div>
@@ -59,7 +59,10 @@ export function CreateChannelFromURL({ onChannelCreated, searchParams }: CreateC
const [tokenExpiresAt, setTokenExpiresAt] = useState('');
useEffect(() => {
if (!searchParams || !searchParams.showCreateChannel) return;
if (!searchParams || !searchParams.showCreateChannel) {
return;
}
const {
platform: urlPlatform,
@@ -74,55 +77,58 @@ export function CreateChannelFromURL({ onChannelCreated, searchParams }: CreateC
tokenExpiresAt: urlTokenExpiresAt
} = searchParams;
if (!urlDomain) return;
console.log('CreateChannelFromURL - Processing OAuth callback:', {
hasClientId: !!client_id,
hasPlatformId: !!urlPlatform,
hasAccessToken: !!urlAccessToken,
hasCode: !!code,
domain: urlDomain
});
if (!urlDomain) {
return;
}
// Auto-generate channel name from domain
const domainWithoutProtocol = decodeURIComponent(urlDomain).replace(/^https?:\/\//, '');
const cleanName = domainWithoutProtocol.split('.')[0].replace(/[-_]/g, ' ');
const capitalizedName = cleanName.charAt(0).toUpperCase() + cleanName.slice(1) + ' Channel';
// Fix OpenFront capitalization specifically
const fixedName = cleanName.replace(/openfront/gi, 'Openfront');
const capitalizedName = fixedName.charAt(0).toUpperCase() + fixedName.slice(1) + ' Channel';
setName(capitalizedName);
setDomain(decodeURIComponent(urlDomain));
// Check if this is marketplace flow (has client_id) or long flow (has platform)
if (client_id && client_secret && app_name && adapter_slug && urlAccessToken) {
// Marketplace flow - OAuth already exchanged, ready to create platform/shop
console.log('✅ Marketplace flow detected');
if (client_id && client_secret && app_name && adapter_slug && urlAccessToken && urlAccessToken !== 'undefined') {
// Marketplace flow - OAuth already exchanged, ready to create platform/channel
setIsMarketplaceFlow(true);
setClientId(client_id);
setClientSecret(client_secret);
setAppName(app_name);
setAdapterSlug(adapter_slug);
setAccessToken(urlAccessToken);
if (urlRefreshToken) {
setRefreshToken(urlRefreshToken);
}
if (urlTokenExpiresAt) {
setTokenExpiresAt(urlTokenExpiresAt);
}
} else if (urlPlatform && urlAccessToken) {
// Long flow - platform exists, just need to create channel
console.log('✅ Long flow detected');
setPlatformId(urlPlatform);
setAccessToken(urlAccessToken);
// Set additional token data if provided
if (urlRefreshToken) setRefreshToken(urlRefreshToken);
if (urlTokenExpiresAt) setTokenExpiresAt(urlTokenExpiresAt);
if (urlRefreshToken) {
setRefreshToken(urlRefreshToken);
}
if (urlTokenExpiresAt) {
setTokenExpiresAt(urlTokenExpiresAt);
}
} else {
console.log('❌ Invalid OAuth callback - missing required parameters');
return;
}
setIsDialogOpen(true);
// Clean URL params
const newUrl = new URL(window.location.href);
['showCreateChannel', 'platform', 'accessToken', 'domain', 'client_id', 'client_secret', 'app_name', 'adapter_slug', 'refreshToken', 'tokenExpiresAt', 'code'].forEach(param => {
newUrl.searchParams.delete(param);
});
router.replace(newUrl.pathname + newUrl.search);
// DON'T clean URL params immediately - wait for user action
// This was causing the dialog to lose its data source
}, [searchParams, router]);
const handleChannelCreation = async () => {
@@ -143,20 +149,16 @@ export function CreateChannelFromURL({ onChannelCreated, searchParams }: CreateC
// Marketplace flow: Check if platform exists
if (isMarketplaceFlow && clientId && clientSecret) {
console.log('🔍 Checking if platform exists for client_id:', clientId);
const { getChannelPlatformByClientId } = await import('../actions/getChannelPlatformByClientId');
const platformResult = await getChannelPlatformByClientId(clientId);
console.log('🔍 Platform check result:', platformResult);
if (platformResult.success && platformResult.data) {
// Platform exists, use it
finalPlatformId = platformResult.data.id;
console.log('✅ Found existing platform ID:', finalPlatformId, 'Name:', platformResult.data.name);
} else {
// Platform doesn't exist, will create inline with shop
console.log('📦 Platform does not exist, will create inline');
finalPlatformId = null;
}
}
@@ -168,27 +170,27 @@ export function CreateChannelFromURL({ onChannelCreated, searchParams }: CreateC
return;
}
// Create the shop
// Create the channel
const channelData: any = {
name: name.trim(),
domain: domain.trim(),
accessToken: finalAccessToken.trim(),
...(refreshToken && { refreshToken: refreshToken }),
...(tokenExpiresAt && { tokenExpiresAt: new Date(tokenExpiresAt) }),
};
console.log('🏪 Creating channel with data:');
console.log(' - finalPlatformId:', finalPlatformId);
console.log(' - isMarketplaceFlow:', isMarketplaceFlow);
console.log(' - clientId:', clientId);
console.log(' - adapterSlug:', adapterSlug);
if (refreshToken) {
channelData.refreshToken = refreshToken;
}
if (tokenExpiresAt) {
channelData.tokenExpiresAt = new Date(tokenExpiresAt);
}
if (finalPlatformId) {
console.log('🔗 Using existing platform ID:', finalPlatformId);
channelData.platformId = finalPlatformId;
} else if (isMarketplaceFlow && clientId && clientSecret && appName && adapterSlug) {
// Create platform inline using dynamic adapter slug
console.log('📦 Creating platform inline with adapter:', adapterSlug);
channelData.platform = {
create: {
name: appName + ' (Auto-created)',
@@ -207,19 +209,22 @@ export function CreateChannelFromURL({ onChannelCreated, searchParams }: CreateC
deleteWebhookFunction: adapterSlug,
}
};
console.log('📦 Platform inline creation data:', channelData.platform.create);
} else {
console.log('❌ No platform connection - this will cause an error');
console.log('❌ Missing: finalPlatformId?', !finalPlatformId, 'isMarketplaceFlow?', !isMarketplaceFlow, 'clientId?', !clientId, 'adapterSlug?', !adapterSlug);
}
console.log('🏪 Final channelData before createChannel call:', JSON.stringify(channelData, null, 2));
const channelResult = await createChannel(channelData);
if (channelResult.success) {
toast.success('Channel connected successfully!');
setIsDialogOpen(false);
// Clean URL params after successful creation
const newUrl = new URL(window.location.href);
['showCreateChannel', 'platform', 'accessToken', 'domain', 'client_id', 'client_secret', 'app_name', 'adapter_slug', 'refreshToken', 'tokenExpiresAt', 'code'].forEach(param => {
newUrl.searchParams.delete(param);
});
router.replace(newUrl.pathname + newUrl.search);
router.refresh();
if (onChannelCreated) {
@@ -229,7 +234,6 @@ export function CreateChannelFromURL({ onChannelCreated, searchParams }: CreateC
toast.error(channelResult.error || 'Failed to create channel');
}
} catch (error) {
console.error('Error creating channel:', error);
toast.error(error instanceof Error ? error.message : 'Failed to create channel');
} finally {
setIsLoading(false);
@@ -255,10 +259,10 @@ export function CreateChannelFromURL({ onChannelCreated, searchParams }: CreateC
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Connect OpenFront Channel</DialogTitle>
<DialogTitle>Connect Openfront Channel</DialogTitle>
<DialogDescription>
{isMarketplaceFlow
? 'Setting up your OpenFront integration. We\'ll create the platform if needed and connect your channel.'
? 'Setting up your Openfront integration. We\'ll create the platform if needed and connect your channel.'
: 'Complete your channel setup with the OAuth credentials received.'
}
</DialogDescription>
@@ -304,7 +308,7 @@ export function CreateChannelFromURL({ onChannelCreated, searchParams }: CreateC
{isMarketplaceFlow && (
<div className="text-sm bg-zinc-50 dark:bg-zinc-950/20 p-3 rounded-lg border border-zinc-200 dark:border-zinc-800">
<p className="font-medium text-zinc-900 dark:text-zinc-100 mb-1">
OpenFront Integration
Openfront Integration
</p>
<p className="text-zinc-700 dark:text-zinc-300 text-xs">
This platform will be created with the channel.
@@ -131,12 +131,9 @@ export function PlatformTabs({
>
<div className="text-sm font-medium leading-5 whitespace-nowrap flex items-center justify-center h-full gap-2">
<span className="uppercase tracking-wide">{platform.name}</span>
<Badge
color="blue"
className="px-1.5 py-0 text-[10px] leading-[14px] rounded-sm shadow-xs inline-flex items-center h-[18px]"
>
<span className="rounded-sm bg-background border shadow-xs px-1.5 py-0 text-[10px] leading-[14px] font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400 inline-flex items-center h-[18px]">
{platform.count || 0}
</Badge>
</span>
{(renderEditButton || onEditPlatform) && (
renderEditButton ? renderEditButton(platform) : (
<Button
+3 -3
View File
@@ -85,7 +85,7 @@ export function StatusTabs({ statusCounts, statusConfig, entityName, onSelectAll
/>
<div
className="absolute bottom-[-1px] h-[2px] bg-foreground transition-all duration-300 ease-out ml-4 md:ml-6"
className="absolute bottom-[-1px] h-[2px] bg-blue-500 transition-all duration-300 ease-out ml-4 md:ml-6"
style={{
left: `${activeTabOffsetLeft - scrollOffset}px`,
width: `${activeTabWidth}px`,
@@ -137,9 +137,9 @@ export function StatusTabs({ statusCounts, statusConfig, entityName, onSelectAll
>
<div className="text-sm font-medium leading-5 whitespace-nowrap flex items-center justify-center h-full gap-2">
{status.label}
<Badge color={config.color as any} className="px-1.5 py-0 text-[10px] leading-[14px] rounded-sm shadow-xs inline-flex items-center h-[18px]">
<span className="rounded-sm bg-background border shadow-xs px-1.5 py-0 text-[10px] leading-[14px] font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400 inline-flex items-center h-[18px]">
{status.count}
</Badge>
</span>
</div>
</div>
);
@@ -2,7 +2,6 @@
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Square, Store, Tv } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
@@ -144,11 +143,9 @@ export function MatchesTabs({ statusCounts, onSelectAll, selectedItems, totalIte
>
<div className="text-sm font-medium leading-5 whitespace-nowrap flex items-center justify-center h-full gap-2">
{status.label}
{status.value === "MATCHES" ? (
<Badge color={statusConfig[status.value as keyof typeof statusConfig].color} className="px-1.5 py-0 text-[10px] leading-[14px] rounded-sm shadow-xs inline-flex items-center h-[18px]">
{status.count}
</Badge>
) : null}
<span className="rounded-sm bg-background border shadow-xs px-1.5 py-0 text-[10px] leading-[14px] font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400 inline-flex items-center h-[18px]">
{status.count}
</span>
</div>
</div>
);
@@ -9,17 +9,18 @@ import { Checkbox } from "@/components/ui/checkbox";
const statusConfig = {
SHOP: {
label: "Shop Products",
icon: Layers3,
// No icon for platforms
color: "blue"
},
CHANNEL: {
label: "Channel Products",
icon: Database,
// No icon for platforms
color: "green"
},
MATCHES: {
label: "Matches",
icon: Square,
iconColor: "text-purple-500",
color: "purple"
},
} as const;
@@ -90,13 +91,14 @@ export function StatusTabs({ statusCounts, onSelectAll, selectedItems, totalItem
updateScroll(n => n + 1);
};
const activeIndex = statuses.findIndex((s) => s.value === currentStatus);
const activeIndex = currentStatus === "all" ? 0 : statuses.findIndex((s) => s.value === currentStatus) + 1;
const activeTabOffsetLeft = tabRefs.current[activeIndex]?.offsetLeft || 0;
const activeTabWidth = tabRefs.current[activeIndex]?.offsetWidth || 0;
const scrollOffset = scrollContainerRef.current ? scrollContainerRef.current.scrollLeft : 0;
return (
<div className="relative">
{/* Hover background */}
<div
className="absolute h-[28px] mt-1 transition-all duration-300 ease-out bg-muted/60 rounded-[6px] flex items-center ml-4 md:ml-6"
style={{
@@ -106,11 +108,13 @@ export function StatusTabs({ statusCounts, onSelectAll, selectedItems, totalItem
}}
/>
{/* Active indicator line */}
<div
className="absolute bottom-[-1px] h-[2px] bg-foreground transition-all duration-300 ease-out ml-4 md:ml-6"
className="absolute h-[2px] bg-blue-500 transition-all duration-300 ease-out ml-4 md:ml-6"
style={{
left: `${activeTabOffsetLeft - scrollOffset}px`,
width: `${activeTabWidth}px`,
bottom: '-1.5px',
}}
/>
@@ -124,25 +128,32 @@ export function StatusTabs({ statusCounts, onSelectAll, selectedItems, totalItem
/>
)}
{statuses.map((status, index) => {
const StatusIcon = statusConfig[status.value as keyof typeof statusConfig].icon;
const config = statusConfig[status.value as keyof typeof statusConfig];
const StatusIcon = config.icon;
const iconColor = config.iconColor;
const isActive = currentStatus === status.value;
return (
<div
key={status.value}
ref={el => { tabRefs.current[index] = el }}
ref={el => { tabRefs.current[index + 1] = el }}
className={`px-3 py-2 cursor-pointer transition-colors duration-300 ${
currentStatus === status.value
isActive
? "text-foreground"
: "text-muted-foreground"
}`}
onMouseEnter={() => setHoveredIndex(index)}
onMouseEnter={() => setHoveredIndex(index + 1)}
onMouseLeave={() => setHoveredIndex(null)}
onClick={() => handleStatusChange(status.value)}
>
<div className="text-sm font-medium leading-5 whitespace-nowrap flex items-center justify-center h-full gap-2">
{/* Only show icon for MATCHES */}
{StatusIcon && <StatusIcon className={`h-4 w-4 ${iconColor}`} />}
{status.label}
<Badge color={statusConfig[status.value as keyof typeof statusConfig].color} className="px-1.5 py-0 text-[10px] leading-[14px] rounded-sm shadow-xs inline-flex items-center h-[18px]">
{/* Use neutral badge for all tabs */}
<span className="rounded-sm bg-background border shadow-xs px-1.5 py-0 text-[10px] leading-[14px] font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400 inline-flex items-center h-[18px]">
{status.count}
</Badge>
</span>
</div>
</div>
);
@@ -164,7 +164,7 @@ export const ChannelSearchAccordion: React.FC<ChannelSearchAccordionProps> = ({
<div className="space-y-2 mt-2 max-h-60 overflow-y-auto">
{searchResults.map((product) => (
<div
key={product.productId}
key={`${product.productId}-${product.variantId}`}
className="flex items-center space-x-2 p-2 bg-background rounded-md border"
>
<div className="w-12 h-12 flex-shrink-0 rounded-md overflow-hidden">
@@ -1,6 +1,6 @@
"use client";
import React, { useState } from "react";
import React, { useState, useCallback } from "react";
// Helper function to safely format price - handles text prices that may not be numeric
function formatPrice(price: string | number | undefined, currency: string = '$'): string {
@@ -78,6 +78,18 @@ import { useToast } from '@/components/ui/use-toast';
import { Order } from "../lib/types";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { deleteItemAction } from '@/features/dashboard/actions/item-actions';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger
} from '@/components/ui/alert-dialog';
interface OrderDetailsComponentProps {
order: Order;
@@ -133,6 +145,37 @@ export const OrderDetailsComponent = ({
onAction('matchOrder', order.id);
};
// Delete handler exactly like EditItemDrawer
const handleDelete = useCallback(async () => {
try {
const { errors } = await deleteItemAction('Order', order.id);
const error = errors?.find(x => x.path === undefined || x.path?.length === 1);
if (error) {
toast({
title: 'Unable to delete order',
description: error.message,
variant: 'destructive'
});
return;
}
toast({
title: 'Order deleted successfully'
});
// Refresh the page or trigger parent refresh
window.location.reload();
} catch (err: any) {
toast({
title: 'Unable to delete order',
description: err.message || "An unexpected error occurred",
variant: 'destructive'
});
}
}, [order.id, toast]);
return (
<>
<Accordion type="single" collapsible className="w-full">
@@ -197,7 +240,8 @@ export const OrderDetailsComponent = ({
<StatusBadge status={order.status as any} />
{currentAction && (
<Badge
className="uppercase tracking-wide font-medium text-xs flex items-center gap-1.5 border py-0.5"
color="zinc"
className="text-[.6rem] sm:text-[.7rem] py-0 px-2 sm:px-3 tracking-wide font-medium rounded-md border h-6 uppercase flex items-center gap-1.5"
>
<Loader2 className="size-3.5 shrink-0 animate-spin" />
{getLoadingText(currentAction)}
@@ -214,54 +258,52 @@ export const OrderDetailsComponent = ({
<MoreVertical className="stroke-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onAction("getMatch", order.id)}>
<Square
size={16}
className="opacity-60 mr-2"
aria-hidden="true"
/>
GET MATCH
<Square size={16} className="opacity-40" strokeWidth={2} aria-hidden="true" />
Get Match
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction("saveMatch", order.id)}>
<Save
size={16}
className="opacity-60 mr-2"
aria-hidden="true"
/>
SAVE MATCH
<Save size={16} className="opacity-40" strokeWidth={2} aria-hidden="true" />
Save Match
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAction("placeOrder", order.id)}>
<Ticket
size={16}
className="opacity-60 mr-2"
aria-hidden="true"
/>
PLACE ORDER
<Ticket size={16} className="opacity-40" strokeWidth={2} aria-hidden="true" />
Place Order
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsEditDrawerOpen(true)}>
<FilePenLine
size={16}
className="opacity-60 mr-2"
aria-hidden="true"
/>
EDIT ORDER
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (confirm(`Are you sure you want to delete order ${order.orderName || order.orderId}? This action cannot be undone.`)) {
onAction("deleteOrder", order.id);
}
}}
className="text-red-600 focus:text-red-600 focus:bg-red-50 dark:focus:bg-red-950/50"
>
<Trash2
size={16}
className="opacity-60 mr-2"
aria-hidden="true"
/>
DELETE ORDER
<FilePenLine size={16} className="opacity-40" strokeWidth={2} aria-hidden="true" />
Edit Order
</DropdownMenuItem>
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="text-red-600 focus:text-red-600 focus:bg-red-50 dark:focus:bg-red-950/50"
>
<Trash2 size={16} className="opacity-40" strokeWidth={2} aria-hidden="true" />
Delete Order
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete order</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete{' '}
<strong>
Order {order.orderName || `#${order.orderId}`}
</strong>
? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>
Yes, delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -34,7 +34,6 @@ interface StatusBadgeProps {
export function StatusBadge({ status }: StatusBadgeProps) {
return (
<Badge
color={statusConfig[status].color}
className="text-[.6rem] sm:text-[.7rem] py-0 px-2 sm:px-3 tracking-wide font-medium rounded-md border h-6"
>
{status}
+64 -5
View File
@@ -17,12 +17,26 @@ export interface CreateShopInput {
}
export async function createShop(data: CreateShopInput) {
console.log('🏪 createShop action called with data:');
console.log(' - name:', data.name);
console.log(' - domain:', data.domain);
console.log(' - accessToken present:', !!data.accessToken);
console.log(' - refreshToken present:', !!data.refreshToken);
console.log(' - refreshToken value:', data.refreshToken ? `${data.refreshToken.substring(0, 10)}...` : 'MISSING');
console.log(' - tokenExpiresAt present:', !!data.tokenExpiresAt);
console.log(' - tokenExpiresAt value:', data.tokenExpiresAt);
console.log(' - platformId:', data.platformId);
console.log(' - platform.create present:', !!data.platform?.create);
const mutation = `
mutation CreateShop($data: ShopCreateInput!) {
createShop(data: $data) {
id
name
domain
accessToken
refreshToken
tokenExpiresAt
platform {
id
name
@@ -35,31 +49,76 @@ export async function createShop(data: CreateShopInput) {
let platformData;
if (data.platformId) {
// Existing platform - connect by ID
console.log('🔗 Using existing platform ID:', data.platformId);
platformData = { connect: { id: data.platformId } };
} else if (data.platform?.create) {
// Inline platform creation
console.log('📦 Creating platform inline:', data.platform.create);
platformData = { create: data.platform.create };
} else {
console.log('❌ No platform connection method provided');
throw new Error('Either platformId or platform.create must be provided');
}
const variables = {
const variables: {
data: {
name: string;
domain: string;
accessToken: string;
refreshToken?: string;
tokenExpiresAt?: string;
platform?: any;
}
} = {
data: {
name: data.name,
domain: data.domain,
accessToken: data.accessToken,
...(data.refreshToken && { refreshToken: data.refreshToken }),
...(data.tokenExpiresAt && { tokenExpiresAt: data.tokenExpiresAt.toISOString() }),
platform: platformData
}
};
console.log('🔍 Building GraphQL variables:');
console.log(' - Base variables:', JSON.stringify(variables, null, 2));
if (data.refreshToken) {
console.log('🔑 Adding refreshToken to variables:', data.refreshToken ? `${data.refreshToken.substring(0, 10)}...` : 'MISSING');
variables.data.refreshToken = data.refreshToken;
} else {
console.log('❌ No refreshToken to add');
}
if (data.tokenExpiresAt) {
console.log('⏰ Adding tokenExpiresAt to variables:', data.tokenExpiresAt);
variables.data.tokenExpiresAt = data.tokenExpiresAt.toISOString();
} else {
console.log('❌ No tokenExpiresAt to add');
}
variables.data.platform = platformData;
console.log('🚀 Final GraphQL variables being sent:');
console.log(JSON.stringify(variables, null, 2));
const response = await keystoneClient(mutation, variables);
console.log('Created shop response:', response);
console.log('📨 GraphQL response received:');
console.log(' - success:', response.success);
console.log(' - data:', JSON.stringify(response.data, null, 2));
console.log(' - error:', response.error);
if (response.success && response.data?.createShop) {
console.log('✅ Shop created successfully:');
console.log(' - ID:', response.data.createShop.id);
console.log(' - Name:', response.data.createShop.name);
console.log(' - Domain:', response.data.createShop.domain);
console.log(' - Has accessToken:', !!response.data.createShop.accessToken);
console.log(' - Has refreshToken:', !!response.data.createShop.refreshToken);
console.log(' - TokenExpiresAt:', response.data.createShop.tokenExpiresAt);
return { success: true, shop: response.data.createShop };
} else {
console.log('❌ Shop creation failed:');
console.log(' - Response success:', response.success);
console.log(' - Response error:', response.error);
console.log(' - Full response:', JSON.stringify(response, null, 2));
return {
success: false,
error: response.error || 'Failed to create shop'
@@ -3,7 +3,7 @@
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { createShop } from "../actions/createShop";
import { createShop, CreateShopInput } from "../actions/createShop";
import { createShopPlatform } from "../actions/createShopPlatform";
import { toast } from "sonner";
import {
@@ -76,14 +76,6 @@ export function CreateShopFromURL({ onShopCreated, searchParams }: CreateShopFro
if (!urlDomain) return;
console.log('CreateShopFromURL - Processing OAuth callback:', {
hasClientId: !!client_id,
hasPlatformId: !!urlPlatform,
hasAccessToken: !!urlAccessToken,
hasCode: !!code,
domain: urlDomain
});
// Auto-generate shop name from domain
const domainWithoutProtocol = decodeURIComponent(urlDomain).replace(/^https?:\/\//, '');
const cleanName = domainWithoutProtocol.split('.')[0].replace(/[-_]/g, ' ');
@@ -92,37 +84,38 @@ export function CreateShopFromURL({ onShopCreated, searchParams }: CreateShopFro
setDomain(decodeURIComponent(urlDomain));
// Check if this is marketplace flow (has client_id) or long flow (has platform)
if (client_id && client_secret && app_name && adapter_slug && urlAccessToken) {
if (client_id && client_secret && app_name && adapter_slug && urlAccessToken && urlAccessToken !== 'undefined') {
// Marketplace flow - OAuth already exchanged, ready to create platform/shop
console.log('✅ Marketplace flow detected');
setIsMarketplaceFlow(true);
setClientId(client_id);
setClientSecret(client_secret);
setAppName(app_name);
setAdapterSlug(adapter_slug);
setAccessToken(urlAccessToken);
if (urlRefreshToken) {
setRefreshToken(urlRefreshToken);
}
if (urlTokenExpiresAt) {
setTokenExpiresAt(urlTokenExpiresAt);
}
} else if (urlPlatform && urlAccessToken) {
// Long flow - platform exists, just need to create shop
console.log('✅ Long flow detected');
setPlatformId(urlPlatform);
setAccessToken(urlAccessToken);
// Set additional token data if provided
if (urlRefreshToken) setRefreshToken(urlRefreshToken);
if (urlTokenExpiresAt) setTokenExpiresAt(urlTokenExpiresAt);
if (urlRefreshToken) {
setRefreshToken(urlRefreshToken);
}
if (urlTokenExpiresAt) {
setTokenExpiresAt(urlTokenExpiresAt);
}
} else {
console.log('❌ Invalid OAuth callback - missing required parameters');
return;
}
setIsDialogOpen(true);
// Clean URL params
const newUrl = new URL(window.location.href);
['showCreateShop', 'platform', 'accessToken', 'domain', 'client_id', 'client_secret', 'app_name', 'adapter_slug', 'refreshToken', 'tokenExpiresAt', 'code'].forEach(param => {
newUrl.searchParams.delete(param);
});
router.replace(newUrl.pathname + newUrl.search);
}, [searchParams, router]);
const handleShopCreation = async () => {
@@ -143,20 +136,14 @@ export function CreateShopFromURL({ onShopCreated, searchParams }: CreateShopFro
// Marketplace flow: Check if platform exists
if (isMarketplaceFlow && clientId && clientSecret) {
console.log('🔍 Checking if platform exists for client_id:', clientId);
const { getShopPlatformByClientId } = await import('../actions/getShopPlatformByClientId');
const platformResult = await getShopPlatformByClientId(clientId);
console.log('🔍 Platform check result:', platformResult);
if (platformResult.success && platformResult.data) {
// Platform exists, use it
finalPlatformId = platformResult.data.id;
console.log('✅ Found existing platform ID:', finalPlatformId, 'Name:', platformResult.data.name);
} else {
// Platform doesn't exist, will create inline with shop
console.log('📦 Platform does not exist, will create inline');
finalPlatformId = null;
}
}
@@ -169,26 +156,24 @@ export function CreateShopFromURL({ onShopCreated, searchParams }: CreateShopFro
}
// Create the shop
const shopData: any = {
const shopData: CreateShopInput = {
name: name.trim(),
domain: domain.trim(),
accessToken: finalAccessToken.trim(),
...(refreshToken && { refreshToken: refreshToken }),
...(tokenExpiresAt && { tokenExpiresAt: new Date(tokenExpiresAt) }),
};
console.log('🏪 Creating shop with data:');
console.log(' - finalPlatformId:', finalPlatformId);
console.log(' - isMarketplaceFlow:', isMarketplaceFlow);
console.log(' - clientId:', clientId);
console.log(' - adapterSlug:', adapterSlug);
if (refreshToken) {
shopData.refreshToken = refreshToken;
}
if (tokenExpiresAt) {
shopData.tokenExpiresAt = new Date(tokenExpiresAt);
}
if (finalPlatformId) {
console.log('🔗 Using existing platform ID:', finalPlatformId);
shopData.platformId = finalPlatformId;
} else if (isMarketplaceFlow && clientId && clientSecret && appName && adapterSlug) {
// Create platform inline using dynamic adapter slug
console.log('📦 Creating platform inline with adapter:', adapterSlug);
shopData.platform = {
create: {
name: appName + ' (Auto-created)',
@@ -211,19 +196,21 @@ export function CreateShopFromURL({ onShopCreated, searchParams }: CreateShopFro
deleteWebhookFunction: adapterSlug,
}
};
console.log('📦 Platform inline creation data:', shopData.platform.create);
} else {
console.log('❌ No platform connection - this will cause an error');
console.log('❌ Missing: finalPlatformId?', !finalPlatformId, 'isMarketplaceFlow?', !isMarketplaceFlow, 'clientId?', !clientId, 'adapterSlug?', !adapterSlug);
}
console.log('🏪 Final shopData before createShop call:', JSON.stringify(shopData, null, 2));
const shopResult = await createShop(shopData);
if (shopResult.success) {
toast.success('Shop connected successfully!');
setIsDialogOpen(false);
// Clean URL params after successful creation
const newUrl = new URL(window.location.href);
['showCreateShop', 'platform', 'accessToken', 'domain', 'client_id', 'client_secret', 'app_name', 'adapter_slug', 'refreshToken', 'tokenExpiresAt', 'code'].forEach(param => {
newUrl.searchParams.delete(param);
});
router.replace(newUrl.pathname + newUrl.search);
router.refresh();
if (onShopCreated) {
@@ -259,10 +246,10 @@ export function CreateShopFromURL({ onShopCreated, searchParams }: CreateShopFro
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Connect OpenFront Store</DialogTitle>
<DialogTitle>Connect Openfront Store</DialogTitle>
<DialogDescription>
{isMarketplaceFlow
? 'Setting up your OpenFront integration. We\'ll create the platform if needed and connect your store.'
? 'Setting up your Openfront integration. We\'ll create the platform if needed and connect your store.'
: 'Complete your shop setup with the OAuth credentials received.'
}
</DialogDescription>
@@ -308,7 +295,7 @@ export function CreateShopFromURL({ onShopCreated, searchParams }: CreateShopFro
{isMarketplaceFlow && (
<div className="text-sm bg-zinc-50 dark:bg-zinc-950/20 p-3 rounded-lg border border-zinc-200 dark:border-zinc-800">
<p className="font-medium text-zinc-900 dark:text-zinc-100 mb-1">
openfront Integration
Openfront Integration
</p>
<p className="text-zinc-700 dark:text-zinc-300 text-xs">
This platform will be created with the shop.