From c419406a9a13cfc11766731777fc524ecbdda659 Mon Sep 17 00:00:00 2001 From: Aldino Kemal Date: Fri, 11 Oct 2024 23:33:24 +0700 Subject: [PATCH] feat: add newsletter (#204) * feat(newsletter): add newsletter service, endpoints, and UI components Add Newsletter service to support functionality for unfollowing newsletters Add Newsletter REST controller and routing Implement newsletter-related endpoints and methods in the User Service Create UnfollowRequest for the INewsletterService interface Add MyListNewsletterResponse to user's data fields Add newsletter validation Refactor JS components to support newsletter type and simplify recipient forms by moving logic to FormRecipient component Refactor window global constants to support newsletters Modify server to initialize and use the newsletter services Add UI component for listing newsletters Refactor existing components to use FormRecipient for recipient data input * chore: update documentation feat(openapi.yaml): add newsletter support with new paths and schemas docs(readme.md): update API endpoints including newsletter and images fix(openapi.yaml): correct duplicated summary text for user my newsletters * feat: update package name * feat: Update src/views/components/NewsletterList.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * feat: Update src/views/components/NewsletterList.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * feat: Update src/views/components/NewsletterList.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- docs/openapi.yaml | 303 +++++++++++++++++- readme.md | 7 +- src/cmd/root.go | 2 + src/domains/newsletter/newsletter.go | 11 + src/domains/user/account.go | 4 + src/domains/user/user.go | 1 + src/internal/rest/newsletter.go | 32 ++ src/internal/rest/user.go | 13 + src/pkg/whatsapp/whatsapp.go | 22 +- src/services/newsletter.go | 32 ++ src/services/user.go | 14 + src/validations/newsletter_validation.go | 20 ++ src/views/components/AccountAvatar.js | 25 +- src/views/components/AccountUserInfo.js | 24 +- src/views/components/MessageDelete.js | 26 +- src/views/components/MessageReact.js | 26 +- src/views/components/MessageRevoke.js | 26 +- src/views/components/MessageUpdate.js | 26 +- src/views/components/NewsletterList.js | 117 +++++++ src/views/components/SendAudio.js | 25 +- src/views/components/SendContact.js | 26 +- src/views/components/SendFile.js | 26 +- src/views/components/SendImage.js | 26 +- src/views/components/SendLocation.js | 26 +- src/views/components/SendMessage.js | 25 +- src/views/components/SendPoll.js | 26 +- src/views/components/SendVideo.js | 26 +- src/views/components/generic/FormRecipient.js | 52 +++ src/views/index.html | 17 +- 29 files changed, 766 insertions(+), 240 deletions(-) create mode 100644 src/domains/newsletter/newsletter.go create mode 100644 src/internal/rest/newsletter.go create mode 100644 src/services/newsletter.go create mode 100644 src/validations/newsletter_validation.go create mode 100644 src/views/components/NewsletterList.js create mode 100644 src/views/components/generic/FormRecipient.js diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 27ce576..71569f6 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.0 info: title: WhatsApp API MultiDevice - version: 4.2.0 + version: 4.3.0 description: This API is used for sending whatsapp via API servers: - url: http://localhost:3000 @@ -16,6 +16,8 @@ tags: description: Message manipulation (revoke/react/update). - name: group description: Group setting + - name: newsletter + description: newsletter setting paths: /app/login: get: @@ -212,6 +214,31 @@ paths: - user summary: User My List Groups responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/UserGroupResponse' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorInternalServer' + /user/my/newsletters: + get: + operationId: userMyNewsletter + tags: + - user + summary: User My List Groups + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/NewsletterResponse' '500': description: Internal Server Error content: @@ -1042,6 +1069,40 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorInternalServer' + /newsletter/unfollow: + post: + operationId: unfollowNewsletter + tags: + - newsletter + summary: Unfollow newsletter + requestBody: + content: + application/json: + schema: + type: object + properties: + newsletter_id: + type: string + example: '120363024512399999@newsletter' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/GenericResponse' + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBadRequest' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorInternalServer' components: schemas: @@ -1341,4 +1402,242 @@ components: results: type: object example: null - description: 'additional data' \ No newline at end of file + description: 'additional data' + NewsletterResponse: + type: object + properties: + code: + type: string + example: "SUCCESS" + message: + type: string + example: "Success get list newsletter" + results: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Newsletter' + Newsletter: + type: object + properties: + id: + type: string + example: "120363144038483540@newsletter" + state: + type: object + properties: + type: + type: string + example: "active" + thread_metadata: + type: object + properties: + creation_time: + type: string + example: "1688746895" + invite: + type: string + example: "0029Va4K0PZ5a245NkngBA2M" + name: + type: object + properties: + text: + type: string + example: "WhatsApp" + id: + type: string + example: "1688746895480511" + update_time: + type: string + example: "1688746895480511" + description: + type: object + properties: + text: + type: string + example: "WhatsApp’s official channel. Follow for our latest feature launches, updates, exclusive drops and more." + id: + type: string + example: "1689653839450668" + update_time: + type: string + example: "1689653839450668" + subscribers_count: + type: string + example: "0" + verification: + type: string + example: "verified" + picture: + type: object + properties: + url: + type: string + example: "" + id: + type: string + example: "1707950960975554" + type: + type: string + example: "IMAGE" + direct_path: + type: string + example: "/v/t61.24694-24/416962407_970228831134395_8869146381947923973_n.jpg?ccb=11-4&oh=01_Q5AaIIvOIeu3l0HCZWILrmr-dGR_vXFqnhUeytw0-ojPc4hL&oe=670D95B1&_nc_sid=5e03e0&_nc_cat=110" + preview: + type: object + properties: + url: + type: string + example: "" + id: + type: string + example: "1707950960975554" + type: + type: string + example: "PREVIEW" + direct_path: + type: string + example: "/v/t61.24694-24/416962407_970228831134395_8869146381947923973_n.jpg?stp=dst-jpg_s192x192&ccb=11-4&oh=01_Q5AaIHO-DQklqm3q3awF7xwji_WAn9DkgZASQA0B2Ct0qbSa&oe=670D95B1&_nc_sid=5e03e0&_nc_cat=110" + settings: + type: object + properties: + reaction_codes: + type: object + properties: + value: + type: string + example: "ALL" + viewer_metadata: + type: object + properties: + mute: + type: string + example: "off" + role: + type: string + example: "subscriber" + GroupResponse: + type: object + properties: + code: + type: string + example: "SUCCESS" + message: + type: string + example: "Success get list groups" + results: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Group' + Group: + type: object + properties: + JID: + type: string + example: "120363347168689807@g.us" + OwnerJID: + type: string + example: "6288228744537@s.whatsapp.net" + Name: + type: string + example: "Example Group" + NameSetAt: + type: string + format: date-time + example: "2024-10-11T21:27:29+07:00" + NameSetBy: + type: string + example: "6288228744537@s.whatsapp.net" + Topic: + type: string + example: "" + TopicID: + type: string + example: "" + TopicSetAt: + type: string + format: date-time + example: "0001-01-01T00:00:00Z" + TopicSetBy: + type: string + example: "" + TopicDeleted: + type: boolean + example: false + IsLocked: + type: boolean + example: false + IsAnnounce: + type: boolean + example: false + AnnounceVersionID: + type: string + example: "1728656849439709" + IsEphemeral: + type: boolean + example: false + DisappearingTimer: + type: integer + example: 0 + IsIncognito: + type: boolean + example: false + IsParent: + type: boolean + example: false + DefaultMembershipApprovalMode: + type: string + example: "" + LinkedParentJID: + type: string + example: "" + IsDefaultSubGroup: + type: boolean + example: false + IsJoinApprovalRequired: + type: boolean + example: false + GroupCreated: + type: string + format: date-time + example: "2024-10-11T21:27:29+07:00" + ParticipantVersionID: + type: string + example: "1728656849439790" + Participants: + type: array + items: + $ref: '#/components/schemas/Participant' + MemberAddMode: + type: string + example: "admin_add" + + Participant: + type: object + properties: + JID: + type: string + example: "6288228744537@s.whatsapp.net" + LID: + type: string + example: "20036609675500@lid" + IsAdmin: + type: boolean + example: true + IsSuperAdmin: + type: boolean + example: true + DisplayName: + type: string + example: "" + Error: + type: integer + example: 0 + AddRequest: + type: string + example: null \ No newline at end of file diff --git a/readme.md b/readme.md index 7f45f3d..e902f69 100644 --- a/readme.md +++ b/readme.md @@ -112,7 +112,8 @@ You can fork or edit this source code ! | ✅ | Devices | GET | /app/devices | | ✅ | User Info | GET | /user/info | | ✅ | User Avatar | GET | /user/avatar | -| ✅ | User My Group List | GET | /user/my/groups | +| ✅ | User My Groups | GET | /user/my/groups | +| ✅ | User My Newsletter | GET | /user/my/newsletters | | ✅ | User My Privacy Setting | GET | /user/my/privacy | | ✅ | Send Message | POST | /send/message | | ✅ | Send Image | POST | /send/image | @@ -135,6 +136,7 @@ You can fork or edit this source code ! | ✅ | Remove Participant in Group | POST | /group/participants/remove | | ✅ | Promote Participant in Group | POST | /group/participants/promote | | ✅ | Demote Participant in Group | POST | /group/participants/demote | +| ✅ | Unfollow Newsletter | POST | /group/newsletter/unfollow | ``` ✅ = Available @@ -145,7 +147,7 @@ You can fork or edit this source code ! | Description | Image | |--------------------|------------------------------------------------------------------------------------------| -| Homepage | ![Homepage](https://i.ibb.co.com/L0B1LVb/homepage-v4-16.png) | +| Homepage | ![Homepage](https://i.ibb.co.com/Sy0dHZp/homepage-v4-20.png) | | Login | ![Login](https://i.ibb.co.com/jkcB15R/login.png?v=1) | | Login With Code | ![Login With Code](https://i.ibb.co.com/rdJGvGw/paircode.png) | | Send Message | ![Send Message](https://i.ibb.co.com/rc3NXMX/send-message.png?v1) | @@ -167,6 +169,7 @@ You can fork or edit this source code ! | Auto Reply | ![Auto Reply](https://i.ibb.co.com/D4rTytX/IMG-20220517-162500.jpg) | | Basic Auth Prompt | ![Basic Auth Prompt](https://i.ibb.co.com/PDjQ92W/Screenshot-2022-11-06-at-14-06-29.png) | | Manage Participant | ![Manage Participant](https://i.ibb.co.com/ynrN7cr/manage-participant.png) | +| My Newsletter | ![List Newsletter](https://i.ibb.co.com/WDg50jJ/image.png) | ### Mac OS NOTE diff --git a/src/cmd/root.go b/src/cmd/root.go index e5b0406..b93c224 100644 --- a/src/cmd/root.go +++ b/src/cmd/root.go @@ -113,6 +113,7 @@ func runRest(_ *cobra.Command, _ []string) { userService := services.NewUserService(cli) messageService := services.NewMessageService(cli) groupService := services.NewGroupService(cli) + newsletterService := services.NewNewsletterService(cli) // Rest rest.InitRestApp(app, appService) @@ -120,6 +121,7 @@ func runRest(_ *cobra.Command, _ []string) { rest.InitRestUser(app, userService) rest.InitRestMessage(app, messageService) rest.InitRestGroup(app, groupService) + rest.InitRestNewsletter(app, newsletterService) app.Get("/", func(c *fiber.Ctx) error { return c.Render("views/index", fiber.Map{ diff --git a/src/domains/newsletter/newsletter.go b/src/domains/newsletter/newsletter.go new file mode 100644 index 0000000..16b1452 --- /dev/null +++ b/src/domains/newsletter/newsletter.go @@ -0,0 +1,11 @@ +package newsletter + +import "context" + +type INewsletterService interface { + Unfollow(ctx context.Context, request UnfollowRequest) (err error) +} + +type UnfollowRequest struct { + NewsletterID string `json:"newsletter_id" form:"newsletter_id"` +} diff --git a/src/domains/user/account.go b/src/domains/user/account.go index ad23e04..d424755 100644 --- a/src/domains/user/account.go +++ b/src/domains/user/account.go @@ -48,3 +48,7 @@ type MyPrivacySettingResponse struct { type MyListGroupsResponse struct { Data []types.GroupInfo `json:"data"` } + +type MyListNewsletterResponse struct { + Data []types.NewsletterMetadata `json:"data"` +} diff --git a/src/domains/user/user.go b/src/domains/user/user.go index dabca89..feb3b78 100644 --- a/src/domains/user/user.go +++ b/src/domains/user/user.go @@ -8,5 +8,6 @@ type IUserService interface { Info(ctx context.Context, request InfoRequest) (response InfoResponse, err error) Avatar(ctx context.Context, request AvatarRequest) (response AvatarResponse, err error) MyListGroups(ctx context.Context) (response MyListGroupsResponse, err error) + MyListNewsletter(ctx context.Context) (response MyListNewsletterResponse, err error) MyPrivacySetting(ctx context.Context) (response MyPrivacySettingResponse, err error) } diff --git a/src/internal/rest/newsletter.go b/src/internal/rest/newsletter.go new file mode 100644 index 0000000..0386e9f --- /dev/null +++ b/src/internal/rest/newsletter.go @@ -0,0 +1,32 @@ +package rest + +import ( + domainNewsletter "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/newsletter" + "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/utils" + "github.com/gofiber/fiber/v2" +) + +type Newsletter struct { + Service domainNewsletter.INewsletterService +} + +func InitRestNewsletter(app *fiber.App, service domainNewsletter.INewsletterService) Newsletter { + rest := Newsletter{Service: service} + app.Post("/newsletter/unfollow", rest.Unfollow) + return rest +} + +func (controller *Newsletter) Unfollow(c *fiber.Ctx) error { + var request domainNewsletter.UnfollowRequest + err := c.BodyParser(&request) + utils.PanicIfNeeded(err) + + err = controller.Service.Unfollow(c.UserContext(), request) + utils.PanicIfNeeded(err) + + return c.JSON(utils.ResponseData{ + Status: 200, + Code: "SUCCESS", + Message: "Success unfollow newsletter", + }) +} diff --git a/src/internal/rest/user.go b/src/internal/rest/user.go index dd01f5a..6621bb2 100644 --- a/src/internal/rest/user.go +++ b/src/internal/rest/user.go @@ -17,6 +17,7 @@ func InitRestUser(app *fiber.App, service domainUser.IUserService) User { app.Get("/user/avatar", rest.UserAvatar) app.Get("/user/my/privacy", rest.UserMyPrivacySetting) app.Get("/user/my/groups", rest.UserMyListGroups) + app.Get("/user/my/newsletters", rest.UserMyListNewsletter) return rest } @@ -80,3 +81,15 @@ func (controller *User) UserMyListGroups(c *fiber.Ctx) error { Results: response, }) } + +func (controller *User) UserMyListNewsletter(c *fiber.Ctx) error { + response, err := controller.Service.MyListNewsletter(c.UserContext()) + utils.PanicIfNeeded(err) + + return c.JSON(utils.ResponseData{ + Status: 200, + Code: "SUCCESS", + Message: "Success get list newsletter", + Results: response, + }) +} diff --git a/src/pkg/whatsapp/whatsapp.go b/src/pkg/whatsapp/whatsapp.go index d0f4953..77e20f8 100644 --- a/src/pkg/whatsapp/whatsapp.go +++ b/src/pkg/whatsapp/whatsapp.go @@ -107,17 +107,19 @@ func ParseJID(arg string) (types.JID, error) { } if !strings.ContainsRune(arg, '@') { return types.NewJID(arg, types.DefaultUserServer), nil - } else { - recipient, err := types.ParseJID(arg) - if err != nil { - fmt.Printf("invalid JID %s: %v", arg, err) - return recipient, pkgError.ErrInvalidJID - } else if recipient.User == "" { - fmt.Printf("invalid JID %v: no server specified", arg) - return recipient, pkgError.ErrInvalidJID - } - return recipient, nil } + + recipient, err := types.ParseJID(arg) + if err != nil { + fmt.Printf("invalid JID %s: %v", arg, err) + return recipient, pkgError.ErrInvalidJID + } + + if recipient.User == "" { + fmt.Printf("invalid JID %v: no server specified", arg) + return recipient, pkgError.ErrInvalidJID + } + return recipient, nil } func IsOnWhatsapp(waCli *whatsmeow.Client, jid string) bool { diff --git a/src/services/newsletter.go b/src/services/newsletter.go new file mode 100644 index 0000000..0afcf42 --- /dev/null +++ b/src/services/newsletter.go @@ -0,0 +1,32 @@ +package services + +import ( + "context" + domainNewsletter "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/newsletter" + "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/whatsapp" + "github.com/aldinokemal/go-whatsapp-web-multidevice/validations" + "go.mau.fi/whatsmeow" +) + +type newsletterService struct { + WaCli *whatsmeow.Client +} + +func NewNewsletterService(waCli *whatsmeow.Client) domainNewsletter.INewsletterService { + return &newsletterService{ + WaCli: waCli, + } +} + +func (service newsletterService) Unfollow(ctx context.Context, request domainNewsletter.UnfollowRequest) (err error) { + if err = validations.ValidateUnfollowNewsletter(ctx, request); err != nil { + return err + } + + JID, err := whatsapp.ValidateJidWithLogin(service.WaCli, request.NewsletterID) + if err != nil { + return err + } + + return service.WaCli.UnfollowNewsletter(JID) +} diff --git a/src/services/user.go b/src/services/user.go index a6e7b8a..19d0652 100644 --- a/src/services/user.go +++ b/src/services/user.go @@ -127,6 +127,20 @@ func (service userService) MyListGroups(_ context.Context) (response domainUser. return response, nil } +func (service userService) MyListNewsletter(_ context.Context) (response domainUser.MyListNewsletterResponse, err error) { + whatsapp.MustLogin(service.WaCli) + + datas, err := service.WaCli.GetSubscribedNewsletters() + if err != nil { + return + } + fmt.Printf("%+v\n", datas) + for _, data := range datas { + response.Data = append(response.Data, *data) + } + return response, nil +} + func (service userService) MyPrivacySetting(_ context.Context) (response domainUser.MyPrivacySettingResponse, err error) { whatsapp.MustLogin(service.WaCli) diff --git a/src/validations/newsletter_validation.go b/src/validations/newsletter_validation.go new file mode 100644 index 0000000..635d7c7 --- /dev/null +++ b/src/validations/newsletter_validation.go @@ -0,0 +1,20 @@ +package validations + +import ( + "context" + domainNewsletter "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/newsletter" + pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error" + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +func ValidateUnfollowNewsletter(ctx context.Context, request domainNewsletter.UnfollowRequest) error { + err := validation.ValidateStructWithContext(ctx, &request, + validation.Field(&request.NewsletterID, validation.Required), + ) + + if err != nil { + return pkgError.ValidationError(err.Error()) + } + + return nil +} diff --git a/src/views/components/AccountAvatar.js b/src/views/components/AccountAvatar.js index 2e602cb..e9ab15f 100644 --- a/src/views/components/AccountAvatar.js +++ b/src/views/components/AccountAvatar.js @@ -1,8 +1,13 @@ +import FormRecipient from "./generic/FormRecipient.js"; + export default { name: 'AccountAvatar', + components: { + FormRecipient + }, data() { return { - type: 'user', + type: window.TYPEUSER, phone: '', image: null, loading: false, @@ -12,7 +17,7 @@ export default { }, computed: { phone_id() { - return this.type === 'user' ? `${this.phone}@${window.TYPEUSER}` : `${this.phone}@${window.TYPEGROUP}` + return this.phone + this.type; } }, methods: { @@ -45,7 +50,7 @@ export default { handleReset() { this.phone = ''; this.image = null; - this.type = 'user'; + this.type = window.TYPEUSER; } }, template: ` @@ -66,19 +71,7 @@ export default {
-
- - -
-
- - - -
+
diff --git a/src/views/components/AccountUserInfo.js b/src/views/components/AccountUserInfo.js index a034990..8e307bf 100644 --- a/src/views/components/AccountUserInfo.js +++ b/src/views/components/AccountUserInfo.js @@ -1,8 +1,13 @@ +import FormRecipient from "./generic/FormRecipient.js"; + export default { name: 'AccountUserInfo', + components: { + FormRecipient + }, data() { return { - type: 'user', + type: window.TYPEUSER, phone: '', // name: null, @@ -15,7 +20,7 @@ export default { computed: { phone_id() { - return this.type === 'user' ? `${this.phone}@${window.TYPEUSER}` : `${this.phone}@${window.TYPEGROUP}` + return this.phone + this.type; } }, methods: { @@ -52,7 +57,7 @@ export default { this.name = null; this.status = null; this.devices = []; - this.type = 'user'; + this.type = window.TYPEUSER; } }, template: ` @@ -74,18 +79,7 @@ export default {
-
- - -
-
- - - -
+
-
- - -
-
- - - -
+ +
-
- - -
-
- - - -
+ +
-
- - -
-
- - - -
+ +
-
- - -
-
- - - -
+ +
+
+ Newsletter +
List Newsletters
+
+ Display all your newsletters +
+
+
+ + + + ` +} \ No newline at end of file diff --git a/src/views/components/SendAudio.js b/src/views/components/SendAudio.js index 7651ddd..c237e1a 100644 --- a/src/views/components/SendAudio.js +++ b/src/views/components/SendAudio.js @@ -1,15 +1,20 @@ +import FormRecipient from "./generic/FormRecipient.js"; + export default { name: 'Send', + components: { + FormRecipient + }, data() { return { phone: '', - type: 'user', + type: window.TYPEUSER, loading: false, } }, computed: { phone_id() { - return this.type === 'user' ? `${this.phone}@${window.TYPEUSER}` : `${this.phone}@${window.TYPEGROUP}` + return this.phone + this.type; } }, methods: { @@ -49,7 +54,7 @@ export default { }, handleReset() { this.phone = ''; - this.type = 'user'; + this.type = window.TYPEUSER; $("#file_audio").val(''); }, }, @@ -72,19 +77,7 @@ export default {
-
- - -
-
- - - -
+
diff --git a/src/views/components/SendContact.js b/src/views/components/SendContact.js index 2123962..21b899d 100644 --- a/src/views/components/SendContact.js +++ b/src/views/components/SendContact.js @@ -1,8 +1,13 @@ +import FormRecipient from "./generic/FormRecipient.js"; + export default { name: 'SendContact', + components: { + FormRecipient + }, data() { return { - type: 'user', + type: window.TYPEUSER, phone: '', card_name: '', card_phone: '', @@ -11,7 +16,7 @@ export default { }, computed: { phone_id() { - return this.type === 'user' ? `${this.phone}@${window.TYPEUSER}` : `${this.phone}@${window.TYPEGROUP}` + return this.phone + this.type; } }, methods: { @@ -58,7 +63,7 @@ export default { this.phone = ''; this.card_name = ''; this.card_phone = ''; - this.type = 'user'; + this.type = window.TYPEUSER; }, }, template: ` @@ -80,19 +85,8 @@ export default {
-
- - -
-
- - - -
+ +
-
- - -
-
- - - -
+ +