Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

initialize dev docs for loyalty extension #2486

Open
wants to merge 1 commit into
base: unstable
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import React, {useState} from 'react';

import {
reactExtension,
POSBlock,
Text,
POSBlockRow,
useApi,
Button,
} from '@shopify/ui-extensions-react/point-of-sale';

import {useLoyaltyPoints} from './useLoyaltyPoints';
import {applyDiscount} from './applyDiscount';

// 1. Define discount tiers and available discounts
// For development purposes, we'll use a local server
export const serverUrl = 'SERVER URL HERE';

const discountTiers = [
{pointsRequired: 100, discountValue: 5},
{pointsRequired: 200, discountValue: 10},
{pointsRequired: 300, discountValue: 15},
];

const LoyaltyPointsBlock = () => {
// 2. Initialize API
const api = useApi<'pos.customer-details.block.render'>();
const customerId = api.customer.id;
const [pointBalance, setPointBalance] = useState<number | null>(null);

// 3. Pass setPointBalance to useLoyaltyPoints to calculate the point balance
const {loading} = useLoyaltyPoints(api, customerId, setPointBalance);

// 4. Filter available discounts based on point balance
const availableDiscounts = pointBalance
? discountTiers.filter((tier) => pointBalance >= tier.pointsRequired)
: [];

if (loading) {
return <Text>Loading...</Text>;
}
Comment on lines +39 to +41
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this end up looking like in a Block extension? Does it even work? I think you may need to wrap this in a POSBlock at least, and perhaps a POSBlockRow, too?


if (pointBalance === null) {
return (
<POSBlock>
<POSBlockRow>
<Text color="TextWarning">Unable to fetch point balance.</Text>
</POSBlockRow>
</POSBlock>
);
}
return (
<POSBlock>
<POSBlockRow>
<Text variant="headingLarge" color="TextSuccess">
{/* 5. Display the point balance */}
Point Balance:{pointBalance}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this missing a space?

</Text>
</POSBlockRow>

{availableDiscounts.length > 0 ? (
<>
<POSBlockRow>
<Text variant="headingSmall">Available Discounts:</Text>
{/* 6. Display available discounts as buttons, calling applyDiscount */}
{availableDiscounts.map((tier) => (
<POSBlockRow key={tier.pointsRequired}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you try a List component here? Just wondering if it works and if it looks better than stacking POSBlockRows

<Button
title={`Redeem $${tier.discountValue} Discount (Use ${tier.pointsRequired} points)`}
type="primary"
onPress={() =>
applyDiscount(
api,
customerId,
tier.discountValue,
tier.pointsRequired,
setPointBalance,
)
}
/>
</POSBlockRow>
))}
</POSBlockRow>
</>
) : (
<POSBlockRow>
<Text variant="headingSmall" color="TextWarning">
No available discounts.
</Text>
</POSBlockRow>
)}
</POSBlock>
);
};
// 7. Render the LoyaltyPointsBlock component at the appropriate target
export default reactExtension('pos.customer-details.block.render', () => (
<LoyaltyPointsBlock />
));
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type {useApi} from '@shopify/ui-extensions-react/point-of-sale';
import {serverUrl} from './LoyaltyPointsBlock';
export const applyDiscount = async (
api: ReturnType<typeof useApi>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does ReturnType do here? I think UI Extensions exports the correct type somewhere. I think it's ApiForRenderExtension<Target>

customerId: number,
discountValue: number,
pointsToDeduct: number,
setPointBalance: React.Dispatch<React.SetStateAction<number | null>>,
) => {
// 1. Apply discount to cart using the Cart API
try {
api.cart.applyCartDiscount(
'FixedAmount',
'Loyalty Discount',
discountValue.toString(),
);

const sessionToken = await api.session.getSessionToken();

// 2. Deduct points from the backend
const response = await fetch(`${serverUrl}/points/${customerId}/deduct`, {
method: 'POST',
headers: {
Authorization: `Bearer ${sessionToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({pointsToDeduct}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to deduct points: ${errorText}`);
}
// 3. Update the point balance in the state
setPointBalance((prev) => (prev !== null ? prev - pointsToDeduct : null));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's good practice to let the server respond with the updated total here, so the math isn't being done in two places.


console.log('Points deducted successfully');
} catch (error) {
console.error('Error deducting points:', error);
}
Comment on lines +37 to +39
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a little weird to throw and catch in the same function. You should prefer an early return instead, so I'd log the failure on line 31 and then just return.

};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
shopify upgrade
shopify app generate extension
Comment on lines +1 to +2
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can leave this out

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type {ActionFunction} from '@remix-run/node';
import {json} from '@remix-run/node';
import {addRedeemedPoints} from '../services/redeemedPoints.server';
import {authenticate} from 'app/shopify.server';

export const action: ActionFunction = async ({request, params}) => {
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
},
});
}

// 1. Authenticate the request
try {
await authenticate.admin(request);
} catch (error) {
console.error('Authentication failed:', error);
return new Response('Unauthorized', {status: 401});
}
Comment on lines +18 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you let this error propagate, doesn't it result in a 401? In other words, what happens if you don't catch this error?


// 2. Get the customer ID from the params
const {customerId} = params;

const {pointsToDeduct} = await request.json();
if (!customerId) {
throw new Error('Customer ID is required');
}

await addRedeemedPoints(customerId, pointsToDeduct);

return json({message: 'Points deducted successfully'});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type {LoaderFunctionArgs} from '@remix-run/node';
import {authenticate} from '../shopify.server';
import {json} from '@remix-run/node';
import {fetchCustomerTotal} from './fetchCustomer';
import {getRedeemedPoints} from '../services/redeemedPoints.server';

export const loader = async ({request, params}: LoaderFunctionArgs) => {
// 1. Authenticate the request
await authenticate.admin(request);

// 2. Get the customer ID from the params
const {customerId} = params;

if (!customerId) {
throw new Response('Customer ID is required', {status: 400});
}
// 3. Fetch the customer total
const data = await fetchCustomerTotal(request, customerId);

if (data === null) {
throw new Response('Order not found', {status: 404});
}

// 4. Fetch the redeemed points
const totalRedeemedPoints = await getRedeemedPoints(customerId);

// 5. Convert the customer total to points, subtracting the redeemed points
const pointBalance = data * 10 - totalRedeemedPoints;

return json(
{pointBalance},
{
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
},
},
);
};

export default null;
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import prisma from '../db.server';

export async function getRedeemedPoints(customerId: string): Promise<number> {
const record = await prisma.redeemedPoints.findUnique({
where: {customerId},
});
return record ? record.pointsRedeemed : 0;
}

export async function addRedeemedPoints(
customerId: string,
points: number,
): Promise<void> {
await prisma.redeemedPoints.upsert({
where: {customerId},
update: {pointsRedeemed: {increment: points}},
create: {customerId, pointsRedeemed: points},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {authenticate} from 'app/shopify.server';

export async function fetchCustomerTotal(request: Request, customerId: string) {
try {
// 1. Authenticate the request
const {admin} = await authenticate.admin(request);

// 2. Format the customer ID
const formattedCustomerId = `gid://shopify/Customer/${customerId}`;

// 3. Fetch the customer's orders
const response = await admin.graphql(
`#graphql
query GetCustomerOrders($customerId: ID!) {
customer(id: $customerId) {
orders(first: 100) {
edges {
node {
id
currentSubtotalPriceSet {
shopMoney {
amount
currencyCode
}
}
}
}
}
}
}`,
{variables: {customerId: formattedCustomerId}},
);
// 4. Parse the response and handle erorrs
const data = (await response.json()) as {data?: any; errors?: any[]};
let grandTotal = 0;

if (data.errors) {
console.error('GraphQL Errors:', data.errors);
data.errors.forEach((error: any) => {
console.error('GraphQL Error Details:', error);
});
return null;
}

if (!response.ok) {
console.error('Network Error:', response.statusText);
return null;
}

const orders = data.data.customer.orders;
if (!orders) {
console.error('No orders found for customer');
return null;
}
// 5. Calculate the grand total and return
for (const edge of orders.edges) {
const amountString = edge.node.currentSubtotalPriceSet.shopMoney.amount;
if (amountString) {
grandTotal += parseFloat(amountString);
}
}

return grandTotal;
} catch (error) {
console.error('Error fetching data:', error);
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
api_version = "2024-10"

[[extensions]]
type = "ui_extension"
name = "loyalty-extension"

handle = "loyalty-extension"
description = "An example loyalty extension"

[[extensions.targeting]]
module = "./src/LoyaltyPointsBlock.tsx"
target = "pos.customer-details.block.render"

Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {useEffect, useState} from 'react';
import {serverUrl} from './LoyaltyPointsBlock';

export const useLoyaltyPoints = (
api: any,
customerId: number,
setPointBalance: React.Dispatch<React.SetStateAction<number | null>>,
) => {
const [loading, setLoading] = useState(true);

// 1. Fetch the points total from the backend
useEffect(() => {
const fetchOrderData = async () => {
try {
// Get the session token
const sessionToken = await api.session.getSessionToken();

const response = await fetch(`${serverUrl}/points/${customerId}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${sessionToken}`,
},
});

if (!response.ok) {
const errorText = await response.text();
console.error('Error Response Text:', errorText);
throw new Error(`Failed to fetch order data: ${errorText}`);
}

const data = await response.json();

if (typeof data.totalPoints === 'number') {
// 2. Update the points total in the state
setPointBalance(data.totalPoints);
} else {
console.error('No points available in the response.');
}
} catch (error) {
console.error('Error fetching order data in client:', error);
} finally {
setLoading(false);
}
};

fetchOrderData();
}, [api, customerId, setPointBalance]);

return {loading};
};
Loading
Loading