@dargmuesli/nuxt-vio 3.0.0-beta.2 → 3.0.0-beta.3

Sign up to get free protection for your applications and to get access to all the features.
package/app.config.ts CHANGED
@@ -2,11 +2,8 @@ import { useServerSeoMeta } from '@unhead/vue'
2
2
 
3
3
  export default defineAppConfig({
4
4
  vio: {
5
- legalNotice: undefined,
6
- privacyPolicy: undefined,
7
- seoMeta: {
8
- twitterSite: '@dargmuesli',
9
- },
5
+ pages: undefined,
6
+ seoMeta: undefined,
10
7
  server: {
11
8
  middleware: {
12
9
  headers: {
@@ -37,55 +34,58 @@ export default defineAppConfig({
37
34
  },
38
35
  },
39
36
  },
40
- themeColor: '#202020',
37
+ stagingHost: undefined,
38
+ themeColor: undefined,
41
39
  },
42
40
  })
43
41
 
44
42
  declare module 'nuxt/schema' {
45
- interface AppConfig {
43
+ interface AppConfigInput {
46
44
  vio: {
47
- legalNotice?: {
48
- contact: {
49
- email: string
50
- }
51
- responsibility: {
52
- address: {
53
- city: string
54
- name: string
55
- street: string
56
- }
57
- }
58
- tmg: {
59
- address: {
60
- city: string
61
- name: string
62
- street: string
45
+ pages?: {
46
+ legalNotice?: {
47
+ contact: {
48
+ email: string
63
49
  }
64
- }
65
- }
66
- privacyPolicy?: {
67
- hostingCdn?: {
68
- external: {
50
+ responsibility: {
69
51
  address: {
70
52
  city: string
71
53
  name: string
72
54
  street: string
73
55
  }
74
56
  }
75
- }
76
- mandatoryInfo?: {
77
- responsible: {
57
+ tmg: {
78
58
  address: {
79
59
  city: string
80
- email: string
81
60
  name: string
82
61
  street: string
83
62
  }
84
63
  }
85
64
  }
65
+ privacyPolicy?: {
66
+ hostingCdn?: {
67
+ external: {
68
+ address: {
69
+ city: string
70
+ name: string
71
+ street: string
72
+ }
73
+ }
74
+ }
75
+ mandatoryInfo?: {
76
+ responsible: {
77
+ address: {
78
+ city: string
79
+ email: string
80
+ name: string
81
+ street: string
82
+ }
83
+ }
84
+ }
85
+ }
86
86
  }
87
87
  seoMeta?: Parameters<typeof useServerSeoMeta>[0]
88
- server: {
88
+ server?: {
89
89
  middleware: {
90
90
  headers: {
91
91
  csp: {
@@ -95,6 +95,7 @@ declare module 'nuxt/schema' {
95
95
  }
96
96
  }
97
97
  }
98
+ stagingHost?: string
98
99
  themeColor?: string
99
100
  }
100
101
  }
@@ -3,7 +3,7 @@
3
3
  <NuxtLayout>
4
4
  <!-- `NuxtLayout` can't have mulitple child nodes (https://github.com/nuxt/nuxt/issues/21759) -->
5
5
  <div>
6
- <NuxtPage />
6
+ <NuxtPage :site-description="siteDescriptionProp" />
7
7
  <CookieControl :locale="locale" />
8
8
  </div>
9
9
  </NuxtLayout>
@@ -29,6 +29,7 @@ const siteDescriptionProp = toRef(() => props.siteDescription)
29
29
  const { $dayjs } = useNuxtApp()
30
30
  const i18n = useI18n()
31
31
  const cookieControl = useCookieControl()
32
+ const siteConfig = useSiteConfig()
32
33
 
33
34
  const { loadingIds, indicateLoadingDone } = useLoadingDoneIndicator('app')
34
35
 
@@ -68,7 +69,6 @@ watch(
68
69
  )
69
70
 
70
71
  // initialization
71
- init()
72
72
  updateSiteConfig({
73
73
  description: siteDescriptionProp.value,
74
74
  })
@@ -78,5 +78,14 @@ defineOgImage({
78
78
  description: siteDescriptionProp.value,
79
79
  })
80
80
  useAppLayout()
81
- useFavicons()
81
+ useFavicons() // TODO: move to head default
82
+ useSchemaOrg([
83
+ defineWebSite({
84
+ description: siteDescriptionProp,
85
+ inLanguage: locale,
86
+ name: siteConfig.name,
87
+ }),
88
+ defineWebPage(),
89
+ ])
90
+ init()
82
91
  </script>
@@ -12,7 +12,7 @@
12
12
  >
13
13
  <slot />
14
14
  </a>
15
- <NuxtLink
15
+ <NuxtLinkLocale
16
16
  v-else
17
17
  :aria-label="ariaLabel"
18
18
  :class="classes"
@@ -20,7 +20,7 @@
20
20
  @click="emit('click')"
21
21
  >
22
22
  <slot />
23
- </NuxtLink>
23
+ </NuxtLinkLocale>
24
24
  </template>
25
25
 
26
26
  <script setup lang="ts">
@@ -9,13 +9,13 @@
9
9
  <br />
10
10
  </template>
11
11
  <template #city>
12
- {{ appConfig.legalNotice.tmg.address.city }}
12
+ {{ appConfig.vio.pages.legalNotice.tmg.address.city }}
13
13
  </template>
14
14
  <template #name>
15
- {{ appConfig.legalNotice.tmg.address.name }}
15
+ {{ appConfig.vio.pages.legalNotice.tmg.address.name }}
16
16
  </template>
17
17
  <template #street>
18
- {{ appConfig.legalNotice.tmg.address.street }}
18
+ {{ appConfig.vio.pages.legalNotice.tmg.address.street }}
19
19
  </template>
20
20
  </i18n-t>
21
21
  </address>
@@ -24,7 +24,9 @@
24
24
  <p>
25
25
  <slot v-if="$slots.contactEmail" name="contactEmail" />
26
26
  <span v-else>
27
- {{ t('email', { email: appConfig.legalNotice.contact.email }) }}
27
+ {{
28
+ t('email', { email: appConfig.vio.pages.legalNotice.contact.email })
29
+ }}
28
30
  </span>
29
31
  </p>
30
32
 
@@ -36,13 +38,13 @@
36
38
  <br />
37
39
  </template>
38
40
  <template #city>
39
- {{ appConfig.legalNotice.responsibility.address.city }}
41
+ {{ appConfig.vio.pages.legalNotice.responsibility.address.city }}
40
42
  </template>
41
43
  <template #name>
42
- {{ appConfig.legalNotice.responsibility.address.name }}
44
+ {{ appConfig.vio.pages.legalNotice.responsibility.address.name }}
43
45
  </template>
44
46
  <template #street>
45
- {{ appConfig.legalNotice.responsibility.address.street }}
47
+ {{ appConfig.vio.pages.legalNotice.responsibility.address.street }}
46
48
  </template>
47
49
  </i18n-t>
48
50
  </address>
@@ -64,14 +64,21 @@
64
64
  <br />
65
65
  </template>
66
66
  <template #city>
67
- {{ appConfig.privacyPolicy.hostingCdn.external.address.city }}
67
+ {{
68
+ appConfig.vio.pages.privacyPolicy.hostingCdn.external
69
+ .address.city
70
+ }}
68
71
  </template>
69
72
  <template #name>
70
- {{ appConfig.privacyPolicy.hostingCdn.external.address.name }}
73
+ {{
74
+ appConfig.vio.pages.privacyPolicy.hostingCdn.external
75
+ .address.name
76
+ }}
71
77
  </template>
72
78
  <template #street>
73
79
  {{
74
- appConfig.privacyPolicy.hostingCdn.external.address.street
80
+ appConfig.vio.pages.privacyPolicy.hostingCdn.external
81
+ .address.street
75
82
  }}
76
83
  </template>
77
84
  </i18n-t>
@@ -109,29 +116,29 @@
109
116
  </template>
110
117
  <template #city>
111
118
  {{
112
- appConfig.privacyPolicy.mandatoryInfo.responsible.address
113
- .city
119
+ appConfig.vio.pages.privacyPolicy.mandatoryInfo.responsible
120
+ .address.city
114
121
  }}
115
122
  </template>
116
123
  <template #email>
117
124
  {{
118
125
  t('email', {
119
126
  email:
120
- appConfig.privacyPolicy.mandatoryInfo.responsible
121
- .address.email,
127
+ appConfig.vio.pages.privacyPolicy.mandatoryInfo
128
+ .responsible.address.email,
122
129
  })
123
130
  }}
124
131
  </template>
125
132
  <template #name>
126
133
  {{
127
- appConfig.privacyPolicy.mandatoryInfo.responsible.address
128
- .name
134
+ appConfig.vio.pages.privacyPolicy.mandatoryInfo.responsible
135
+ .address.name
129
136
  }}
130
137
  </template>
131
138
  <template #street>
132
139
  {{
133
- appConfig.privacyPolicy.mandatoryInfo.responsible.address
134
- .street
140
+ appConfig.vio.pages.privacyPolicy.mandatoryInfo.responsible
141
+ .address.street
135
142
  }}
136
143
  </template>
137
144
  </i18n-t>
@@ -1,5 +1,6 @@
1
1
  export const useAppLayout = () => {
2
2
  const appConfig = useAppConfig()
3
+ const siteConfig = useSiteConfig()
3
4
 
4
5
  useServerHeadSafe({
5
6
  ...useLocaleHead({ addSeoAttributes: true }).value,
@@ -7,23 +8,16 @@ export const useAppLayout = () => {
7
8
  class:
8
9
  'bg-background-bright dark:bg-background-dark font-sans text-text-dark dark:text-text-bright',
9
10
  },
10
- ...(appConfig.themeColor
11
- ? {
12
- meta: [
13
- {
14
- content: appConfig.themeColor,
15
- name: 'msapplication-TileColor',
16
- },
17
- {
18
- content: appConfig.themeColor,
19
- name: 'theme-color',
20
- },
21
- ],
22
- }
23
- : {}),
24
11
  })
25
12
 
26
- if (appConfig.seoMeta) {
27
- useServerSeoMeta(appConfig.seoMeta)
28
- }
13
+ useServerSeoMeta({
14
+ msapplicationTileColor: appConfig.vio.themeColor,
15
+ themeColor: appConfig.vio.themeColor,
16
+ titleTemplate: (titleChunk) => {
17
+ return titleChunk && titleChunk !== siteConfig.name
18
+ ? `${titleChunk} ${siteConfig.titleSeparator} ${siteConfig.name}`
19
+ : siteConfig.name
20
+ },
21
+ ...appConfig.vio.seoMeta,
22
+ })
29
23
  }
@@ -4,46 +4,18 @@ export const useFavicons = () => {
4
4
  useServerHeadSafe({
5
5
  link: [
6
6
  {
7
- href: '/assets/static/favicon/apple-touch-icon.png?v=bOXMwoKlJr',
8
- rel: 'apple-touch-icon',
9
- sizes: '180x180',
10
- },
11
- {
12
- href: '/assets/static/favicon/favicon-16x16.png?v=bOXMwoKlJr',
13
- rel: 'icon',
14
- sizes: '16x16',
15
- type: 'image/png',
16
- },
17
- {
18
- href: '/assets/static/favicon/favicon-32x32.png?v=bOXMwoKlJr',
19
- rel: 'icon',
20
- sizes: '32x32',
21
- type: 'image/png',
22
- },
23
- {
24
- href: '/favicon.ico',
25
- rel: 'icon',
26
- type: 'image/x-icon',
27
- },
28
- {
29
- href: '/assets/static/favicon/site.webmanifest?v=bOXMwoKlJr',
7
+ href: `/assets/static/favicon/site.webmanifest?v=${CACHE_VERSION}`,
30
8
  rel: 'manifest',
31
9
  },
32
10
  {
33
11
  color: appConfig.vio.themeColor,
34
- href: '/assets/static/favicon/safari-pinned-tab.svg?v=bOXMwoKlJr',
12
+ href: `/assets/static/favicon/safari-pinned-tab.svg?v=${CACHE_VERSION}`,
35
13
  rel: 'mask-icon',
36
14
  },
37
15
  {
38
- href: '/favicon.ico?v=bOXMwoKlJr',
16
+ href: `/favicon.ico?v=${CACHE_VERSION}`,
39
17
  rel: 'shortcut icon',
40
18
  },
41
19
  ],
42
- meta: [
43
- {
44
- content: '/assets/static/favicon/browserconfig.xml?v=bOXMwoKlJr',
45
- name: 'msapplication-config',
46
- },
47
- ],
48
20
  })
49
21
  }
@@ -1,6 +1,6 @@
1
1
  export const useGetServiceHref = () => {
2
2
  const host = useHost()
3
- const config = useRuntimeConfig()
3
+ const appConfig = useAppConfig()
4
4
 
5
5
  return ({
6
6
  isSsr = true,
@@ -16,32 +16,6 @@ export const useGetServiceHref = () => {
16
16
  isSsr,
17
17
  name,
18
18
  port,
19
- stagingHost: config.public.stagingHost,
19
+ stagingHost: appConfig.vio.stagingHost,
20
20
  })
21
21
  }
22
-
23
- export const getServiceHref = ({
24
- host,
25
- isSsr = true,
26
- name,
27
- port,
28
- stagingHost,
29
- }: {
30
- host: string
31
- isSsr?: boolean
32
- name?: string
33
- port?: number
34
- stagingHost?: string
35
- }) => {
36
- const nameSubdomain = name?.replaceAll('_', '-')
37
- const nameSubdomainString = nameSubdomain ? `${nameSubdomain}.` : ''
38
- const portString = port ? `:${port}` : ''
39
-
40
- if (stagingHost) {
41
- return `https://${nameSubdomainString}${stagingHost}`
42
- } else if (isSsr && process.server) {
43
- return `http://${name}${portString}`
44
- } else {
45
- return `https://${nameSubdomainString}${getDomainTldPort(host)}`
46
- }
47
- }
@@ -1,34 +1,21 @@
1
1
  import { defu } from 'defu'
2
- import type { UseHeadSafeInput } from '@unhead/vue'
3
2
  import type { ComputedRef } from 'vue'
4
3
 
5
- export const useHeadDefault = (
6
- title: string | ComputedRef<string>,
7
- extension?: UseHeadSafeInput,
8
- ) => {
9
- const host = useHost()
10
- const router = useRouter()
4
+ export const useHeadDefault = ({
5
+ extension,
6
+ title,
7
+ }: {
8
+ extension?: Parameters<typeof useServerSeoMeta>[0]
9
+ title: string | ComputedRef<string>
10
+ }) => {
11
+ const attrs = useAttrs()
11
12
 
12
- const defaults: UseHeadSafeInput = {
13
- meta: [
14
- {
15
- id: 'og:title',
16
- property: 'og:title',
17
- content: title,
18
- },
19
- {
20
- id: 'og:url',
21
- property: 'og:url',
22
- content: `https://${host}${router.currentRoute.value.fullPath}`,
23
- },
24
- {
25
- id: 'twitter:title',
26
- property: 'twitter:title',
27
- content: title,
28
- },
29
- ],
13
+ const defaults: Parameters<typeof useServerSeoMeta>[0] = {
14
+ msapplicationConfig: `/assets/static/favicon/browserconfig.xml?v=${CACHE_VERSION}`,
30
15
  title,
16
+ twitterDescription: attrs['site-description'] as string,
17
+ twitterTitle: title,
31
18
  }
32
19
 
33
- return useServerHeadSafe(defu(extension, defaults))
20
+ return useSeoMeta(defu(extension, defaults)) // TODO: use `useServerSeoMeta`
34
21
  }
package/nuxt.config.ts CHANGED
@@ -11,7 +11,8 @@ import {
11
11
  const currentDir = dirname(fileURLToPath(import.meta.url))
12
12
 
13
13
  const BASE_URL =
14
- 'https://' +
14
+ (process.env.NUXT_PUBLIC_STACK_DOMAIN ? 'https' : 'http') +
15
+ '://' +
15
16
  (process.env.NUXT_PUBLIC_STACK_DOMAIN ||
16
17
  `${process.env.HOST || 'localhost'}:${
17
18
  !process.env.NODE_ENV || process.env.NODE_ENV === 'development'
@@ -29,13 +30,19 @@ export default defineNuxtConfig({
29
30
  htmlAttrs: {
30
31
  lang: 'en', // fallback data to prevent invalid html at generation
31
32
  },
32
- titleTemplate: `%s`,
33
33
  title: SITE_NAME, // fallback data to prevent invalid html at generation
34
+ titleTemplate: '%s', // fully set in `composables/useAppLayout.ts`
34
35
  },
35
36
  pageTransition: {
36
37
  name: 'layout',
37
38
  },
38
39
  },
40
+ devtools: {
41
+ enabled: process.env.NODE_ENV !== 'production',
42
+ timeline: {
43
+ enabled: true,
44
+ },
45
+ },
39
46
  modules: [
40
47
  '@dargmuesli/nuxt-cookie-control',
41
48
  '@nuxtjs/color-mode',
@@ -56,16 +63,10 @@ export default defineNuxtConfig({
56
63
  },
57
64
  isInProduction: process.env.NODE_ENV === 'production',
58
65
  isTesting: false,
59
- stagingHost:
60
- process.env.NODE_ENV !== 'production' &&
61
- !process.env.NUXT_PUBLIC_STACK_DOMAIN
62
- ? 'jonas-thelemann.de'
63
- : undefined,
64
66
  },
65
67
  },
66
68
  typescript: {
67
69
  shim: false,
68
- strict: true,
69
70
  // tsConfig: {
70
71
  // compilerOptions: {
71
72
  // esModuleInterop: true,
@@ -136,7 +137,7 @@ export default defineNuxtConfig({
136
137
  locales: ['en', 'de'],
137
138
  },
138
139
  htmlValidator: {
139
- // failOnError: true,
140
+ failOnError: false, // TODO: fix invalid html in nuxt html template (https://github.com/nuxt/nuxt/issues/22526)
140
141
  logLevel: 'warning',
141
142
  },
142
143
  i18n: {
@@ -148,7 +149,7 @@ export default defineNuxtConfig({
148
149
  },
149
150
  },
150
151
  linkChecker: {
151
- failOnError: false, // TODO: enable (https://github.com/harlan-zw/nuxt-seo-kit/issues/4#issuecomment-1434522124)
152
+ failOnError: true,
152
153
  },
153
154
  seoKit: {
154
155
  splash: false,
@@ -156,6 +157,7 @@ export default defineNuxtConfig({
156
157
  site: {
157
158
  debug: process.env.NODE_ENV === 'development',
158
159
  name: SITE_NAME,
160
+ titleSeparator: '·',
159
161
  url: BASE_URL,
160
162
  },
161
163
  sitemap: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dargmuesli/nuxt-vio",
3
- "version": "3.0.0-beta.2",
3
+ "version": "3.0.0-beta.3",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -15,9 +15,11 @@
15
15
  "composables",
16
16
  "layouts",
17
17
  "locales",
18
- "server",
19
18
  "pages",
20
19
  "plugins",
20
+ "server",
21
+ "store",
22
+ "types",
21
23
  "utils",
22
24
  "app.config.ts",
23
25
  "error.vue",
@@ -25,23 +27,22 @@
25
27
  "nuxt.config.ts",
26
28
  "tailwind.config.ts"
27
29
  ],
28
- "main": "./nuxt.config.ts",
30
+ "main": "nuxt.config.ts",
29
31
  "scripts": {
30
32
  "dev": "nuxi dev .playground",
31
33
  "build": "nuxi build .playground",
32
34
  "generate": "nuxi generate .playground",
33
35
  "preview": "nuxi preview .playground",
34
- "prepare": "pnpm husky install && pnpm nuxt prepare .playground",
36
+ "prepare": "nuxi prepare .playground",
35
37
  "lint": "pnpm lint:js && pnpm lint:ts && pnpm lint:style",
36
38
  "lint:fix": "pnpm lint:js --fix && pnpm lint:ts --fix && pnpm lint:style --fix",
37
39
  "lint:js": "eslint --cache --ext .js,.ts,.vue --ignore-path .gitignore .",
38
- "lint:staged": "pnpm lint-staged",
40
+ "lint:staged": "lint-staged",
39
41
  "lint:style": "stylelint **/*.{vue,css} --ignore-path .gitignore",
40
- "lint:ts": "nuxt typecheck"
42
+ "lint:ts": "nuxi typecheck"
41
43
  },
42
44
  "dependencies": {
43
45
  "@dargmuesli/nuxt-cookie-control": "6.1.5",
44
- "@dargmuesli/nuxt-vio": "link:",
45
46
  "@http-util/status-i18n": "0.7.0",
46
47
  "@nuxtjs/color-mode": "3.3.0",
47
48
  "@nuxtjs/html-validator": "1.5.2",
@@ -59,15 +60,13 @@
59
60
  "dayjs": "1.11.9",
60
61
  "is-https": "4.0.0",
61
62
  "jose": "4.14.4",
62
- "marked": "7.0.0",
63
+ "marked": "7.0.1",
63
64
  "nuxt-seo-kit-module": "2.0.0-beta.9",
64
65
  "pinia": "2.1.6",
65
66
  "sweetalert2": "11.7.20",
66
67
  "vue-gtag": "2.0.1"
67
68
  },
68
69
  "devDependencies": {
69
- "@commitlint/cli": "17.6.7",
70
- "@commitlint/config-conventional": "17.6.7",
71
70
  "@intlify/eslint-plugin-vue-i18n": "3.0.0-next.3",
72
71
  "@nuxtjs/eslint-config-typescript": "12.0.0",
73
72
  "@types/marked": "5.0.1",
@@ -77,7 +76,6 @@
77
76
  "eslint-plugin-nuxt": "4.0.0",
78
77
  "eslint-plugin-prettier": "5.0.0",
79
78
  "eslint-plugin-yml": "1.8.0",
80
- "husky": "8.0.3",
81
79
  "lint-staged": "13.2.3",
82
80
  "nuxt": "3.6.5",
83
81
  "prettier": "3.0.1",
package/store/auth.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { decodeJwt, JWTPayload } from 'jose'
2
+ import { defineStore } from 'pinia'
3
+ import { ref } from 'vue'
4
+
5
+ export const useVioAuthStore = defineStore('vio-auth', () => {
6
+ const jwt = ref<string>()
7
+ const jwtDecoded = ref<JWTPayload>()
8
+ const signedInUsername = ref<string>()
9
+
10
+ const jwtRemove = () => jwtSet(undefined)
11
+
12
+ const jwtSet = (jwtNew?: string) => {
13
+ const jwtDecodedNew = jwtNew !== undefined ? decodeJwt(jwtNew) : undefined
14
+
15
+ jwt.value = jwtNew
16
+ jwtDecoded.value = jwtDecodedNew
17
+ signedInUsername.value =
18
+ jwtDecodedNew?.role === 'vio_account' &&
19
+ jwtDecodedNew.exp !== undefined &&
20
+ jwtDecodedNew.exp > Math.floor(Date.now() / 1000)
21
+ ? (jwtDecodedNew.username as string | undefined)
22
+ : undefined
23
+ }
24
+
25
+ return {
26
+ jwt,
27
+ jwtDecoded,
28
+ signedInUsername,
29
+ jwtRemove,
30
+ jwtSet,
31
+ }
32
+ })
package/types/api.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export type BackendError = CombinedError | { errcode: string; message: string }
2
+
3
+ export type ApiData = ComputedRef<{
4
+ data?: Object
5
+ errors: BackendError[]
6
+ isFetching: boolean
7
+ }>
@@ -0,0 +1,8 @@
1
+ export interface StrapiResult<T> {
2
+ data: CollectionItem<T>[]
3
+ meta: {
4
+ pagination: {
5
+ total: number
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,6 @@
1
+ declare module '*.gql' {
2
+ import { DocumentNode } from 'graphql'
3
+
4
+ const content: DocumentNode
5
+ export default content
6
+ }
@@ -0,0 +1,6 @@
1
+ declare module '*.graphql' {
2
+ import { DocumentNode } from 'graphql'
3
+
4
+ const content: DocumentNode
5
+ export default content
6
+ }
@@ -1,5 +1,6 @@
1
1
  export const SITE_NAME = 'Vio'
2
2
 
3
+ export const CACHE_VERSION = 'bOXMwoKlJr'
3
4
  export const COOKIE_PREFIX = SITE_NAME.toLocaleLowerCase()
4
5
  export const COOKIE_SEPARATOR = '_'
5
6
  export const FETCH_RETRY_AMOUNT = 3