Skip to content
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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,39 @@ Example: I have created a pages directory and added a `contact.vue` file in the
Here is a [branch](https://github.com/scottyzen/woonuxt/tree/myshop) with an example of some basic customizations:
And here is the live demo of the customized WooNuxt site: [My Shop](https://myshop.woonuxt.com/).

### Location Hooks System πŸͺ

WooNuxt now includes a **Location Hooks** system that provides WordPress-like extensibility for headless storefronts. Instead of overriding entire components, you can inject custom UI and logic at predefined extension points throughout the site.

**Key Features:**

- βœ… **SSR/SSG Compatible** - Hooks render in static builds
- βœ… **SEO Friendly** - Content included in initial HTML
- βœ… **Type-safe** - Full TypeScript support
- βœ… **Layer Compatible** - Works seamlessly with Nuxt layers

**Available Hook Locations** (14 outlets):

- Layout (header/footer)
- Product pages (title, price, gallery, tabs)
- Cart (line items, totals)
- Checkout (customer, shipping, payment, review)

**Quick Example:**

```ts
// plugins/my-hooks.ts
import TrustBadge from "~/components/TrustBadge.vue";

export default defineNuxtPlugin(() => {
registerHook({
name: "product.summary.afterPrice",
id: "trust-badge",
renderer: TrustBadge,
});
});
```

### Progress

| Feature | Ongoing Enhancements | In the Pipeline | In Progress | Done | Next |
Expand Down
76 changes: 76 additions & 0 deletions woonuxt_base/app/components/HookOutlet.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<script setup lang="ts" generic="T extends HookName">
import type { Component } from 'vue';
import type { HookName, HookContext } from '../composables/useHooks';

interface Props {
/** The name of the hook outlet */
name: T;
/** Context data to pass to hook renderers */
ctx?: HookContext<T>;
/** Optional wrapper element or component */
as?: string | Component;
/** Whether this outlet is required (warns in dev if no hooks registered) */
required?: boolean;
Comment on lines +12 to +13
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

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

The 'required' prop is defined in the Props interface with documentation stating it "warns in dev if no hooks registered", but this prop is never actually used in the component logic. Either implement the warning functionality or remove this unused prop to avoid confusion.

Copilot uses AI. Check for mistakes.
}

const props = withDefaults(defineProps<Props>(), {
ctx: () => ({}) as HookContext<T>,
as: 'div',
required: false,
});

const { get } = useHooks();

// Warn in development if required outlet has no hooks
if (process.env.NODE_ENV !== 'production' && props.required) {
watchEffect(() => {
const allEntries = get(props.name);
if (allEntries.length === 0) {
console.warn(`[HookOutlet] Required outlet "${props.name}" has no hooks registered.`);
}
});
}

// Get hook entries for this outlet
const entries = computed(() => {
const allEntries = get(props.name);

// Filter by when condition
return allEntries.filter((entry: any) => {
if (!entry.when) return true;
try {
return entry.when(props.ctx);
} catch (error) {
if (process.env.NODE_ENV !== 'production') {
console.error(`[HookOutlet] Error evaluating "when" condition for hook "${entry.id}" at outlet "${props.name}":`, error);
}
return false;
Comment on lines +43 to +47
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

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

Errors during the when condition evaluation are silently caught and treated as false. This could hide bugs in conditional logic. Consider logging these errors in development mode to help developers identify issues with their conditional functions.

Copilot uses AI. Check for mistakes.
}
});
});

/**
* Render a single hook entry
*/
const renderEntry = (entry: any) => {
try {
if (typeof entry.renderer === 'function') {
return entry.renderer(props.ctx);
}
return h(entry.renderer, { ctx: props.ctx });
} catch (error) {
if (process.env.NODE_ENV !== 'production') {
console.error(`[HookOutlet] Error rendering hook "${entry.id}" at outlet "${props.name}":`, error);
}
return null;
}
Comment on lines +61 to +66
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

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

Errors during hook rendering are silently caught and return null. While this prevents hooks from breaking the page, it makes debugging difficult. Consider logging errors in development mode so developers can identify and fix issues with their hook implementations.

Copilot uses AI. Check for mistakes.
};
</script>

<template>
<template v-if="entries.length > 0">
<component :is="as">
<component :is="() => renderEntry(entry)" v-for="entry in entries" :key="entry.id" />
</component>
</template>
</template>
6 changes: 5 additions & 1 deletion woonuxt_base/app/components/cartElements/CartCard.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup>
const { updateItemQuantity } = useCart();
const { updateItemQuantity, cart } = useCart();
const { addToWishlist } = useWishlist();
const { FALLBACK_IMG } = useHelpers();
const { storeSettings } = useAppConfig();
Expand Down Expand Up @@ -46,6 +46,10 @@ const moveToWishList = () => {
<NuxtLink class="leading-tight line-clamp-2 text-gray-900 dark:text-gray-100 hover:text-primary dark:hover:text-primary" :to="productSlug">{{
productType.name
}}</NuxtLink>

<!-- Hook: After cart line item name -->
<HookOutlet name="cart.lineItem.afterName" :ctx="{ item, cart }" as="span" />

<span
v-if="productType.salePrice"
class="text-[10px] border-green-200 dark:border-green-800 leading-none bg-green-100 dark:bg-green-900/30 inline-block p-0.5 rounded-sm text-green-600 dark:text-green-400 border">
Expand Down
24 changes: 24 additions & 0 deletions woonuxt_base/app/components/examples/CartUpsell.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<script setup lang="ts">
// Example hook: Cart upsell suggestion
const props = defineProps<{ ctx: { cart: any } }>();

// Example: Suggest free shipping threshold
const freeShippingThreshold = 50;
const subtotal = computed(() => {
if (!props.ctx?.cart) return 0;
const totalString = props.ctx.cart.subtotal?.replace(/[^0-9.]/g, '') || '0';
return parseFloat(totalString);
});

const runtimeConfig = useRuntimeConfig();
const currencySymbol = runtimeConfig?.public?.CURRENCY_SYMBOL || '$';

const remaining = computed(() => Math.max(0, freeShippingThreshold - subtotal.value));
const hasReachedFreeShipping = computed(() => remaining.value === 0);
</script>

<template>
<div v-if="!hasReachedFreeShipping" class="p-3 text-sm text-center items-center text-gray-700 bg-gray-100 rounded-lg dark:text-blue-300 leading-loose">
Add <strong>{{ currencySymbol }}{{ remaining.toFixed(2) }}</strong> more for <strong>FREE shipping</strong>
</div>
</template>
6 changes: 6 additions & 0 deletions woonuxt_base/app/components/generalElements/AppFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ const { wishlistLink } = useAuth();

<template>
<footer class="bg-white dark:bg-gray-800 order-last">
<!-- Hook: Top of footer -->
<HookOutlet name="layout.footer.top" as="div" class="container pt-8" />

<div class="container flex flex-wrap justify-between gap-12 my-24 md:gap-24">
<div class="mr-auto">
<Logo />
Expand Down Expand Up @@ -99,6 +102,9 @@ const { wishlistLink } = useAuth();
</div>
<SocialIcons class="ml-auto" />
</div>

<!-- Hook: Bottom of footer -->
<HookOutlet name="layout.footer.bottom" as="div" class="container pb-8" />
</footer>
</template>

Expand Down
7 changes: 7 additions & 0 deletions woonuxt_base/app/components/generalElements/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,18 @@ const { isShowingSearch } = useSearching();
<template>
<header class="sticky top-0 z-40 bg-white dark:bg-gray-800 shadow-sm shadow-gray-200 dark:shadow-gray-900 border-b border-transparent dark:border-gray-700">
<div class="container flex items-center justify-between py-4">
<!-- Hook: Before header navigation -->
<HookOutlet name="layout.header.beforeNav" as="div" />

<div class="flex items-center">
<MenuTrigger class="lg:hidden" />
<Logo class="w-40" />
</div>
<MainMenu class="items-center hidden gap-6 text-sm text-gray-500 dark:text-gray-400 lg:flex lg:px-4" />

<!-- Hook: After header navigation -->
<HookOutlet name="layout.header.afterNav" as="div" />

<div class="flex justify-end items-center w-40 flex-1 ml-auto gap-4 md:gap-6">
<ProductSearch class="hidden sm:inline-flex max-w-80 w-[60%]" />
<SearchTrigger />
Expand Down
6 changes: 6 additions & 0 deletions woonuxt_base/app/components/shopElements/Cart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,17 @@ const { cart, toggleCart, isUpdatingCart } = useCart();
<CartCard v-for="item in cart.contents?.nodes" :key="item.key" :item />
</ul>
<div class="px-6 pb-8 mb-safe md:px-8">
<!-- Hook: Before cart totals -->
<HookOutlet name="cart.summary.beforeTotals" :ctx="{ cart }" as="div" class="mb-4" />

<Button :to="cart && !cart.isEmpty ? '/checkout' : '/shop'" class="w-full" size="lg" variant="primary" @click="toggleCart()">
<span class="mx-2" v-if="cart && !cart.isEmpty">{{ $t('shop.checkout') }}</span>
<span class="mx-2" v-else>{{ $t('shop.continueShopping') }}</span>
<span v-if="cart && !cart.isEmpty" v-html="cart.total" />
</Button>

<!-- Hook: After cart totals -->
<HookOutlet name="cart.summary.afterTotals" :ctx="{ cart }" as="div" class="mt-4" />
</div>
</template>
<!-- Empty Cart Message -->
Expand Down
Loading